@uic-coe-connect/cli 0.2.2 → 0.6.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/apps.js +3 -1
- 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 +16 -3
- package/dist/commands/vms.d.ts +1 -0
- package/dist/commands/vms.js +29 -0
- package/dist/index.js +4 -2
- package/dist/types.d.ts +16 -24
- package/package.json +2 -2
package/dist/commands/apps.js
CHANGED
|
@@ -34,9 +34,10 @@ export function registerAppCommands() {
|
|
|
34
34
|
table(apps.map((a) => ({
|
|
35
35
|
id: a.id,
|
|
36
36
|
name: a.name,
|
|
37
|
+
vm: a.vmId ?? "—",
|
|
37
38
|
url: a.url,
|
|
38
39
|
pipelines: String(a.pipelines?.length ?? 0),
|
|
39
|
-
})), ["id", "name", "url", "pipelines"]);
|
|
40
|
+
})), ["id", "name", "vm", "url", "pipelines"]);
|
|
40
41
|
});
|
|
41
42
|
},
|
|
42
43
|
}, {
|
|
@@ -49,6 +50,7 @@ export function registerAppCommands() {
|
|
|
49
50
|
details([
|
|
50
51
|
["Id", app.id],
|
|
51
52
|
["Name", app.name],
|
|
53
|
+
["VM", app.vmId ?? "—"],
|
|
52
54
|
["URL", app.url],
|
|
53
55
|
["Repo", app.repo ?? "—"],
|
|
54
56
|
["Branch", app.branch ?? "—"],
|
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 === undefined ? "" : `, ${block.restarts} restarts`}) · ${stream === "out" ? "stdout" : "stderr"} ──`);
|
|
50
|
+
if (block.lines.length === 0) {
|
|
51
|
+
info(block.error ? ` (${block.error})` : " (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,15 @@ 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 };
|
|
27
|
+
case "docker": {
|
|
28
|
+
const action = (arg || "up");
|
|
29
|
+
if (!["up", "restart", "build"].includes(action)) {
|
|
30
|
+
throw new CliError(`docker step action must be up, restart or build: got "${arg}"`, 6);
|
|
31
|
+
}
|
|
32
|
+
return { type: "docker", action, subdir: subdir || undefined };
|
|
33
|
+
}
|
|
25
34
|
case "npm":
|
|
26
35
|
if (!arg)
|
|
27
36
|
throw new CliError(`npm step needs a script: npm:ci or npm:build@subdir`, 6);
|
|
@@ -33,7 +42,7 @@ function parseStep(token) {
|
|
|
33
42
|
throw new CliError(`pm2 step needs an action: pm2:restart@process-name`, 6);
|
|
34
43
|
return { type: "pm2", action: arg, process: subdir || undefined };
|
|
35
44
|
default:
|
|
36
|
-
throw new CliError(`Unknown step "${token}".`, 6, "Valid: pull, scan, chown, npm:<script>[@subdir], migrate[:subdir], pm2:<action>[@process]");
|
|
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]");
|
|
37
46
|
}
|
|
38
47
|
}
|
|
39
48
|
function describeStep(step) {
|
|
@@ -42,6 +51,10 @@ function describeStep(step) {
|
|
|
42
51
|
return `npm ${step.script}${step.subdir ? ` (${step.subdir})` : ""}`;
|
|
43
52
|
case "migrate":
|
|
44
53
|
return `migrate${step.subdir ? ` (${step.subdir})` : ""}`;
|
|
54
|
+
case "env":
|
|
55
|
+
return `write .env${step.subdir ? ` (${step.subdir})` : ""}`;
|
|
56
|
+
case "docker":
|
|
57
|
+
return `docker compose ${step.action ?? "up"}${step.subdir ? ` (${step.subdir})` : ""}`;
|
|
45
58
|
case "pm2":
|
|
46
59
|
return `pm2 ${step.action}${step.process ? ` ${step.process}` : ""}`;
|
|
47
60
|
default:
|
|
@@ -114,7 +127,7 @@ export function registerPipelineCommands() {
|
|
|
114
127
|
summary: "Create a pipeline from a step list",
|
|
115
128
|
usage: 'pipelines create <app> --name "Full deploy" <step>... | --steps-file <f.json>',
|
|
116
129
|
details: [
|
|
117
|
-
"Steps: pull, scan, chown, npm:<script>[@subdir], migrate[:subdir], pm2:<action>[@process]",
|
|
130
|
+
"Steps: pull, scan, env[:subdir], chown, npm:<script>[@subdir], migrate[:subdir], docker[:<up|restart|build>][@subdir], pm2:<action>[@process]",
|
|
118
131
|
'Example: coe pipelines create myapp --name "Full" pull scan npm:ci@backend pm2:restart@myapp-api',
|
|
119
132
|
],
|
|
120
133
|
async run({ args, client }) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerVmCommands(): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { emit, info, table } from "../output.js";
|
|
2
|
+
import { register } from "../registry.js";
|
|
3
|
+
export function registerVmCommands() {
|
|
4
|
+
register({
|
|
5
|
+
name: "vms list",
|
|
6
|
+
summary: "The VMs this COEConnect knows about, and which it can act on",
|
|
7
|
+
usage: "vms list",
|
|
8
|
+
details: [
|
|
9
|
+
"An app runs on exactly one VM; deploys, logs and env all act there.",
|
|
10
|
+
"`managed` means this COEConnect instance may act on that VM — the dev",
|
|
11
|
+
"deployment manages only itself, so a prod app shows managed=no there.",
|
|
12
|
+
],
|
|
13
|
+
async run({ client }) {
|
|
14
|
+
const result = await client().request("GET", "/vms");
|
|
15
|
+
emit(result, () => {
|
|
16
|
+
table(result.vms.map((v) => ({
|
|
17
|
+
id: v.id,
|
|
18
|
+
hostname: v.hostname,
|
|
19
|
+
label: v.label ?? "—",
|
|
20
|
+
here: v.local ? "yes" : "",
|
|
21
|
+
managed: v.managed ? "yes" : "no",
|
|
22
|
+
agent: v.tokenPrefix ? `${v.tokenPrefix}…` : "no token",
|
|
23
|
+
})), ["id", "hostname", "label", "here", "managed", "agent"]);
|
|
24
|
+
info("");
|
|
25
|
+
info(`This server runs on ${result.localVmId}; it manages ${result.managed.join(", ")}.`);
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,9 +5,10 @@ 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
|
-
import {
|
|
11
|
+
import { registerVmCommands } from "./commands/vms.js";
|
|
11
12
|
import { info, setJsonMode } from "./output.js";
|
|
12
13
|
import { allCommands, findCommand, register } from "./registry.js";
|
|
13
14
|
registerAuthCommands();
|
|
@@ -17,7 +18,8 @@ registerDeployCommands();
|
|
|
17
18
|
registerAccessCommands();
|
|
18
19
|
registerEnvCommands();
|
|
19
20
|
registerResourceCommands();
|
|
20
|
-
|
|
21
|
+
registerLogCommands();
|
|
22
|
+
registerVmCommands();
|
|
21
23
|
function parseArgs(argv) {
|
|
22
24
|
const positional = [];
|
|
23
25
|
const flags = {};
|
package/dist/types.d.ts
CHANGED
|
@@ -12,9 +12,16 @@ 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;
|
|
21
|
+
} | {
|
|
22
|
+
type: "docker";
|
|
23
|
+
subdir?: string;
|
|
24
|
+
action?: "up" | "restart" | "build";
|
|
18
25
|
} | {
|
|
19
26
|
type: "chown";
|
|
20
27
|
} | {
|
|
@@ -30,50 +37,36 @@ export interface NamedPipeline {
|
|
|
30
37
|
name: string;
|
|
31
38
|
config: DeployConfig;
|
|
32
39
|
}
|
|
33
|
-
export type
|
|
34
|
-
export interface
|
|
40
|
+
export type AccessOperator = "equals" | "contains" | "startsWith" | "regex";
|
|
41
|
+
export interface AccessCondition {
|
|
35
42
|
attribute: string;
|
|
36
|
-
operator:
|
|
43
|
+
operator: AccessOperator;
|
|
37
44
|
value: string;
|
|
38
45
|
}
|
|
39
46
|
/**
|
|
40
|
-
* A rule matches when ALL its conditions hold;
|
|
47
|
+
* A rule matches when ALL its conditions hold; access is granted when ANY rule
|
|
41
48
|
* matches. The property is `all`, not `conditions` — the server reads `.all`
|
|
42
49
|
* and silently drops rules shaped any other way.
|
|
43
50
|
*/
|
|
44
|
-
export interface
|
|
45
|
-
all:
|
|
46
|
-
}
|
|
47
|
-
export interface AppRole {
|
|
48
|
-
key: string;
|
|
49
|
-
label: string;
|
|
50
|
-
description?: string;
|
|
51
|
-
members: string[];
|
|
52
|
-
rules: RoleRule[];
|
|
51
|
+
export interface AccessRule {
|
|
52
|
+
all: AccessCondition[];
|
|
53
53
|
}
|
|
54
54
|
export interface StaffAccess {
|
|
55
55
|
everyone: boolean;
|
|
56
56
|
members: string[];
|
|
57
|
-
rules:
|
|
57
|
+
rules: AccessRule[];
|
|
58
58
|
exceptions: string[];
|
|
59
59
|
}
|
|
60
60
|
export interface AppAccess {
|
|
61
61
|
devTeam: string[];
|
|
62
62
|
staff: StaffAccess;
|
|
63
63
|
}
|
|
64
|
-
/** An app's role-resolve token, minus anything that could reconstruct it. */
|
|
65
|
-
export interface RolesTokenInfo {
|
|
66
|
-
appId: string;
|
|
67
|
-
/** Leading characters only — enough to recognise which token is deployed. */
|
|
68
|
-
prefix: string;
|
|
69
|
-
createdAt: string;
|
|
70
|
-
createdBy: string;
|
|
71
|
-
lastUsedAt?: string;
|
|
72
|
-
}
|
|
73
64
|
export interface RegisteredApp {
|
|
74
65
|
id: string;
|
|
75
66
|
name: string;
|
|
76
67
|
url: string;
|
|
68
|
+
/** Which VM hosts this app — deploys, logs and env all act on that machine. */
|
|
69
|
+
vmId: string;
|
|
77
70
|
repo?: string;
|
|
78
71
|
branch?: string;
|
|
79
72
|
/**
|
|
@@ -83,7 +76,6 @@ export interface RegisteredApp {
|
|
|
83
76
|
*/
|
|
84
77
|
envKeys: string[];
|
|
85
78
|
pipelines?: NamedPipeline[];
|
|
86
|
-
roles?: AppRole[];
|
|
87
79
|
access?: AppAccess;
|
|
88
80
|
createdAt: string;
|
|
89
81
|
updatedAt: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uic-coe-connect/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The coe CLI
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "The coe CLI — 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": {
|
|
7
7
|
"coe": "./dist/index.js"
|