@uic-coe-connect/cli 0.12.0 → 0.13.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/client.js CHANGED
@@ -25,8 +25,44 @@ async function fail(res, server) {
25
25
  }
26
26
  if (res.status === 404)
27
27
  throw new CliError(`Not found: ${message}`, 4);
28
+ // A refusal the caller can act on: bad input (400), something that can't be
29
+ // done in the current state (409, e.g. "this COEConnect doesn't manage that
30
+ // VM" or "nothing is deploying"), or input the server understood and
31
+ // rejected (422). These used to fall through to the generic 1, which is not
32
+ // in the documented table at all — so a script told to branch on exit codes
33
+ // got an undocumented one for the most common recoverable failures.
34
+ if ([400, 409, 422].includes(res.status))
35
+ throw new CliError(message, 6);
28
36
  throw new CliError(message, 1);
29
37
  }
38
+ /**
39
+ * How long a plain request may take before the CLI gives up.
40
+ *
41
+ * Generous — some reads go out to a VM agent and back — but finite. Without a
42
+ * limit a server that accepts the connection and then never answers leaves the
43
+ * CLI waiting for ever, printing nothing: no spinner, no error, no exit. That
44
+ * is what `coe auth list` did in production on 2026-09-22, and the silence
45
+ * made it look like the CLI itself was broken rather than one route on the
46
+ * server.
47
+ */
48
+ const REQUEST_TIMEOUT_MS = 30_000;
49
+ /** Uploads and downloads move real files, so they get longer. */
50
+ const TRANSFER_TIMEOUT_MS = 120_000;
51
+ /**
52
+ * A deploy stream has no overall limit — a pipeline legitimately runs for
53
+ * minutes — so it is policed on *silence* instead. The server heartbeats every
54
+ * ten seconds precisely so a quiet build still looks alive, which makes a long
55
+ * gap a real failure rather than a slow step.
56
+ */
57
+ const STREAM_IDLE_TIMEOUT_MS = 120_000;
58
+ /** Turns a timeout into something that says what to do next. */
59
+ function timeoutError(server, seconds) {
60
+ return new CliError(`${server} did not respond within ${seconds}s.`, 5, "The server accepted the connection but never answered. Try again, and if it persists " +
61
+ "check the server's logs — a route that throws can stop responding without closing.");
62
+ }
63
+ function isAbort(error) {
64
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
65
+ }
30
66
  /**
31
67
  * Spread into a fetch init. Omits `body` entirely rather than passing
32
68
  * `undefined` — fetch rejects a body on GET even when it's undefined.
@@ -49,9 +85,16 @@ export function createClient(options = {}) {
49
85
  async request(method, path, body) {
50
86
  let res;
51
87
  try {
52
- res = await fetch(`${server}${path}`, { method, headers, ...bodyInit(body) });
88
+ res = await fetch(`${server}${path}`, {
89
+ method,
90
+ headers,
91
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
92
+ ...bodyInit(body),
93
+ });
53
94
  }
54
95
  catch (error) {
96
+ if (isAbort(error))
97
+ throw timeoutError(server, REQUEST_TIMEOUT_MS / 1000);
55
98
  throw new CliError(`Could not reach ${server}: ${error instanceof Error ? error.message : String(error)}`, 5, "Check the server URL, and that you're on the campus network or VPN.");
56
99
  }
57
100
  if (!res.ok)
@@ -72,40 +115,82 @@ export function createClient(options = {}) {
72
115
  "X-File-Name": encodeURIComponent(filename),
73
116
  },
74
117
  body: bytes,
118
+ signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS),
119
+ }).catch((error) => {
120
+ if (isAbort(error))
121
+ throw timeoutError(server, TRANSFER_TIMEOUT_MS / 1000);
122
+ throw error;
75
123
  });
76
124
  if (!res.ok)
77
125
  await fail(res, server);
78
126
  return (await res.json());
79
127
  },
80
128
  async download(path) {
81
- const res = await fetch(`${server}${path}`, { headers });
129
+ const res = await fetch(`${server}${path}`, {
130
+ headers,
131
+ signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS),
132
+ }).catch((error) => {
133
+ if (isAbort(error))
134
+ throw timeoutError(server, TRANSFER_TIMEOUT_MS / 1000);
135
+ throw error;
136
+ });
82
137
  if (!res.ok)
83
138
  await fail(res, server);
84
139
  return new Uint8Array(await res.arrayBuffer());
85
140
  },
86
141
  async stream(method, path, body, onLine) {
87
- const res = await fetch(`${server}${path}`, { method, headers, ...bodyInit(body) });
88
- if (!res.ok)
89
- await fail(res, server);
90
- if (!res.body)
91
- throw new CliError("The server sent no output stream.");
92
- // The deploy endpoint writes plain newline-delimited text, not SSE, so
93
- // buffer across chunk boundaries rather than assuming whole lines.
94
- const reader = res.body.getReader();
95
- const decoder = new TextDecoder();
96
- let buffer = "";
97
- for (;;) {
98
- const { done, value } = await reader.read();
99
- if (done)
100
- break;
101
- buffer += decoder.decode(value, { stream: true });
102
- const lines = buffer.split("\n");
103
- buffer = lines.pop() ?? "";
104
- for (const line of lines)
105
- onLine(line);
142
+ // No overall deadline here, only an idle one: the whole point of this
143
+ // call is to sit open while a deploy runs.
144
+ const idle = new AbortController();
145
+ let watchdog;
146
+ const resetWatchdog = () => {
147
+ if (watchdog)
148
+ clearTimeout(watchdog);
149
+ watchdog = setTimeout(() => idle.abort(), STREAM_IDLE_TIMEOUT_MS);
150
+ watchdog.unref?.();
151
+ };
152
+ resetWatchdog();
153
+ try {
154
+ const res = await fetch(`${server}${path}`, {
155
+ method,
156
+ headers,
157
+ signal: idle.signal,
158
+ ...bodyInit(body),
159
+ });
160
+ if (!res.ok)
161
+ await fail(res, server);
162
+ if (!res.body)
163
+ throw new CliError("The server sent no output stream.");
164
+ // The deploy endpoint writes plain newline-delimited text, not SSE, so
165
+ // buffer across chunk boundaries rather than assuming whole lines.
166
+ const reader = res.body.getReader();
167
+ const decoder = new TextDecoder();
168
+ let buffer = "";
169
+ for (;;) {
170
+ const { done, value } = await reader.read();
171
+ if (done)
172
+ break;
173
+ resetWatchdog();
174
+ buffer += decoder.decode(value, { stream: true });
175
+ const lines = buffer.split("\n");
176
+ buffer = lines.pop() ?? "";
177
+ for (const line of lines)
178
+ onLine(line);
179
+ }
180
+ if (buffer)
181
+ onLine(buffer);
182
+ }
183
+ catch (error) {
184
+ if (isAbort(error)) {
185
+ throw new CliError(`${server} sent nothing for ${STREAM_IDLE_TIMEOUT_MS / 1000}s.`, 5, "A running deploy heartbeats every 10s, so this means the connection died — " +
186
+ "the deploy itself may still be going. Check: coe builds <app>");
187
+ }
188
+ throw error;
189
+ }
190
+ finally {
191
+ if (watchdog)
192
+ clearTimeout(watchdog);
106
193
  }
107
- if (buffer)
108
- onLine(buffer);
109
194
  },
110
195
  };
111
196
  }
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "../client.js";
2
- import { details, emit, info } from "../output.js";
2
+ import { details, emit, info, success } from "../output.js";
3
3
  import { register } from "../registry.js";
4
4
  import { resolveApp } from "./apps.js";
5
5
  function currentAccess(app) {
@@ -42,7 +42,7 @@ export function registerAccessCommands() {
42
42
  const access = currentAccess(app);
43
43
  const devTeam = [...new Set([...access.devTeam, ...toAdd])];
44
44
  await saveAccess(c, app.id, { ...access, devTeam });
45
- emit({ appId: app.id, devTeam, added: toAdd }, () => info(`✓ Dev team for ${app.id}: ${devTeam.join(", ")}`));
45
+ emit({ appId: app.id, devTeam, added: toAdd }, () => success(`Dev team for ${app.id}: ${devTeam.join(", ")}`));
46
46
  },
47
47
  }, {
48
48
  name: "access remove-dev",
@@ -60,7 +60,7 @@ export function registerAccessCommands() {
60
60
  throw new CliError(`That removes everyone from ${app.id}'s dev team, leaving it webadmin-only.`, 6, "Re-run with --yes if that's intended.");
61
61
  }
62
62
  await saveAccess(c, app.id, { ...access, devTeam });
63
- emit({ appId: app.id, devTeam, removed: [...toRemove] }, () => info(`✓ Dev team for ${app.id}: ${devTeam.join(", ") || "(none)"}`));
63
+ emit({ appId: app.id, devTeam, removed: [...toRemove] }, () => success(`Dev team for ${app.id}: ${devTeam.join(", ") || "(none)"}`));
64
64
  },
65
65
  }, {
66
66
  name: "access set-staff",
@@ -79,7 +79,7 @@ export function registerAccessCommands() {
79
79
  staff: { ...access.staff, everyone: mode === "everyone" },
80
80
  };
81
81
  await saveAccess(c, app.id, next);
82
- emit({ appId: app.id, staffAccess: mode }, () => info(`✓ Staff access for ${app.id}: ${mode}`));
82
+ emit({ appId: app.id, staffAccess: mode }, () => success(`Staff access for ${app.id}: ${mode}`));
83
83
  },
84
84
  }, {
85
85
  name: "access add-staff",
@@ -93,7 +93,7 @@ export function registerAccessCommands() {
93
93
  const members = [...new Set([...access.staff.members, ...toAdd])];
94
94
  await saveAccess(c, app.id, { ...access, staff: { ...access.staff, members } });
95
95
  emit({ appId: app.id, members, added: toAdd }, () => {
96
- info(`✓ Staff members for ${app.id}: ${members.join(", ")}`);
96
+ success(`Staff members for ${app.id}: ${members.join(", ")}`);
97
97
  if (access.staff.everyone) {
98
98
  info(" note: staff access is still 'everyone', so this list isn't enforced yet.");
99
99
  }
@@ -110,7 +110,7 @@ export function registerAccessCommands() {
110
110
  const access = currentAccess(app);
111
111
  const members = access.staff.members.filter((n) => !toRemove.has(n));
112
112
  await saveAccess(c, app.id, { ...access, staff: { ...access.staff, members } });
113
- emit({ appId: app.id, members, removed: [...toRemove] }, () => info(`✓ Staff members for ${app.id}: ${members.join(", ") || "(none)"}`));
113
+ emit({ appId: app.id, members, removed: [...toRemove] }, () => success(`Staff members for ${app.id}: ${members.join(", ") || "(none)"}`));
114
114
  },
115
115
  }, {
116
116
  name: "access block",
@@ -123,7 +123,7 @@ export function registerAccessCommands() {
123
123
  const access = currentAccess(app);
124
124
  const exceptions = [...new Set([...access.staff.exceptions, ...toAdd])];
125
125
  await saveAccess(c, app.id, { ...access, staff: { ...access.staff, exceptions } });
126
- emit({ appId: app.id, blacklist: exceptions, added: toAdd }, () => info(`✓ Blacklist for ${app.id}: ${exceptions.join(", ")}`));
126
+ emit({ appId: app.id, blacklist: exceptions, added: toAdd }, () => success(`Blacklist for ${app.id}: ${exceptions.join(", ")}`));
127
127
  },
128
128
  }, {
129
129
  name: "access unblock",
@@ -136,7 +136,7 @@ export function registerAccessCommands() {
136
136
  const access = currentAccess(app);
137
137
  const exceptions = access.staff.exceptions.filter((n) => !toRemove.has(n));
138
138
  await saveAccess(c, app.id, { ...access, staff: { ...access.staff, exceptions } });
139
- emit({ appId: app.id, blacklist: exceptions, removed: [...toRemove] }, () => info(`✓ Blacklist for ${app.id}: ${exceptions.join(", ") || "(none)"}`));
139
+ emit({ appId: app.id, blacklist: exceptions, removed: [...toRemove] }, () => success(`Blacklist for ${app.id}: ${exceptions.join(", ") || "(none)"}`));
140
140
  },
141
141
  });
142
142
  }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { CliError, createClient } from "../client.js";
3
3
  import { clearCredentials, credentialsPath, readCredentials, resolveServer, saveCredentials, saveServer, sessionLabel, } from "../config.js";
4
- import { details, emit, info, isJsonMode } from "../output.js";
4
+ import { details, emit, info, isJsonMode, style, success } from "../output.js";
5
5
  import { register } from "../registry.js";
6
6
  /** Best-effort browser open. Never fatal — the URL is always printed too. */
