@uic-coe-connect/cli 0.11.0 → 0.12.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.
@@ -5,21 +5,37 @@ 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,32 +122,63 @@ 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
176
  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) => ({
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}`);
180
+ emit({ appId: app.id, builds, total, offset }, () => {
181
+ table(builds.map((b) => ({
110
182
  started: new Date(b.startedAt).toLocaleString(),
111
183
  status: b.status,
112
184
  version: b.version ?? "—",
@@ -114,7 +186,94 @@ export function registerDeployCommands() {
114
186
  source: b.source,
115
187
  by: b.by ?? "—",
116
188
  commit: b.commit?.slice(0, 8) ?? "—",
117
- })), ["started", "status", "version", "pipeline", "source", "by", "commit"]);
189
+ rollback: b.previousCommit?.slice(0, 8) ?? "",
190
+ warn: String((b.warnings ?? []).reduce((n, w) => n + w.count, 0) || "—"),
191
+ })), ["started", "status", "version", "pipeline", "source", "by", "commit", "rollback", "warn"]);
192
+ if (total > builds.length) {
193
+ info(`\nShowing ${builds.length} of ${total} — use --offset to page back.`);
194
+ }
195
+ });
196
+ },
197
+ }, {
198
+ name: "build-log",
199
+ summary: "Print a past build's console",
200
+ usage: "build-log <app> <buildId>",
201
+ details: [
202
+ "Only the most recent builds keep a console — `coe builds` marks the ones",
203
+ "that still have one. A build's warnings are printed first, because they",
204
+ "are the part of a successful deploy worth reading.",
205
+ ],
206
+ async run({ args, client }) {
207
+ const c = client();
208
+ const app = await resolveApp(c, args.arg(0, "app"));
209
+ const buildId = args.arg(1, "buildId");
210
+ const { builds } = await c.request("GET", `/registered-apps/${app.id}/builds?limit=100`);
211
+ const build = builds.find((b) => b.id === buildId || b.version === buildId);
212
+ if (!build) {
213
+ throw new CliError(`No build "${buildId}" on ${app.id}.`, 4, "List them with: coe builds " + app.id);
214
+ }
215
+ const { lines } = await c.request("GET", `/registered-apps/${app.id}/builds/${build.id}/log`);
216
+ emit({ appId: app.id, build, lines }, () => {
217
+ for (const w of build.warnings ?? []) {
218
+ info(`⚠ ${w.count > 1 ? `${w.count}× ` : ""}${w.label}`);
219
+ if (w.hint)
220
+ info(` ${w.hint}`);
221
+ }
222
+ if ((build.warnings ?? []).length > 0)
223
+ info("");
224
+ for (const line of lines)
225
+ process.stdout.write(`${line}\n`);
226
+ });
227
+ },
228
+ }, {
229
+ name: "commits",
230
+ summary: "List recent commits on a branch of an app's repo",
231
+ usage: "commits <app> [--branch <b>] [--limit <n>]",
232
+ details: [
233
+ "Reads the app's own checkout on its VM, so this is what `coe deploy",
234
+ "--commit` can actually deploy — not what your local clone has.",
235
+ ],
236
+ async run({ args, client }) {
237
+ const c = client();
238
+ const app = await resolveApp(c, args.arg(0, "app"));
239
+ const branch = args.flag("branch") ?? app.branch;
240
+ if (!branch) {
241
+ throw new CliError(`${app.id} has no default branch set.`, 4, "Name one with --branch, or set it on the app.");
242
+ }
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}`);
246
+ emit({ appId: app.id, branch, commits }, () => {
247
+ table(commits.map((x) => ({
248
+ commit: x.sha,
249
+ subject: x.subject,
250
+ author: x.author,
251
+ date: new Date(x.date).toLocaleString(),
252
+ })), ["commit", "subject", "author", "date"]);
253
+ });
254
+ },
255
+ }, {
256
+ name: "cancel",
257
+ summary: "Stop the deploy running on an app",
258
+ usage: "cancel <app>",
259
+ details: [
260
+ "Stops the pipeline: the running command is killed and no further step",
261
+ "starts. It does not put anything back — the checkout is wherever the",
262
+ "stopped step left it, and if that step was building or installing, the",
263
+ "app needs another deploy to reach a state somebody chose.",
264
+ "",
265
+ "To undo a deploy, deploy what was live before it:",
266
+ " coe deploy <app> --commit $(coe builds <app> --limit 1 --json | …)",
267
+ ],
268
+ async run({ args, client }) {
269
+ const c = client();
270
+ const app = await resolveApp(c, args.arg(0, "app"));
271
+ // A 409 — "nothing is deploying on this app right now" — is the ordinary
272
+ // race, not an error worth dressing up: it finished while you typed.
273
+ // The server's own message says exactly that, so it is left to surface.
274
+ const result = await c.request("POST", `/registered-apps/${app.id}/deploy/cancel`);
275
+ emit({ appId: app.id, ...result }, () => {
276
+ info(`■ Asked ${app.id}'s deploy to stop. Watch it finish with: coe logs ${app.id}`);
118
277
  });
119
278
  },
120
279
  }, {
@@ -7,11 +7,22 @@ import { resolveApp } from "./apps.js";
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
  }
@@ -0,0 +1 @@
1
+ export declare function registerTipCommands(): void;
@@ -0,0 +1,113 @@
1
+ import { CliError } from "../client.js";
2
+ import { emit, info, 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
+ info("✓ 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: 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 }, () => info(`✓ ${tip.id} marked ${tip.status}.`));
111
+ },
112
+ });
113
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ 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
17
  import { info, setJsonMode } from "./output.js";
17
18
  import { allCommands, findCommand, register } from "./registry.js";
@@ -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 = {};
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.12.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": {