@uic-coe-connect/cli 0.11.0 → 0.13.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/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,25 +1,41 @@
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. */
6
6
  const OK = "__DEPLOY_OK__";
7
7
  const FAILED = "__DEPLOY_FAILED__";
8
+ const CANCELLED = "__DEPLOY_CANCELLED__";
8
9
  /**
9
10
  * Machine markers the server interleaves for the web UI's pipeline diagram.
10
11
  * They're meaningless here and read as debug noise, so they never reach the
11
12
  * console or the JSON log — the stream already carries a human line for each.
12
13
  */
13
- const MARKER = /^__(NODE|COMMIT)__:/;
14
+ const MARKER = /^__(NODE|COMMIT|PREV)__:/;
15
+ /**
16
+ * The engine's marker for a line worth remembering after the run.
17
+ *
18
+ * Unlike the others it carries text a person should read, so it is unwrapped
19
+ * rather than dropped — the summary at the end counts the same lines.
20
+ */
21
+ const WARN_MARKER = "__WARN__:";
14
22
  export function registerDeployCommands() {
15
23
  register({
16
24
  name: "deploy",
17
25
  summary: "Run a deploy pipeline, streaming its output",
18
- usage: "deploy <app> [--pipeline <p>] [--branch <b>|--current] [--version <v>]",
26
+ usage: "deploy <app> [--pipeline <p>] [--branch <b>|--commit <sha>] [--version <v>] [--confirm-script]",
19
27
  details: [
20
28
  "Streams the live console. Exits 0 only if the deploy succeeded, so it's",
21
29
  "safe to chain. A pipeline containing a DB migrate step needs",
22
30
  "--confirm-migrate, matching the confirmation the web UI asks for.",
31
+ "",
32
+ "--commit deploys an exact commit rather than a branch tip, which is what",
33
+ "a rollback is: `coe builds <app>` prints each build's previous commit.",
34
+ "Exit code 8 means somebody cancelled the run, which is not a failure.",
35
+ "",
36
+ "A pipeline with a `script` step needs --confirm-script on every run, not",
37
+ "once: the pipeline fixes which file runs, not what is in it, and that",
38
+ "changes with the commit being deployed.",
23
39
  ],
24
40
  async run({ args, client }) {
25
41
  const c = client();
@@ -37,35 +53,60 @@ export function registerDeployCommands() {
37
53
  throw new CliError(`No pipeline "${ref}" on ${app.id}.`, 4, `Available: ${pipelines.map((p) => p.name).join(", ")}`);
38
54
  }
39
55
  const branch = args.flag("branch");
40
- const source = args.bool("current")
41
- ? { mode: "current" }
42
- : branch
43
- ? { mode: "branch", branch }
44
- : {};
56
+ const commit = args.flag("commit");
57
+ if (commit && !/^[0-9a-f]{7,40}$/i.test(commit)) {
58
+ throw new CliError(`"${commit}" is not a commit SHA.`, 4, "Pass the short or full SHA, e.g. --commit a1b2c3d.");
59
+ }
60
+ const source = commit
61
+ ? { mode: "commit", commit, branch }
62
+ : args.bool("current")
63
+ ? { mode: "current" }
64
+ : branch
65
+ ? { mode: "branch", branch }
66
+ : {};
45
67
  const body = {
46
68
  pipelineId: chosen.id,
47
69
  version: args.flag("version"),
48
70
  source,
49
71
  confirmMigrate: args.bool("confirm-migrate"),
72
+ confirmScript: args.bool("confirm-script"),
50
73
  };
51
74
  const needsMigrate = chosen.config.steps.some((s) => s.type === "migrate");
52
75
  if (needsMigrate && !args.bool("confirm-migrate")) {
53
76
  throw new CliError(`Pipeline "${chosen.name}" runs database migrations.`, 6, "Re-run with --confirm-migrate once you've checked the migration is safe.");
54
77
  }
78
+ /**
79
+ * A script step is confirmed on every run, not once when it was added.
80
+ *
81
+ * Every other step's behaviour is fixed by the pipeline. A script's is
82
+ * whatever the file says on the commit being deployed — so approving it
83
+ * once would approve code that had not been written yet.
84
+ */
85
+ const scripts = chosen.config.steps.filter((x) => x.type === "script");
86
+ if (scripts.length > 0 && !args.bool("confirm-script")) {
87
+ const files = scripts.map((x) => [x.subdir, x.file].filter(Boolean).join("/"));
88
+ throw new CliError(`Pipeline "${chosen.name}" runs ${files.join(", ")} from the app's repo as root.`, 6, "Read that file on the commit you're deploying, then re-run with --confirm-script.");
89
+ }
55
90
  info(`Deploying ${app.id} · pipeline "${chosen.name}"${branch ? ` · branch ${branch}` : ""}`);
56
91
  const lines = [];
57
92
  const sentinels = [];
58
- let commit;
93
+ const warnings = [];
94
+ let deployed;
59
95
  await c.stream("POST", `/registered-apps/${app.id}/deploy`, body, (line) => {
60
- if (line === OK || line === FAILED) {
96
+ if (line === OK || line === FAILED || line === CANCELLED) {
61
97
  sentinels.push(line);
62
98
  return;
63
99
  }
64
- if (MARKER.test(line)) {
100
+ if (line.startsWith(WARN_MARKER)) {
101
+ const text = line.slice(WARN_MARKER.length);
102
+ warnings.push(text);
103
+ line = `⚠ ${text}`;
104
+ }
105
+ else if (MARKER.test(line)) {
65
106
  // Keep the one piece of information a marker carries that the
66
107
  // human stream doesn't structure: which commit got deployed.
67
108
  if (line.startsWith("__COMMIT__:"))
68
- commit = line.split(":")[1];
109
+ deployed = line.split(":")[1];
69
110
  return;
70
111
  }
71
112
  lines.push(line);
@@ -81,40 +122,156 @@ export function registerDeployCommands() {
81
122
  // ends the stream with no sentinel at all.
82
123
  const outcome = sentinels.includes(OK)
83
124
  ? "ok"
84
- : sentinels.includes(FAILED)
85
- ? "failed"
86
- : "unknown";
87
- emit({ appId: app.id, pipeline: chosen.name, status: outcome, commit, log: lines }, () => {
88
- info(outcome === "ok" ? `\n✓ Deploy succeeded` : `\n✗ Deploy ${outcome}`);
125
+ : sentinels.includes(CANCELLED)
126
+ ? "cancelled"
127
+ : sentinels.includes(FAILED)
128
+ ? "failed"
129
+ : "unknown";
130
+ emit({
131
+ appId: app.id,
132
+ pipeline: chosen.name,
133
+ status: outcome,
134
+ commit: deployed,
135
+ warnings,
136
+ log: lines,
137
+ }, () => {
138
+ info(outcome === "ok"
139
+ ? "\n✓ Deploy succeeded"
140
+ : outcome === "cancelled"
141
+ ? "\n■ Deploy cancelled"
142
+ : `\n✗ Deploy ${outcome}`);
143
+ // The console has usually scrolled well past these by now, and a
144
+ // deploy that worked is the case where nobody scrolls back.
145
+ if (warnings.length > 0) {
146
+ info(`\n${warnings.length} warning(s):`);
147
+ for (const w of warnings)
148
+ info(` ⚠ ${w}`);
149
+ }
89
150
  });
90
151
  // A stream that ended without a sentinel means the connection dropped
91
152
  // mid-deploy — the deploy may still be running, so don't report success.
92
153
  if (outcome !== "ok") {
93
154
  throw new CliError(outcome === "failed"
94
155
  ? "The deploy failed — see the output above."
95
- : "The deploy stream ended without a result; check build history.", 7);
156
+ : outcome === "cancelled"
157
+ ? "The deploy was cancelled. The app is in whatever state the stopped step left it."
158
+ : "The deploy stream ended without a result; check build history.", outcome === "cancelled" ? 8 : 7);
96
159
  }
97
160
  },
98
161
  }, {
99
162
  name: "builds",
100
163
  summary: "Show an app's build history",
101
- usage: "builds <app> [--limit <n>]",
164
+ usage: "builds <app> [--limit <n>] [--offset <n>]",
165
+ details: [
166
+ "The `warn` column counts warnings the build produced — things that did",
167
+ "not stop it and that nobody reads in a console nobody reopens. Use",
168
+ "`coe build-log` to see them in context.",
169
+ "",
170
+ "`rollback` is the commit that was live before that build: deploying it",
171
+ "is what undoing that build means.",
172
+ ],
102
173
  async run({ args, client }) {
103
174
  const c = client();
104
175
  const app = await resolveApp(c, args.arg(0, "app"));
105
- const { builds } = await c.request("GET", `/registered-apps/${app.id}/builds`);
106
- const limit = Number(args.flag("limit") ?? 20);
107
- const shown = builds.slice(0, Number.isFinite(limit) ? limit : 20);
108
- emit({ appId: app.id, builds: shown }, () => {
109
- table(shown.map((b) => ({
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}`);
179
+ emit({ appId: app.id, builds, total, offset }, () => {
180
+ table(builds.map((b) => ({
110
181
  started: new Date(b.startedAt).toLocaleString(),
111
- status: b.status,
182
+ status: style.state(b.status),
112
183
  version: b.version ?? "—",
113
184
  pipeline: b.pipelineName ?? "—",
114
185
  source: b.source,
115
186
  by: b.by ?? "—",
116
187
  commit: b.commit?.slice(0, 8) ?? "—",
117
- })), ["started", "status", "version", "pipeline", "source", "by", "commit"]);
188
+ rollback: b.previousCommit?.slice(0, 8) ?? "",
189
+ warn: String((b.warnings ?? []).reduce((n, w) => n + w.count, 0) || "—"),
190
+ })), ["started", "status", "version", "pipeline", "source", "by", "commit", "rollback", "warn"]);
191
+ if (total > builds.length) {
192
+ info(`\nShowing ${builds.length} of ${total} — use --offset to page back.`);
193
+ }
194
+ });
195
+ },
196
+ }, {
197
+ name: "build-log",
198
+ summary: "Print a past build's console",
199
+ usage: "build-log <app> <buildId>",
200
+ details: [
201
+ "Only the most recent builds keep a console — `coe builds` marks the ones",
202
+ "that still have one. A build's warnings are printed first, because they",
203
+ "are the part of a successful deploy worth reading.",
204
+ ],
205
+ async run({ args, client }) {
206
+ const c = client();
207
+ const app = await resolveApp(c, args.arg(0, "app"));
208
+ const buildId = args.arg(1, "buildId");
209
+ const { builds } = await c.request("GET", `/registered-apps/${app.id}/builds?limit=100`);
210
+ const build = builds.find((b) => b.id === buildId || b.version === buildId);
211
+ if (!build) {
212
+ throw new CliError(`No build "${buildId}" on ${app.id}.`, 4, "List them with: coe builds " + app.id);
213
+ }
214
+ const { lines } = await c.request("GET", `/registered-apps/${app.id}/builds/${build.id}/log`);
215
+ emit({ appId: app.id, build, lines }, () => {
216
+ for (const w of build.warnings ?? []) {
217
+ info(`⚠ ${w.count > 1 ? `${w.count}× ` : ""}${w.label}`);
218
+ if (w.hint)
219
+ info(` ${w.hint}`);
220
+ }
221
+ if ((build.warnings ?? []).length > 0)
222
+ info("");
223
+ for (const line of lines)
224
+ process.stdout.write(`${line}\n`);
225
+ });
226
+ },
227
+ }, {
228
+ name: "commits",
229
+ summary: "List recent commits on a branch of an app's repo",
230
+ usage: "commits <app> [--branch <b>] [--limit <n>]",
231
+ details: [
232
+ "Reads the app's own checkout on its VM, so this is what `coe deploy",
233
+ "--commit` can actually deploy — not what your local clone has.",
234
+ ],
235
+ async run({ args, client }) {
236
+ const c = client();
237
+ const app = await resolveApp(c, args.arg(0, "app"));
238
+ const branch = args.flag("branch") ?? app.branch;
239
+ if (!branch) {
240
+ throw new CliError(`${app.id} has no default branch set.`, 4, "Name one with --branch, or set it on the app.");
241
+ }
242
+ const limit = args.num("limit", 25);
243
+ const { commits } = await c.request("GET", `/registered-apps/${app.id}/commits?branch=${encodeURIComponent(branch)}&limit=${limit}`);
244
+ emit({ appId: app.id, branch, commits }, () => {
245
+ table(commits.map((x) => ({
246
+ commit: x.sha,
247
+ subject: x.subject,
248
+ author: x.author,
249
+ date: new Date(x.date).toLocaleString(),
250
+ })), ["commit", "subject", "author", "date"]);
251
+ });
252
+ },
253
+ }, {
254
+ name: "cancel",
255
+ summary: "Stop the deploy running on an app",
256
+ usage: "cancel <app>",
257
+ details: [
258
+ "Stops the pipeline: the running command is killed and no further step",
259
+ "starts. It does not put anything back — the checkout is wherever the",
260
+ "stopped step left it, and if that step was building or installing, the",
261
+ "app needs another deploy to reach a state somebody chose.",
262
+ "",
263
+ "To undo a deploy, deploy what was live before it:",
264
+ " coe deploy <app> --commit $(coe builds <app> --limit 1 --json | …)",
265
+ ],
266
+ async run({ args, client }) {
267
+ const c = client();
268
+ const app = await resolveApp(c, args.arg(0, "app"));
269
+ // A 409 — "nothing is deploying on this app right now" — is the ordinary
270
+ // race, not an error worth dressing up: it finished while you typed.
271
+ // The server's own message says exactly that, so it is left to surface.
272
+ const result = await c.request("POST", `/registered-apps/${app.id}/deploy/cancel`);
273
+ emit({ appId: app.id, ...result }, () => {
274
+ info(`■ Asked ${app.id}'s deploy to stop. Watch it finish with: coe logs ${app.id}`);
118
275
  });
119
276
  },
120
277
  }, {
@@ -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,17 +1,28 @@
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
  /**
7
7
  * Steps are written as a compact `type:arg` list so a pipeline can be created
8
8
  * in one command instead of hand-authoring JSON:
9
9
  *
10
- * pull scan env:backend npm:ci@backend migrate:backend chown pm2:restart@my-api
10
+ * preflight pull scan backup:db npm:ci@backend chown pm2:restart@my-api health:/healthz
11
11
  *
12
12
  * `--steps-file` takes the raw JSON array instead, for anything this shorthand
13
- * can't express.
13
+ * can't express — a `script` step's arguments, say.
14
14
  */
15
+ /** Everything `clean` is allowed to delete. Mirrors the engine's allowlist. */
16
+ const CLEAN_TARGETS = [
17
+ "node_modules",
18
+ "dist",
19
+ "build",
20
+ ".next",
21
+ ".nuxt",
22
+ ".cache",
23
+ "target",
24
+ "vendor",
25
+ ];
15
26
  function parseStep(token) {
16
27
  const [head, subdir] = token.split("@");
17
28
  const [type, arg] = head.split(":");
@@ -41,8 +52,36 @@ function parseStep(token) {
41
52
  if (!arg)
42
53
  throw new CliError(`pm2 step needs an action: pm2:restart@process-name`, 6);
43
54
  return { type: "pm2", action: arg, process: subdir || undefined };
55
+ case "preflight":
56
+ return { type: "preflight", minFreeMb: arg ? Number(arg) || undefined : undefined };
57
+ case "apache":
58
+ return { type: "apache" };
59
+ case "clean": {
60
+ // Comma-separated, and each one checked here so a typo is a refusal with
61
+ // the valid list rather than a step that silently cleans nothing.
62
+ const wanted = (arg || "dist").split(",").filter(Boolean);
63
+ const bad = wanted.filter((t) => !CLEAN_TARGETS.includes(t));
64
+ if (bad.length > 0) {
65
+ throw new CliError(`clean step can't remove ${bad.join(", ")}.`, 6, `It only removes well-known build output: ${CLEAN_TARGETS.join(", ")}`);
66
+ }
67
+ return { type: "clean", targets: wanted, subdir: subdir || undefined };
68
+ }
69
+ case "backup":
70
+ if (arg && arg !== "db") {
71
+ throw new CliError(`backup step takes "db" or nothing: got "${arg}"`, 6);
72
+ }
73
+ return { type: "backup", db: arg === "db", subdir: subdir || undefined };
74
+ case "health":
75
+ // `health:/healthz` — the path keeps its slash because only the first
76
+ // colon splits the token.
77
+ return { type: "health", path: arg ? `/${arg.replace(/^\//, "")}` : undefined };
78
+ case "script":
79
+ if (!arg) {
80
+ throw new CliError(`script step needs a file: script:deploy/after-build.sh`, 6, "It must be an executable committed to the app's repo. Use --steps-file to pass arguments.");
81
+ }
82
+ return { type: "script", file: arg, subdir: subdir || undefined };
44
83
  default:
45
- throw new CliError(`Unknown step "${token}".`, 6, "Valid: pull, scan, env[:subdir], chown, npm:<script>[@subdir], migrate[:subdir], docker[:<up|restart|build>][@subdir], pm2:<action>[@process]");
84
+ throw new CliError(`Unknown step "${token}".`, 6, "Valid: preflight[:minFreeMb], pull, scan, backup[:db][@subdir], clean[:a,b][@subdir], env[:subdir], chown, npm:<script>[@subdir], migrate[:subdir], docker[:<up|restart|build>][@subdir], pm2:<action>[@process], apache, health[:path], script:<file>[@subdir]");
46
85
  }
47
86
  }
48
87
  function describeStep(step) {
@@ -57,6 +96,18 @@ function describeStep(step) {
57
96
  return `docker compose ${step.action ?? "up"}${step.subdir ? ` (${step.subdir})` : ""}`;
58
97
  case "pm2":
59
98
  return `pm2 ${step.action}${step.process ? ` ${step.process}` : ""}`;
99
+ case "preflight":
100
+ return "pre-flight checks";
101
+ case "clean":
102
+ return `clean ${(step.targets ?? ["dist"]).join(", ")}${step.subdir ? ` (${step.subdir})` : ""}`;
103
+ case "backup":
104
+ return step.db ? "backup (code + database)" : "backup (code)";
105
+ case "health":
106
+ return `health check ${step.path ?? "/"}`;
107
+ case "apache":
108
+ return "apache configtest + reload";
109
+ case "script":
110
+ return `run ${step.file}${step.subdir ? ` (${step.subdir})` : ""}`;
60
111
  default:
61
112
  return step.type;
62
113
  }
@@ -151,7 +202,7 @@ export function registerPipelineCommands() {
151
202
  pipelines.push({ id: "", name, config: { steps } });
152
203
  const updated = await savePipelines(c, app.id, pipelines);
153
204
  const created = (updated.pipelines ?? []).find((p) => p.name === name);
154
- 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)`));
155
206
  },
156
207
  }, {
157
208
  name: "pipelines set-steps",
@@ -166,7 +217,7 @@ export function registerPipelineCommands() {
166
217
  throw new CliError("A pipeline needs at least one step.", 6);
167
218
  const pipelines = (app.pipelines ?? []).map((p) => p.id === pipeline.id ? { ...p, config: { steps } } : p);
168
219
  await savePipelines(c, app.id, pipelines);
169
- 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)`));
170
221
  },
171
222
  }, {
172
223
  name: "pipelines rename",
@@ -179,7 +230,7 @@ export function registerPipelineCommands() {
179
230
  const newName = args.arg(2, "new-name");
180
231
  const pipelines = (app.pipelines ?? []).map((p) => p.id === pipeline.id ? { ...p, name: newName } : p);
181
232
  await savePipelines(c, app.id, pipelines);
182
- 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}"`));
183
234
  },
184
235
  }, {
185
236
  name: "pipelines delete",
@@ -194,7 +245,7 @@ export function registerPipelineCommands() {
194
245
  }
195
246
  const pipelines = (app.pipelines ?? []).filter((p) => p.id !== pipeline.id);
196
247
  await savePipelines(c, app.id, pipelines);
197
- 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}`));
198
249
  },
199
250
  });
200
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",
@@ -0,0 +1 @@
1
+ export declare function registerTipCommands(): void;
@@ -0,0 +1,113 @@
1
+ import { CliError } from "../client.js";
2
+ import { emit, info, style, success, table } from "../output.js";
3
+ import { register } from "../registry.js";
4
+ import { resolveApp } from "./apps.js";
5
+ export function registerTipCommands() {
6
+ register({
7
+ name: "tips submit",
8
+ summary: "Suggest advice for a deploy failure or warning",
9
+ usage: 'tips submit <app> --kind <failure|warning> --trigger "<text>" --tip "<advice>"',
10
+ details: [
11
+ "Nothing you submit is shown to anyone else until a webadmin reviews it.",
12
+ "",
13
+ "--trigger is what identifies the situation to the *next* person: the",
14
+ "warning category, or the distinctive part of an error line. Leave the",
15
+ "app-specific parts out — paths, ports, process names and SHAs are",
16
+ "normalised away so the same problem from five apps is one entry with a",
17
+ "count, and that count is the thing worth acting on.",
18
+ "",
19
+ "--tip is the advice itself: what the line means and what to do. Write it",
20
+ "for somebody who has not just spent an hour on it.",
21
+ "",
22
+ "Never put credentials, tokens or connection strings in either field.",
23
+ ],
24
+ async run({ args, client }) {
25
+ const c = client();
26
+ const app = await resolveApp(c, args.arg(0, "app"));
27
+ const kind = args.flag("kind");
28
+ if (kind !== "failure" && kind !== "warning") {
29
+ throw new CliError("--kind must be failure or warning.", 6);
30
+ }
31
+ const trigger = args.flag("trigger");
32
+ const tip = args.flag("tip");
33
+ if (!trigger || !tip) {
34
+ throw new CliError("--trigger and --tip are both required.", 6, 'e.g. coe tips submit myapp --kind warning --trigger "npm warn EBADENGINE" --tip "The app declares an engines range this VM\'s Node does not satisfy; bump the range or the VM\'s Node."');
35
+ }
36
+ const result = await c.request("POST", `/registered-apps/${app.id}/deploy-tips`, {
37
+ kind,
38
+ trigger,
39
+ tip,
40
+ buildId: args.flag("build"),
41
+ source: args.flag("source"),
42
+ });
43
+ emit({ appId: app.id, ...result }, () => {
44
+ if (result.alreadyReviewed === "rejected") {
45
+ info("■ A maintainer has already looked at this trigger and declined advice for it. " +
46
+ "Your submission was counted, not queued again — don't keep suggesting it.");
47
+ return;
48
+ }
49
+ if (result.alreadyReviewed === "accepted") {
50
+ success("This trigger already has accepted advice. Counted, not queued again.");
51
+ return;
52
+ }
53
+ info(result.created
54
+ ? `✓ Suggested (${result.id}). It sits in the review queue until a webadmin accepts it.`
55
+ : `✓ Folded into an existing suggestion — now reported ${result.occurrences} times.`);
56
+ });
57
+ },
58
+ }, {
59
+ name: "tips list",
60
+ summary: "The suggested-advice review queue (webadmin)",
61
+ usage: "tips list [--status new|accepted|rejected]",
62
+ details: [
63
+ "Sorted by most recent activity. `seen` is how many times a trigger has",
64
+ "been reported — the column to sort your attention by, since one report",
65
+ "is an anecdote and five from different apps is a gap in the product.",
66
+ ],
67
+ async run({ args, client }) {
68
+ const c = client();
69
+ const status = args.flag("status") ?? "";
70
+ const query = status ? `?status=${encodeURIComponent(status)}` : "";
71
+ const { tips, newCount } = await c.request("GET", `/deploy-tips${query}`);
72
+ emit({ tips, newCount }, () => {
73
+ if (tips.length === 0) {
74
+ info(status ? `No ${status} suggestions.` : "No suggestions yet.");
75
+ return;
76
+ }
77
+ table(tips.map((t) => ({
78
+ id: t.id,
79
+ status: style.state(t.status),
80
+ kind: t.kind,
81
+ seen: String(t.occurrences),
82
+ apps: t.appIds.join(", "),
83
+ trigger: t.trigger.slice(0, 48),
84
+ tip: t.tip.slice(0, 60),
85
+ })), ["id", "status", "kind", "seen", "apps", "trigger", "tip"]);
86
+ if (!status && newCount > 0)
87
+ info(`\n${newCount} awaiting review.`);
88
+ });
89
+ },
90
+ }, {
91
+ name: "tips review",
92
+ summary: "Accept or reject a suggestion (webadmin)",
93
+ usage: 'tips review <id> <accepted|rejected> [--note "<why>"]',
94
+ details: [
95
+ "Rejecting is not a delete: the entry is kept so the same suggestion is",
96
+ "recognised and counted rather than re-queued, and whoever submitted it",
97
+ "is told it has already been declined.",
98
+ ],
99
+ async run({ args, client }) {
100
+ const c = client();
101
+ const id = args.arg(0, "id");
102
+ const status = args.arg(1, "status");
103
+ if (status !== "accepted" && status !== "rejected") {
104
+ throw new CliError("Status must be accepted or rejected.", 6);
105
+ }
106
+ const { tip } = await c.request("POST", `/deploy-tips/${id}/review`, {
107
+ status,
108
+ note: args.flag("note"),
109
+ });
110
+ emit({ tip }, () => success(`${tip.id} marked ${tip.status}.`));
111
+ },
112
+ });
113
+ }
package/dist/index.js CHANGED
@@ -12,8 +12,9 @@ import { registerLogCommands } from "./commands/logs.js";
12
12
  import { registerPipelineCommands } from "./commands/pipelines.js";
13
13
  import { registerReportCommands } from "./commands/reports.js";
14
14
  import { registerResourceCommands } from "./commands/resources.js";
15
+ import { registerTipCommands } from "./commands/tips.js";
15
16
  import { registerVmCommands } from "./commands/vms.js";
16
- import { info, setJsonMode } from "./output.js";
17
+ import { failure, setJsonMode } from "./output.js";
17
18
  import { allCommands, findCommand, register } from "./registry.js";
18
19
  registerAuthCommands();
19
20
  registerAppCommands();
@@ -28,6 +29,7 @@ registerLogCommands();
28
29
  registerVmCommands();
29
30
  registerReportCommands();
30
31
  registerDirectoryCommands();
32
+ registerTipCommands();
31
33
  function parseArgs(argv) {
32
34
  const positional = [];
33
35
  const flags = {};
@@ -69,6 +71,19 @@ function parseArgs(argv) {
69
71
  bool(name) {
70
72
  return flags[name] !== undefined;
71
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
+ },
72
87
  };
73
88
  }
74
89
  function printHelp() {
@@ -123,11 +138,9 @@ async function main() {
123
138
  }
124
139
  main().catch((error) => {
125
140
  if (error instanceof CliError) {
126
- info(`error: ${error.message}`);
127
- if (error.hint)
128
- info(` ${error.hint}`);
141
+ failure(error.message, error.hint);
129
142
  process.exit(error.exitCode);
130
143
  }
131
- info(`error: ${error instanceof Error ? error.message : String(error)}`);
144
+ failure(error instanceof Error ? error.message : String(error));
132
145
  process.exit(1);
133
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/dist/types.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * on its own against a deployed server, so it must not depend on the API's
5
5
  * build output.
6
6
  */
7
+ export type CleanTarget = "node_modules" | "dist" | "build" | ".next" | ".nuxt" | ".cache" | "target" | "vendor";
7
8
  export type DeployStep = {
8
9
  type: "pull";
9
10
  } | {
@@ -28,6 +29,42 @@ export type DeployStep = {
28
29
  type: "pm2";
29
30
  action: string;
30
31
  process?: string;
32
+ }
33
+ /** Checks the rest of the pipeline can run before any of it does. */
34
+ | {
35
+ type: "preflight";
36
+ minFreeMb?: number;
37
+ }
38
+ /** Removes build artefacts, from a fixed allowlist of directory names. */
39
+ | {
40
+ type: "clean";
41
+ subdir?: string;
42
+ targets?: CleanTarget[];
43
+ }
44
+ /** Records the rollback point, and optionally dumps the app's database. */
45
+ | {
46
+ type: "backup";
47
+ subdir?: string;
48
+ db?: boolean;
49
+ keepDays?: number;
50
+ }
51
+ /** Asks the app over HTTP whether it works — what pm2 can't tell you. */
52
+ | {
53
+ type: "health";
54
+ path?: string;
55
+ status?: number;
56
+ retries?: number;
57
+ }
58
+ /** apachectl configtest, then a graceful reload. */
59
+ | {
60
+ type: "apache";
61
+ }
62
+ /** Runs an executable committed to the app's own repo. */
63
+ | {
64
+ type: "script";
65
+ subdir?: string;
66
+ file: string;
67
+ args?: string[];
31
68
  };
32
69
  export interface DeployConfig {
33
70
  steps: DeployStep[];
@@ -90,6 +127,14 @@ export interface RegisteredApp {
90
127
  createdAt: string;
91
128
  updatedAt: string;
92
129
  }
130
+ /** A category of warning a build produced, with how many times it occurred. */
131
+ export interface BuildWarning {
132
+ code: string;
133
+ label: string;
134
+ count: number;
135
+ samples: string[];
136
+ hint?: string;
137
+ }
93
138
  export interface Build {
94
139
  id: string;
95
140
  appId: string;
@@ -97,9 +142,25 @@ export interface Build {
97
142
  pipelineName?: string;
98
143
  source: string;
99
144
  by?: string;
100
- status: "running" | "ok" | "failed";
145
+ /** `cancelled` is its own outcome — somebody stopped it, it didn't break. */
146
+ status: "running" | "ok" | "failed" | "cancelled";
101
147
  startedAt: string;
102
148
  finishedAt?: string;
103
149
  commit?: string;
104
150
  commitSubject?: string;
151
+ /** What was live before this build — what rolling it back would deploy. */
152
+ previousCommit?: string;
153
+ previousCommitSubject?: string;
154
+ warnings?: BuildWarning[];
155
+ /** Whether this build's console is still stored (only the recent ones are). */
156
+ hasLog?: boolean;
157
+ cancelledBy?: string;
158
+ error?: string;
159
+ }
160
+ /** One commit on a branch, as `coe commits` lists them. */
161
+ export interface CommitSummary {
162
+ sha: string;
163
+ subject: string;
164
+ author: string;
165
+ date: string;
105
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uic-coe-connect/cli",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
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": {