7
7
  function openBrowser(url) {
@@ -80,7 +80,7 @@ export function registerAuthCommands() {
80
80
  const all = args.bool("all");
81
81
  const result = await client.request("DELETE", `/cli/sessions${all ? "?all=true" : ""}`);
82
82
  clearCredentials();
83
- emit({ loggedOut: true, revoked: result.revoked }, () => info(`✓ Signed out (${result.revoked} session${result.revoked === 1 ? "" : "s"} revoked)`));
83
+ emit({ loggedOut: true, revoked: result.revoked }, () => success(`Signed out (${result.revoked} session${result.revoked === 1 ? "" : "s"} revoked)`));
84
84
  },
85
85
  }, {
86
86
  name: "whoami",
@@ -112,10 +112,19 @@ export function registerAuthCommands() {
112
112
  }
113
113
  for (const s of sessions) {
114
114
  details([
115
- ["Label", s.label],
115
+ ["Label", s.current ? `${s.label} ${style.ok("(this terminal)")}` : s.label],
116
116
  ["Created", new Date(s.createdAt).toLocaleString()],
117
117
  ["Expires", new Date(s.expiresAt).toLocaleString()],
118
- ["Last used", s.lastUsedAt ? new Date(s.lastUsedAt).toLocaleString() : "never"],
118
+ [
119
+ "Last used",
120
+ // Your own row reads "never" because authenticating stamps the
121
+ // timestamp after this read — say so rather than look wrong.
122
+ s.current
123
+ ? "now"
124
+ : s.lastUsedAt
125
+ ? new Date(s.lastUsedAt).toLocaleString()
126
+ : "never",
127
+ ],
119
128
  ]);
120
129
  info("");
121
130
  }
@@ -131,7 +140,7 @@ export function registerAuthCommands() {
131
140
  }
132
141
  const result = await client().request("DELETE", "/cli/sessions?all=true");
133
142
  clearCredentials();
134
- emit(result, () => info(`✓ Revoked ${result.revoked} session(s)`));
143
+ emit(result, () => success(`Revoked ${result.revoked} session(s)`));
135
144
  },
