@uic-coe-connect/cli 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/deploy.js +15 -1
- package/dist/commands/logs.d.ts +1 -0
- package/dist/commands/logs.js +79 -0
- package/dist/commands/pipelines.js +7 -3
- package/dist/commands/roles.js +69 -0
- package/dist/index.js +2 -0
- package/dist/types.d.ts +12 -0
- package/package.json +1 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -5,6 +5,12 @@ 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
|
+
/**
|
|
9
|
+
* Machine markers the server interleaves for the web UI's pipeline diagram.
|
|
10
|
+
* They're meaningless here and read as debug noise, so they never reach the
|
|
11
|
+
* console or the JSON log — the stream already carries a human line for each.
|
|
12
|
+
*/
|
|
13
|
+
const MARKER = /^__(NODE|COMMIT)__:/;
|
|
8
14
|
export function registerDeployCommands() {
|
|
9
15
|
register({
|
|
10
16
|
name: "deploy",
|
|
@@ -49,11 +55,19 @@ export function registerDeployCommands() {
|
|
|
49
55
|
info(`Deploying ${app.id} · pipeline "${chosen.name}"${branch ? ` · branch ${branch}` : ""}`);
|
|
50
56
|
const lines = [];
|
|
51
57
|
const sentinels = [];
|
|
58
|
+
let commit;
|
|
52
59
|
await c.stream("POST", `/registered-apps/${app.id}/deploy`, body, (line) => {
|
|
53
60
|
if (line === OK || line === FAILED) {
|
|
54
61
|
sentinels.push(line);
|
|
55
62
|
return;
|
|
56
63
|
}
|
|
64
|
+
if (MARKER.test(line)) {
|
|
65
|
+
// Keep the one piece of information a marker carries that the
|
|
66
|
+
// human stream doesn't structure: which commit got deployed.
|
|
67
|
+
if (line.startsWith("__COMMIT__:"))
|
|
68
|
+
commit = line.split(":")[1];
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
57
71
|
lines.push(line);
|
|
58
72
|
// In JSON mode the log is part of the final payload instead, so stdout
|
|
59
73
|
// stays parseable; progress still shows on stderr.
|
|
@@ -70,7 +84,7 @@ export function registerDeployCommands() {
|
|
|
70
84
|
: sentinels.includes(FAILED)
|
|
71
85
|
? "failed"
|
|
72
86
|
: "unknown";
|
|
73
|
-
emit({ appId: app.id, pipeline: chosen.name, status: outcome, log: lines }, () => {
|
|
87
|
+
emit({ appId: app.id, pipeline: chosen.name, status: outcome, commit, log: lines }, () => {
|
|
74
88
|
info(outcome === "ok" ? `\n✓ Deploy succeeded` : `\n✗ Deploy ${outcome}`);
|
|
75
89
|
});
|
|
76
90
|
// A stream that ended without a sentinel means the connection dropped
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerLogCommands(): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { CliError } from "../client.js";
|
|
2
|
+
import { emit, info } from "../output.js";
|
|
3
|
+
import { register } from "../registry.js";
|
|
4
|
+
import { resolveApp } from "./apps.js";
|
|
5
|
+
const FOLLOW_INTERVAL_MS = 2_000;
|
|
6
|
+
export function registerLogCommands() {
|
|
7
|
+
register({
|
|
8
|
+
name: "logs",
|
|
9
|
+
summary: "Read an app's pm2 logs (why it crashed, what it printed)",
|
|
10
|
+
usage: "logs <app> [--out] [--tail <n>] [--follow]",
|
|
11
|
+
details: [
|
|
12
|
+
"Defaults to the error log — that's where a startup crash lands.",
|
|
13
|
+
" --out stdout instead of stderr",
|
|
14
|
+
" --tail <n> how many lines (default 100, max 2000)",
|
|
15
|
+
" --follow keep polling and print new lines until Ctrl-C",
|
|
16
|
+
"",
|
|
17
|
+
"These are root's pm2 logs on the VM, which you otherwise can't read",
|
|
18
|
+
"without SSH. If a deploy just failed a health check it already printed",
|
|
19
|
+
"the tail inline — this is for looking further back.",
|
|
20
|
+
],
|
|
21
|
+
async run({ args, client }) {
|
|
22
|
+
const c = client();
|
|
23
|
+
const app = await resolveApp(c, args.arg(0, "app"));
|
|
24
|
+
const stream = args.bool("out") ? "out" : "err";
|
|
25
|
+
const tail = Number(args.flag("tail") ?? 100);
|
|
26
|
+
if (!Number.isFinite(tail) || tail < 1) {
|
|
27
|
+
throw new CliError(`--tail must be a positive number, got "${args.flag("tail")}".`, 6);
|
|
28
|
+
}
|
|
29
|
+
const path = `/registered-apps/${app.id}/logs?stream=${stream}&lines=${Math.min(tail, 2000)}`;
|
|
30
|
+
const fetchLogs = () => c.request("GET", path);
|
|
31
|
+
const first = await fetchLogs();
|
|
32
|
+
if (first.logs.length === 0) {
|
|
33
|
+
emit(first, () => {
|
|
34
|
+
info(first.note ?? `No pm2 process found for ${app.id}.`);
|
|
35
|
+
info(" Deploy it first, or check the pipeline's pm2 step names.");
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (args.bool("json")) {
|
|
40
|
+
emit(first, () => undefined);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const render = (block, lines) => {
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
info(first.logs.length > 1 ? `[${block.process}] ${line}` : line);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
for (const block of first.logs) {
|
|
49
|
+
info(`── ${block.process} (${block.status}, ${block.restarts} restarts) · ${stream === "out" ? "stdout" : "stderr"} ──`);
|
|
50
|
+
if (block.lines.length === 0) {
|
|
51
|
+
info(" (empty)");
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
render(block, block.lines);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!args.bool("follow"))
|
|
58
|
+
return;
|
|
59
|
+
// Poll rather than hold a connection open: a deploy can restart the app
|
|
60
|
+
// (and rotate the file) underneath us, and re-reading the tail each time
|
|
61
|
+
// survives that where a held stream wouldn't.
|
|
62
|
+
const seen = new Map(first.logs.map((b) => [b.process, b.lines.at(-1)]));
|
|
63
|
+
info("── following (Ctrl-C to stop) ──");
|
|
64
|
+
for (;;) {
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, FOLLOW_INTERVAL_MS));
|
|
66
|
+
const next = await fetchLogs();
|
|
67
|
+
for (const block of next.logs) {
|
|
68
|
+
const last = seen.get(block.process);
|
|
69
|
+
const index = last === undefined ? -1 : block.lines.lastIndexOf(last);
|
|
70
|
+
const fresh = index >= 0 ? block.lines.slice(index + 1) : block.lines;
|
|
71
|
+
if (fresh.length > 0) {
|
|
72
|
+
render(block, fresh);
|
|
73
|
+
seen.set(block.process, fresh.at(-1));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -7,7 +7,7 @@ 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
|
+
* pull scan env:backend npm:ci@backend migrate:backend chown pm2:restart@my-api
|
|
11
11
|
*
|
|
12
12
|
* `--steps-file` takes the raw JSON array instead, for anything this shorthand
|
|
13
13
|
* can't express.
|
|
@@ -22,6 +22,8 @@ function parseStep(token) {
|
|
|
22
22
|
return { type: "scan" };
|
|
23
23
|
case "chown":
|
|
24
24
|
return { type: "chown" };
|
|
25
|
+
case "env":
|
|
26
|
+
return { type: "env", subdir: arg || subdir || undefined };
|
|
25
27
|
case "npm":
|
|
26
28
|
if (!arg)
|
|
27
29
|
throw new CliError(`npm step needs a script: npm:ci or npm:build@subdir`, 6);
|
|
@@ -33,7 +35,7 @@ function parseStep(token) {
|
|
|
33
35
|
throw new CliError(`pm2 step needs an action: pm2:restart@process-name`, 6);
|
|
34
36
|
return { type: "pm2", action: arg, process: subdir || undefined };
|
|
35
37
|
default:
|
|
36
|
-
throw new CliError(`Unknown step "${token}".`, 6, "Valid: pull, scan, chown, npm:<script>[@subdir], migrate[:subdir], pm2:<action>[@process]");
|
|
38
|
+
throw new CliError(`Unknown step "${token}".`, 6, "Valid: pull, scan, env[:subdir], chown, npm:<script>[@subdir], migrate[:subdir], pm2:<action>[@process]");
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
function describeStep(step) {
|
|
@@ -42,6 +44,8 @@ function describeStep(step) {
|
|
|
42
44
|
return `npm ${step.script}${step.subdir ? ` (${step.subdir})` : ""}`;
|
|
43
45
|
case "migrate":
|
|
44
46
|
return `migrate${step.subdir ? ` (${step.subdir})` : ""}`;
|
|
47
|
+
case "env":
|
|
48
|
+
return `write .env${step.subdir ? ` (${step.subdir})` : ""}`;
|
|
45
49
|
case "pm2":
|
|
46
50
|
return `pm2 ${step.action}${step.process ? ` ${step.process}` : ""}`;
|
|
47
51
|
default:
|
|
@@ -114,7 +118,7 @@ export function registerPipelineCommands() {
|
|
|
114
118
|
summary: "Create a pipeline from a step list",
|
|
115
119
|
usage: 'pipelines create <app> --name "Full deploy" <step>... | --steps-file <f.json>',
|
|
116
120
|
details: [
|
|
117
|
-
"Steps: pull, scan, chown, npm:<script>[@subdir], migrate[:subdir], pm2:<action>[@process]",
|
|
121
|
+
"Steps: pull, scan, env[:subdir], chown, npm:<script>[@subdir], migrate[:subdir], pm2:<action>[@process]",
|
|
118
122
|
'Example: coe pipelines create myapp --name "Full" pull scan npm:ci@backend pm2:restart@myapp-api',
|
|
119
123
|
],
|
|
120
124
|
async run({ args, client }) {
|
package/dist/commands/roles.js
CHANGED
|
@@ -184,6 +184,75 @@ export function registerRoleCommands() {
|
|
|
184
184
|
rules.forEach((rule, i) => info(` [${i}] ${describeRule(rule)}`));
|
|
185
185
|
});
|
|
186
186
|
},
|
|
187
|
+
}, {
|
|
188
|
+
name: "roles token",
|
|
189
|
+
summary: "Show, issue, or rotate the app's role-resolve token",
|
|
190
|
+
usage: "roles token <app> [--issue | --rotate | --revoke]",
|
|
191
|
+
details: [
|
|
192
|
+
"The token your app puts in COE_ROLES_TOKEN so the SDK can resolve roles.",
|
|
193
|
+
"It is stored hashed and shown ONCE, at issue — there is no way to read it",
|
|
194
|
+
"back later. Lost it? Rotate and update the app's env.",
|
|
195
|
+
"",
|
|
196
|
+
" coe roles token myapp --issue # first time",
|
|
197
|
+
" coe roles token myapp --rotate # replace a live one",
|
|
198
|
+
"",
|
|
199
|
+
"Then: coe env set myapp COE_ROLES_TOKEN=<value>, and deploy.",
|
|
200
|
+
],
|
|
201
|
+
async run({ args, client }) {
|
|
202
|
+
const c = client();
|
|
203
|
+
const app = await resolveApp(c, args.arg(0, "app"));
|
|
204
|
+
const path = `/registered-apps/${app.id}/roles/token`;
|
|
205
|
+
if (args.bool("revoke")) {
|
|
206
|
+
await c.request("DELETE", path);
|
|
207
|
+
emit({ appId: app.id, revoked: true }, () => {
|
|
208
|
+
info(`✓ Revoked ${app.id}'s roles token`);
|
|
209
|
+
info(" Role resolution now fails for this app until you issue a new one,");
|
|
210
|
+
info(" and users sign in with rolesResolved: false.");
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const issuing = args.bool("issue") || args.bool("rotate");
|
|
215
|
+
if (!issuing) {
|
|
216
|
+
const { token } = await c.request("GET", path);
|
|
217
|
+
emit({ appId: app.id, token }, () => {
|
|
218
|
+
if (!token) {
|
|
219
|
+
info(`${app.id} has no roles token.`);
|
|
220
|
+
info(` Issue one with: coe roles token ${app.id} --issue`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
details([
|
|
224
|
+
["App", app.id],
|
|
225
|
+
["Token", `${token.prefix}… (stored hashed; issue a new one to see a value)`],
|
|
226
|
+
["Created", `${new Date(token.createdAt).toLocaleString()} by ${token.createdBy}`],
|
|
227
|
+
[
|
|
228
|
+
"Last used",
|
|
229
|
+
token.lastUsedAt
|
|
230
|
+
? new Date(token.lastUsedAt).toLocaleString()
|
|
231
|
+
: "never — the app hasn't resolved a role yet",
|
|
232
|
+
],
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Rotating invalidates the value the deployed app is holding, so it
|
|
238
|
+
// can't be something you do by reaching for the wrong flag.
|
|
239
|
+
const existing = await c.request("GET", path);
|
|
240
|
+
if (existing.token && !args.bool("rotate")) {
|
|
241
|
+
throw new CliError(`${app.id} already has a roles token (${existing.token.prefix}…).`, 6, "Re-run with --rotate to replace it. The deployed app will fail to resolve roles until you update COE_ROLES_TOKEN and redeploy.");
|
|
242
|
+
}
|
|
243
|
+
const result = await c.request("POST", path);
|
|
244
|
+
emit(result, () => {
|
|
245
|
+
info(`✓ ${result.rotated ? "Rotated" : "Issued"} ${app.id}'s roles token`);
|
|
246
|
+
info("");
|
|
247
|
+
info(` ${result.token}`);
|
|
248
|
+
info("");
|
|
249
|
+
info(" This is the only time it is shown. Store it now:");
|
|
250
|
+
info(` coe env set ${app.id} COE_ROLES_TOKEN=${result.token}`);
|
|
251
|
+
if (result.rotated) {
|
|
252
|
+
info(" The previous token stopped working immediately — deploy to pick this one up.");
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
},
|
|
187
256
|
}, {
|
|
188
257
|
name: "roles remove-rule",
|
|
189
258
|
summary: "Remove a rule by its index (see roles show)",
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { registerAppCommands } from "./commands/apps.js";
|
|
|
5
5
|
import { registerAuthCommands } from "./commands/auth.js";
|
|
6
6
|
import { registerDeployCommands } from "./commands/deploy.js";
|
|
7
7
|
import { registerEnvCommands } from "./commands/env.js";
|
|
8
|
+
import { registerLogCommands } from "./commands/logs.js";
|
|
8
9
|
import { registerPipelineCommands } from "./commands/pipelines.js";
|
|
9
10
|
import { registerResourceCommands } from "./commands/resources.js";
|
|
10
11
|
import { registerRoleCommands } from "./commands/roles.js";
|
|
@@ -18,6 +19,7 @@ registerAccessCommands();
|
|
|
18
19
|
registerEnvCommands();
|
|
19
20
|
registerResourceCommands();
|
|
20
21
|
registerRoleCommands();
|
|
22
|
+
registerLogCommands();
|
|
21
23
|
function parseArgs(argv) {
|
|
22
24
|
const positional = [];
|
|
23
25
|
const flags = {};
|
package/dist/types.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export type DeployStep = {
|
|
|
12
12
|
type: "npm";
|
|
13
13
|
script: string;
|
|
14
14
|
subdir?: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: "env";
|
|
17
|
+
subdir?: string;
|
|
15
18
|
} | {
|
|
16
19
|
type: "migrate";
|
|
17
20
|
subdir?: string;
|
|
@@ -61,6 +64,15 @@ export interface AppAccess {
|
|
|
61
64
|
devTeam: string[];
|
|
62
65
|
staff: StaffAccess;
|
|
63
66
|
}
|
|
67
|
+
/** An app's role-resolve token, minus anything that could reconstruct it. */
|
|
68
|
+
export interface RolesTokenInfo {
|
|
69
|
+
appId: string;
|
|
70
|
+
/** Leading characters only — enough to recognise which token is deployed. */
|
|
71
|
+
prefix: string;
|
|
72
|
+
createdAt: string;
|
|
73
|
+
createdBy: string;
|
|
74
|
+
lastUsedAt?: string;
|
|
75
|
+
}
|
|
64
76
|
export interface RegisteredApp {
|
|
65
77
|
id: string;
|
|
66
78
|
name: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uic-coe-connect/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "The coe CLI \u2014 manage COEConnect apps (pipelines, deploys, access, env) from a terminal, with browser-approved auth. Designed to be driven by an AI agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|