@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.
@@ -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 ?? "—"],
@@ -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 npm:ci@backend npm:build@backend migrate:backend chown pm2:restart@my-api
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 { registerRoleCommands } from "./commands/roles.js";
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
- registerRoleCommands();
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 RoleOperator = "equals" | "contains" | "startsWith" | "regex";
34
- export interface RoleCondition {
40
+ export type AccessOperator = "equals" | "contains" | "startsWith" | "regex";
41
+ export interface AccessCondition {
35
42
  attribute: string;
36
- operator: RoleOperator;
43
+ operator: AccessOperator;
37
44
  value: string;
38
45
  }
39
46
  /**
40
- * A rule matches when ALL its conditions hold; a role is granted when ANY rule
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 RoleRule {
45
- all: RoleCondition[];
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: RoleRule[];
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.2.2",
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.",
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"