136
145
  }, {
137
146
  name: "auth status",
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "../client.js";
2
- import { emit, info, isJsonMode, table } from "../output.js";
2
+ import { emit, info, isJsonMode, table, style } from "../output.js";
3
3
  import { register } from "../registry.js";
4
4
  import { resolveApp } from "./apps.js";
5
5
  /** Sentinels the deploy stream ends with — the server's own success signal. */
@@ -11,7 +11,7 @@ const CANCELLED = "__DEPLOY_CANCELLED__";
11
11
  * They're meaningless here and read as debug noise, so they never reach the
12
12
  * console or the JSON log — the stream already carries a human line for each.
13
13
  */
14
- const MARKER = /^__(NODE|COMMIT|PREV)__:/;
14
+ const MARKER = /^__(NODE|STEPS|COMMIT|PREV)__:/;
15
15
  /**
16
16
  * The engine's marker for a line worth remembering after the run.
17
17
  *
@@ -173,14 +173,13 @@ export function registerDeployCommands() {
173
173
  async run({ args, client }) {
174
174
  const c = client();
175
175
  const app = await resolveApp(c, args.arg(0, "app"));
176
- const limit = Number(args.flag("limit") ?? 20);
177
- const offset = Number(args.flag("offset") ?? 0);
178
- const { builds, total } = await c.request("GET", `/registered-apps/${app.id}/builds?limit=${Number.isFinite(limit) ? limit : 20}` +
179
- `&offset=${Number.isFinite(offset) ? offset : 0}`);
176
+ const limit = args.num("limit", 20);
177
+ const offset = args.num("offset", 0);
178
+ const { builds, total } = await c.request("GET", `/registered-apps/${app.id}/builds?limit=${limit}&offset=${offset}`);
180
179
  emit({ appId: app.id, builds, total, offset }, () => {
181
180
  table(builds.map((b) => ({
182
181
  started: new Date(b.startedAt).toLocaleString(),
183
- status: b.status,
182
+ status: style.state(b.status),
184
183
  version: b.version ?? "—",
185
184
  pipeline: b.pipelineName ?? "—",
186
185
  source: b.source,
@@ -240,9 +239,8 @@ export function registerDeployCommands() {
240
239
  if (!branch) {
241
240
  throw new CliError(`${app.id} has no default branch set.`, 4, "Name one with --branch, or set it on the app.");
242
241
  }
243
- const limit = Number(args.flag("limit") ?? 25);
244
- const { commits } = await c.request("GET", `/registered-apps/${app.id}/commits?branch=${encodeURIComponent(branch)}` +
245
- `&limit=${Number.isFinite(limit) ? limit : 25}`);
242
+ const limit = args.num("limit", 25);
243
+ const { commits } = await c.request("GET", `/registered-apps/${app.id}/commits?branch=${encodeURIComponent(branch)}&limit=${limit}`);
246
244
  emit({ appId: app.id, branch, commits }, () => {
247
245
  table(commits.map((x) => ({
248
246
  commit: x.sha,
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { CliError } from "../client.js";
3
- import { emit, info, table } from "../output.js";
3
+ import { emit, info, success, table } from "../output.js";
4
4
  import { register } from "../registry.js";
5
5
  import { resolveApp } from "./apps.js";
6
6
  /**
@@ -95,7 +95,7 @@ export function registerEnvCommands() {
95
95
  // clobbers the app's name/url/icon the way a full record PUT would.
96
96
  const next = { ...env, ...updates };
97
97
  await c.request("PUT", `/registered-apps/${app.id}/env`, { env: next });
98
- emit({ appId: app.id, set: Object.keys(updates), count: Object.keys(next).length }, () => info(`✓ Set ${Object.keys(updates).join(", ")} on ${app.id}`));
98
+ emit({ appId: app.id, set: Object.keys(updates), count: Object.keys(next).length }, () => success(`Set ${Object.keys(updates).join(", ")} on ${app.id}`));
99
99
  },
100
100
  }, {
101
101
  name: "env unset",
@@ -116,7 +116,7 @@ export function registerEnvCommands() {
116
116
  for (const key of keys)
117
117
  delete next[key];
118
118
  await c.request("PUT", `/registered-apps/${app.id}/env`, { env: next });
119
- emit({ appId: app.id, removed: keys, count: Object.keys(next).length }, () => info(`✓ Removed ${keys.join(", ")} from ${app.id}`));
119
+ emit({ appId: app.id, removed: keys, count: Object.keys(next).length }, () => success(`Removed ${keys.join(", ")} from ${app.id}`));
120
120
  },
121
121
  });
122
122
  }
@@ -22,7 +22,7 @@ export function registerLogCommands() {
22
22
  const c = client();
23
23
  const app = await resolveApp(c, args.arg(0, "app"));
24
24
  const stream = args.bool("out") ? "out" : "err";
25
- const tail = Number(args.flag("tail") ?? 100);
25
+ const tail = args.num("tail", 100);
26
26
  if (!Number.isFinite(tail) || tail < 1) {
27
27
  throw new CliError(`--tail must be a positive number, got "${args.flag("tail")}".`, 6);
28
28
  }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { CliError } from "../client.js";
3
- import { emit, info, table } from "../output.js";
3
+ import { emit, info, success, table } from "../output.js";
4
4
  import { register } from "../registry.js";
5
5
  import { resolveApp } from "./apps.js";
6
6
  /**
@@ -202,7 +202,7 @@ export function registerPipelineCommands() {
202
202
  pipelines.push({ id: "", name, config: { steps } });
203
203
  const updated = await savePipelines(c, app.id, pipelines);
204
204
  const created = (updated.pipelines ?? []).find((p) => p.name === name);
205
- emit({ appId: app.id, pipeline: created }, () => info(`✓ Created pipeline "${name}" on ${app.id} (${steps.length} steps)`));
205
+ emit({ appId: app.id, pipeline: created }, () => success(`Created pipeline "${name}" on ${app.id} (${steps.length} steps)`));
206
206
  },
207
207
  }, {
208
208
  name: "pipelines set-steps",
@@ -217,7 +217,7 @@ export function registerPipelineCommands() {
217
217
  throw new CliError("A pipeline needs at least one step.", 6);
218
218
  const pipelines = (app.pipelines ?? []).map((p) => p.id === pipeline.id ? { ...p, config: { steps } } : p);
219
219
  await savePipelines(c, app.id, pipelines);
220
- emit({ appId: app.id, pipelineId: pipeline.id, steps }, () => info(`✓ Updated "${pipeline.name}" on ${app.id} (${steps.length} steps)`));
220
+ emit({ appId: app.id, pipelineId: pipeline.id, steps }, () => success(`Updated "${pipeline.name}" on ${app.id} (${steps.length} steps)`));
221
221
  },
222
222
  }, {
223
223
  name: "pipelines rename",
@@ -230,7 +230,7 @@ export function registerPipelineCommands() {
230
230
  const newName = args.arg(2, "new-name");
231
231
  const pipelines = (app.pipelines ?? []).map((p) => p.id === pipeline.id ? { ...p, name: newName } : p);
232
232
  await savePipelines(c, app.id, pipelines);
233
- emit({ appId: app.id, pipelineId: pipeline.id, name: newName }, () => info(`✓ Renamed "${pipeline.name}" → "${newName}"`));
233
+ emit({ appId: app.id, pipelineId: pipeline.id, name: newName }, () => success(`Renamed "${pipeline.name}" → "${newName}"`));
234
234
  },
235
235
  }, {
236
236
  name: "pipelines delete",
@@ -245,7 +245,7 @@ export function registerPipelineCommands() {
245
245
  }
246
246
  const pipelines = (app.pipelines ?? []).filter((p) => p.id !== pipeline.id);
247
247
  await savePipelines(c, app.id, pipelines);
248
- emit({ appId: app.id, deleted: pipeline.id }, () => info(`✓ Deleted pipeline "${pipeline.name}" from ${app.id}`));
248
+ emit({ appId: app.id, deleted: pipeline.id }, () => success(`Deleted pipeline "${pipeline.name}" from ${app.id}`));
249
249
  },
250
250
  });
251
251
  }
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { CliError } from "../client.js";
4
- import { details, emit, info, table } from "../output.js";
4
+ import { details, emit, info, success, table } from "../output.js";
5
5
  import { register } from "../registry.js";
6
6
  /**
7
7
  * Bug reports and feature requests from the terminal.
@@ -184,7 +184,7 @@ export function registerReportCommands() {
184
184
  const message = args.arg(1, "message");
185
185
  const status = args.flag("status") ? assertStatus(args.flag("status")) : undefined;
186
186
  const { report } = await client().request("POST", `/reports/${id}/comments`, { body: message, status });
187
- emit({ report }, () => info(`✓ Replied on ${report.id}${status ? ` — now ${report.status}` : ""}`));
187
+ emit({ report }, () => success(`Replied on ${report.id}${status ? ` — now ${report.status}` : ""}`));
188
188
  },
189
189
  }, {
190
190
  name: "reports status",
@@ -198,7 +198,7 @@ export function registerReportCommands() {
198
198
  const id = args.arg(0, "id");
199
199
  const status = assertStatus(args.arg(1, "status"));
200
200
  const { report } = await client().request("POST", `/reports/${id}/comments`, { body: args.flag("note") ?? "", status });
201
- emit({ report }, () => info(`✓ ${report.id} is now ${report.status}`));
201
+ emit({ report }, () => success(`${report.id} is now ${report.status}`));
202
202
  },
203
203
  }, {
204
204
  name: "reports close",
@@ -213,7 +213,7 @@ export function registerReportCommands() {
213
213
  const id = args.arg(0, "id");
214
214
  const status = args.bool("wont-do") ? "declined" : "done";
215
215
  const { report } = await client().request("POST", `/reports/${id}/comments`, { body: args.flag("note") ?? "", status });
216
- emit({ report }, () => info(`✓ Closed ${report.id} as ${report.status}`));
216
+ emit({ report }, () => success(`Closed ${report.id} as ${report.status}`));
217
217
  },
218
218
  }, {
219
219
  name: "reports reopen",
@@ -222,7 +222,7 @@ export function registerReportCommands() {
222
222
  async run({ args, client }) {
223
223
  const id = args.arg(0, "id");
224
224
  const { report } = await client().request("POST", `/reports/${id}/comments`, { body: args.flag("note") ?? "", status: "open" });
225
- emit({ report }, () => info(`✓ Reopened ${report.id}`));
225
+ emit({ report }, () => success(`Reopened ${report.id}`));
226
226
  },
227
227
  }, {
228
228
  name: "reports attach",
@@ -244,7 +244,7 @@ export function registerReportCommands() {
244
244
  throw new CliError(`Can't read ${filePath}: ${error instanceof Error ? error.message : String(error)}`, 4);
245
245
  }
246
246
  const file = await client().upload(`/reports/${id}/attachments`, basename(filePath), bytes);
247
- emit({ attachment: file }, () => info(`✓ Attached ${file.name} (${humanSize(file.size)}) to ${id}`));
247
+ emit({ attachment: file }, () => success(`Attached ${file.name} (${humanSize(file.size)}) to ${id}`));
248
248
  },
249
249
  }, {
250
250
  name: "reports download",
@@ -258,7 +258,7 @@ export function registerReportCommands() {
258
258
  const bytes = await c.download(`/reports/${id}/attachments/${file.id}`);
259
259
  const out = args.flag("out") ?? file.name;
260
260
  writeFileSync(out, bytes);
261
- emit({ reportId: id, attachment: file.name, out, bytes: bytes.length }, () => info(`✓ Wrote ${out} (${humanSize(bytes.length)})`));
261
+ emit({ reportId: id, attachment: file.name, out, bytes: bytes.length }, () => success(`Wrote ${out} (${humanSize(bytes.length)})`));
262
262
  },
263
263
  }, {
264
264
  name: "reports file",
@@ -290,7 +290,7 @@ export function registerReportCommands() {
290
290
  attachment = await c.upload(`/reports/${report.id}/attachments`, basename(attach), readFileSync(attach));
291
291
  }
292
292
  catch (error) {
293
- info(`✓ Filed ${report.id} — ${report.title}`);
293
+ success(`Filed ${report.id} — ${report.title}`);
294
294
  throw new CliError(`The report was filed, but ${attach} did not attach: ` +
295
295
  (error instanceof Error ? error.message : String(error)), 1, `Try again with: coe reports attach ${report.id} ${attach}`);
296
296
  }
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { CliError } from "../client.js";
4
- import { details, emit, info, table } from "../output.js";
4
+ import { details, emit, success, table } from "../output.js";
5
5
  import { register } from "../registry.js";
6
6
  import { resolveApp } from "./apps.js";
7
7
  function humanSize(bytes) {
@@ -70,7 +70,7 @@ export function registerResourceCommands() {
70
70
  if (description) {
71
71
  await c.request("PATCH", `/registered-apps/${app.id}/files/${file.id}`, { description });
72
72
  }
73
- emit({ appId: app.id, file }, () => info(`✓ Uploaded ${file.name} (${humanSize(file.size)}) to ${app.id}`));
73
+ emit({ appId: app.id, file }, () => success(`Uploaded ${file.name} (${humanSize(file.size)}) to ${app.id}`));
74
74
  },
75
75
  }, {
76
76
  name: "files download",
@@ -87,7 +87,7 @@ export function registerResourceCommands() {
87
87
  const bytes = await c.download(`/registered-apps/${app.id}/files/${file.id}`);
88
88
  const out = args.flag("out") ?? file.name;
89
89
  writeFileSync(out, bytes);
90
- emit({ appId: app.id, file: file.name, out, bytes: bytes.length }, () => info(`✓ Wrote ${out} (${humanSize(bytes.length)})`));
90
+ emit({ appId: app.id, file: file.name, out, bytes: bytes.length }, () => success(`Wrote ${out} (${humanSize(bytes.length)})`));
91
91
  },
92
92
  }, {
93
93
  name: "files delete",
@@ -102,7 +102,7 @@ export function registerResourceCommands() {
102
102
  throw new CliError(`This deletes "${file.name}" from ${app.id}.`, 6, "Re-run with --yes to confirm.");
103
103
  }
104
104
  await c.request("DELETE", `/registered-apps/${app.id}/files/${file.id}`);
105
- emit({ appId: app.id, deleted: file.name }, () => info(`✓ Deleted ${file.name} from ${app.id}`));
105
+ emit({ appId: app.id, deleted: file.name }, () => success(`Deleted ${file.name} from ${app.id}`));
106
106
  },
107
107
  }, {
108
108
  name: "files link",
@@ -118,7 +118,7 @@ export function registerResourceCommands() {
118
118
  name: args.flag("name"),
119
119
  description: args.flag("description"),
120
120
  });
121
- emit({ appId: app.id, link }, () => info(`✓ Linked "${link.name}" → ${link.url}`));
121
+ emit({ appId: app.id, link }, () => success(`Linked "${link.name}" → ${link.url}`));
122
122
  },
123
123
  }, {
124
124
  name: "files show",
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "../client.js";
2
- import { emit, info, table } from "../output.js";
2
+ import { emit, info, style, success, table } from "../output.js";
3
3
  import { register } from "../registry.js";
4
4
  import { resolveApp } from "./apps.js";
5
5
  export function registerTipCommands() {
@@ -47,7 +47,7 @@ export function registerTipCommands() {
47
47
  return;
48
48
  }
49
49
  if (result.alreadyReviewed === "accepted") {
50
- info("This trigger already has accepted advice. Counted, not queued again.");
50
+ success("This trigger already has accepted advice. Counted, not queued again.");
51
51
  return;
52
52
  }
53
53
  info(result.created
@@ -76,7 +76,7 @@ export function registerTipCommands() {
76
76
  }
77
77
  table(tips.map((t) => ({
78
78
  id: t.id,
79
- status: t.status,
79
+ status: style.state(t.status),
80
80
  kind: t.kind,
81
81
  seen: String(t.occurrences),
82
82
  apps: t.appIds.join(", "),
@@ -107,7 +107,7 @@ export function registerTipCommands() {
107
107
  status,
108
108
  note: args.flag("note"),
109
109
  });
110
- emit({ tip }, () => info(`✓ ${tip.id} marked ${tip.status}.`));
110
+ emit({ tip }, () => success(`${tip.id} marked ${tip.status}.`));
111
111
  },
112
112
  });
113
113
  }
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { registerReportCommands } from "./commands/reports.js";
14
14
  import { registerResourceCommands } from "./commands/resources.js";
15
15
  import { registerTipCommands } from "./commands/tips.js";
16
16
  import { registerVmCommands } from "./commands/vms.js";
17
- import { info, setJsonMode } from "./output.js";
17
+ import { failure, setJsonMode } from "./output.js";
18
18
  import { allCommands, findCommand, register } from "./registry.js";
19
19
  registerAuthCommands();
20
20
  registerAppCommands();
@@ -71,6 +71,19 @@ function parseArgs(argv) {
71
71
  bool(name) {
72
72
  return flags[name] !== undefined;
73
73
  },
74
+ num(name, fallback) {
75
+ const raw = flags[name];
76
+ if (raw === undefined)
77
+ return fallback;
78
+ if (raw === true) {
79
+ throw new CliError(`--${name} needs a number.`, 6, `e.g. --${name} ${fallback}`);
80
+ }
81
+ const value = Number(raw);
82
+ if (!Number.isFinite(value)) {
83
+ throw new CliError(`--${name} must be a number, not "${raw}".`, 6);
84
+ }
85
+ return value;
86
+ },
74
87
  };
75
88
  }
76
89
  function printHelp() {
@@ -125,11 +138,9 @@ async function main() {
125
138
  }
126
139
  main().catch((error) => {
127
140
  if (error instanceof CliError) {
128
- info(`error: ${error.message}`);
129
- if (error.hint)
130
- info(` ${error.hint}`);
141
+ failure(error.message, error.hint);
131
142
  process.exit(error.exitCode);
132
143
  }
133
- info(`error: ${error instanceof Error ? error.message : String(error)}`);
144
+ failure(error instanceof Error ? error.message : String(error));
134
145
  process.exit(1);
135
146
  });
package/dist/output.d.ts CHANGED
@@ -8,12 +8,29 @@
8
8
  */
9
9
  export declare function setJsonMode(on: boolean): void;
10
10
  export declare function isJsonMode(): boolean;
11
+ /** Marks a value for the reader's eye. Safe to pass into `table` or `details`. */
12
+ export declare const style: {
13
+ ok: (t: string) => string;
14
+ bad: (t: string) => string;
15
+ warn: (t: string) => string;
16
+ muted: (t: string) => string;
17
+ strong: (t: string) => string;
18
+ id: (t: string) => string;
19
+ /** A build/process state, coloured by what it means rather than by name. */
20
+ state: (t: string) => string;
21
+ };
11
22
  /** The command's result. In JSON mode this is the only thing on stdout. */
12
23
  export declare function emit(data: unknown, human: () => void): void;
13
24
  /** Progress/status chatter. Always stderr, so it never pollutes piped JSON. */
14
25
  export declare function info(message: string): void;
15
26
  export declare function warn(message: string): void;
27
+ /** A command that did what it was asked. */
28
+ export declare function success(message: string): void;
29
+ /** How a failure is rendered, including the hint that says what to do next. */
30
+ export declare function failure(message: string, hint?: string): void;
16
31
  /** Minimal column alignment — no dependency, and stable enough to eyeball. */
17
32
  export declare function table(rows: Array<Record<string, string>>, columns: string[]): void;
18
33
  /** Key/value block for `show`-style commands. */
19
34
  export declare function details(pairs: Array<[string, string]>): void;
35
+ /** A heading above a block, for commands that print more than one. */
36
+ export declare function heading(text: string): void;
package/dist/output.js CHANGED
@@ -13,6 +13,71 @@ export function setJsonMode(on) {
13
13
  export function isJsonMode() {
14
14
  return jsonMode;
15
15
  }
16
+ // ---- colour ----------------------------------------------------------------
17
+ /**
18
+ * Colour only when a person is actually looking at that stream.
19
+ *
20
+ * Checked per stream, not once: stdout is frequently redirected while stderr
21
+ * is still a terminal (`coe builds app > builds.txt`), and escape codes in the
22
+ * redirected half would corrupt a file someone is about to parse. `NO_COLOR`
23
+ * is honoured because it is the convention, and `FORCE_COLOR` exists so output
24
+ * can be inspected through a pipe when working on this file.
25
+ */
26
+ function allowColor(stream) {
27
+ if (process.env.NO_COLOR !== undefined)
28
+ return false;
29
+ if (process.env.FORCE_COLOR)
30
+ return true;
31
+ return stream.isTTY === true && process.env.TERM !== "dumb";
32
+ }
33
+ const CODES = {
34
+ reset: "\u001B[0m",
35
+ bold: "\u001B[1m",
36
+ dim: "\u001B[2m",
37
+ red: "\u001B[31m",
38
+ green: "\u001B[32m",
39
+ yellow: "\u001B[33m",
40
+ blue: "\u001B[34m",
41
+ cyan: "\u001B[36m",
42
+ };
43
+ function paint(text, style, stream) {
44
+ return allowColor(stream) ? `${CODES[style]}${text}${CODES.reset}` : text;
45
+ }
46
+ /** Style for stdout (data). Never colours in JSON mode. */
47
+ const out = (text, style) => jsonMode ? text : paint(text, style, process.stdout);
48
+ /** Style for stderr (chatter). */
49
+ const err = (text, style) => paint(text, style, process.stderr);
50
+ /**
51
+ * Length as a terminal renders it.
52
+ *
53
+ * Column widths are computed from cell contents, and an escape sequence is
54
+ * zero columns wide but several characters long — measuring the raw string
55
+ * makes every coloured cell over-padded and the table ragged.
56
+ */
57
+ // eslint-disable-next-line no-control-regex
58
+ const ANSI = /\u001B\[[0-9;]*m/g;
59
+ const visibleLength = (text) => text.replace(ANSI, "").length;
60
+ /** Marks a value for the reader's eye. Safe to pass into `table` or `details`. */
61
+ export const style = {
62
+ ok: (t) => out(t, "green"),
63
+ bad: (t) => out(t, "red"),
64
+ warn: (t) => out(t, "yellow"),
65
+ muted: (t) => out(t, "dim"),
66
+ strong: (t) => out(t, "bold"),
67
+ id: (t) => out(t, "cyan"),
68
+ /** A build/process state, coloured by what it means rather than by name. */
69
+ state: (t) => {
70
+ const s = t.toLowerCase();
71
+ if (["ok", "online", "up", "done", "accepted", "active", "yes"].includes(s))
72
+ return style.ok(t);
73
+ if (["failed", "error", "down", "stopped", "rejected", "errored"].includes(s))
74
+ return style.bad(t);
75
+ if (["cancelled", "running", "new", "pending", "queued", "unknown"].includes(s))
76
+ return style.warn(t);
77
+ return t;
78
+ },
79
+ };
80
+ // ---- writers ---------------------------------------------------------------
16
81
  /** The command's result. In JSON mode this is the only thing on stdout. */
17
82
  export function emit(data, human) {
18
83
  if (jsonMode) {
@@ -26,18 +91,29 @@ export function info(message) {
26
91
  process.stderr.write(`${message}\n`);
27
92
  }
28
93
  export function warn(message) {
29
- process.stderr.write(`warning: ${message}\n`);
94
+ process.stderr.write(`${err("warning:", "yellow")} ${message}\n`);
95
+ }
96
+ /** A command that did what it was asked. */
97
+ export function success(message) {
98
+ process.stderr.write(`${err("✓", "green")} ${message}\n`);
99
+ }
100
+ /** How a failure is rendered, including the hint that says what to do next. */
101
+ export function failure(message, hint) {
102
+ process.stderr.write(`${err("error:", "red")} ${message}\n`);
103
+ if (hint)
104
+ process.stderr.write(`${err(` ${hint}`, "dim")}\n`);
30
105
  }
31
106
  /** Minimal column alignment — no dependency, and stable enough to eyeball. */
32
107
  export function table(rows, columns) {
33
108
  if (rows.length === 0) {
34
- info("(none)");
109
+ info(err("(none)", "dim"));
35
110
  return;
36
111
  }
37
- const width = (col) => Math.max(col.length, ...rows.map((r) => (r[col] ?? "").length));
112
+ const width = (col) => Math.max(col.length, ...rows.map((r) => visibleLength(r[col] ?? "")));
38
113
  const widths = columns.map(width);
39
- const line = (cells) => cells.map((c, i) => c.padEnd(i === cells.length - 1 ? 0 : widths[i])).join(" ").trimEnd();
40
- process.stdout.write(`${line(columns.map((c) => c.toUpperCase()))}\n`);
114
+ const pad = (cell, i, last) => last ? cell : cell + " ".repeat(Math.max(0, widths[i] - visibleLength(cell)));
115
+ const line = (cells) => cells.map((c, i) => pad(c, i, i === cells.length - 1)).join(" ").trimEnd();
116
+ process.stdout.write(`${out(line(columns.map((c) => c.toUpperCase())), "dim")}\n`);
41
117
  for (const row of rows) {
42
118
  process.stdout.write(`${line(columns.map((c) => row[c] ?? ""))}\n`);
43
119
  }
@@ -46,6 +122,10 @@ export function table(rows, columns) {
46
122
  export function details(pairs) {
47
123
  const width = Math.max(...pairs.map(([k]) => k.length));
48
124
  for (const [k, v] of pairs) {
49
- process.stdout.write(`${k.padEnd(width)} ${v}\n`);
125
+ process.stdout.write(`${out(k.padEnd(width), "dim")} ${v}\n`);
50
126
  }
51
127
  }
128
+ /** A heading above a block, for commands that print more than one. */
129
+ export function heading(text) {
130
+ process.stderr.write(`\n${err(text.toUpperCase(), "bold")}\n`);
131
+ }
@@ -12,6 +12,14 @@ export interface Args {
12
12
  /** A flag's value, or undefined. `--flag` with no value reads as true. */
13
13
  flag(name: string): string | undefined;
14
14
  bool(name: string): boolean;
15
+ /**
16
+ * A numeric flag, or the fallback when it wasn't given.
17
+ *
18
+ * Rejects a value that isn't a number instead of quietly falling back:
19
+ * `--limit abc` used to print the default twenty rows and exit 0, which
20
+ * reads exactly like a limit that worked.
21
+ */
22
+ num(name: string, fallback: number): number;
15
23
  }
16
24
  export interface CommandContext {
17
25
  args: Args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uic-coe-connect/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "The coe CLI \u2014 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": {