@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 +108 -23
- package/dist/commands/access.js +8 -8
- package/dist/commands/auth.js +14 -5
- package/dist/commands/deploy.js +183 -26
- package/dist/commands/env.js +3 -3
- package/dist/commands/logs.js +1 -1
- package/dist/commands/pipelines.js +59 -8
- package/dist/commands/reports.js +8 -8
- package/dist/commands/resources.js +5 -5
- package/dist/commands/tips.d.ts +1 -0
- package/dist/commands/tips.js +113 -0
- package/dist/index.js +18 -5
- package/dist/output.d.ts +17 -0
- package/dist/output.js +86 -6
- package/dist/registry.d.ts +8 -0
- package/dist/types.d.ts +62 -1
- package/package.json +1 -1
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}`, {
|
|
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}`, {
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
}
|
package/dist/commands/access.js
CHANGED
|
@@ -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 }, () =>
|
|
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] }, () =>
|
|
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 }, () =>
|
|
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
|
-
|
|
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] }, () =>
|
|
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 }, () =>
|
|
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] }, () =>
|
|
139
|
+
emit({ appId: app.id, blacklist: exceptions, removed: [...toRemove] }, () => success(`Blacklist for ${app.id}: ${exceptions.join(", ") || "(none)"}`));
|
|
140
140
|
},
|
|
141
141
|
});
|
|
142
142
|
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -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 }, () =>
|
|
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
|
-
[
|
|
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, () =>
|
|
143
|
+
emit(result, () => success(`Revoked ${result.revoked} session(s)`));
|
|
135
144
|
},
|
|
136
145
|
}, {
|
|
137
146
|
name: "auth status",
|
package/dist/commands/deploy.js
CHANGED
|
@@ -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>|--
|
|
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,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(
|
|
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
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
emit({ appId: app.id, builds
|
|
109
|
-
table(
|
|
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
|
-
|
|
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
|
}, {
|
package/dist/commands/env.js
CHANGED
|
@@ -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 }, () =>
|
|
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 }, () =>
|
|
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
|
}
|
package/dist/commands/logs.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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 }, () =>
|
|
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 }, () =>
|
|
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 }, () =>
|
|
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 }, () =>
|
|
248
|
+
emit({ appId: app.id, deleted: pipeline.id }, () => success(`Deleted pipeline "${pipeline.name}" from ${app.id}`));
|
|
198
249
|
},
|
|
199
250
|
});
|
|
200
251
|
}
|
package/dist/commands/reports.js
CHANGED
|
@@ -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 }, () =>
|
|
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 }, () =>
|
|
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 }, () =>
|
|
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 }, () =>
|
|
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 }, () =>
|
|
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 }, () =>
|
|
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
|
-
|
|
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,
|
|
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 }, () =>
|
|
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 }, () =>
|
|
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 }, () =>
|
|
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 }, () =>
|
|
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 {
|
|
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
|
-
|
|
127
|
-
if (error.hint)
|
|
128
|
-
info(` ${error.hint}`);
|
|
141
|
+
failure(error.message, error.hint);
|
|
129
142
|
process.exit(error.exitCode);
|
|
130
143
|
}
|
|
131
|
-
|
|
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(
|
|
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] ?? "")
|
|
112
|
+
const width = (col) => Math.max(col.length, ...rows.map((r) => visibleLength(r[col] ?? "")));
|
|
38
113
|
const widths = columns.map(width);
|
|
39
|
-
const
|
|
40
|
-
|
|
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
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.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": {
|