@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.
- package/dist/commands/deploy.js +182 -23
- package/dist/commands/pipelines.js +54 -3
- package/dist/commands/tips.d.ts +1 -0
- package/dist/commands/tips.js +113 -0
- package/dist/index.js +2 -0
- package/dist/types.d.ts +62 -1
- package/package.json +1 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -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>|--
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
85
|
-
? "
|
|
86
|
-
:
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
:
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": {
|