@uic-coe-connect/cli 0.1.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.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A command failed in a way the user (or the AI reading stderr) should act on.
3
+ * Carries an exit code so `main` can exit meaningfully without every command
4
+ * duplicating process.exit plumbing.
5
+ */
6
+ export declare class CliError extends Error {
7
+ readonly exitCode: number;
8
+ readonly hint?: string | undefined;
9
+ constructor(message: string, exitCode?: number, hint?: string | undefined);
10
+ }
11
+ export interface ClientOptions {
12
+ /** Server override from --server. */
13
+ server?: string;
14
+ /** Skip the credential requirement (only `login` needs this). */
15
+ anonymous?: boolean;
16
+ }
17
+ export interface Client {
18
+ server: string;
19
+ netid: string;
20
+ request<T>(method: string, path: string, body?: unknown): Promise<T>;
21
+ /** Streams a text/plain response line by line — used by `deploy`. */
22
+ stream(method: string, path: string, body: unknown, onLine: (line: string) => void): Promise<void>;
23
+ }
24
+ export declare function createClient(options?: ClientOptions): Client;
package/dist/client.js ADDED
@@ -0,0 +1,88 @@
1
+ import { readCredentials, resolveServer } from "./config.js";
2
+ /**
3
+ * A command failed in a way the user (or the AI reading stderr) should act on.
4
+ * Carries an exit code so `main` can exit meaningfully without every command
5
+ * duplicating process.exit plumbing.
6
+ */
7
+ export class CliError extends Error {
8
+ exitCode;
9
+ hint;
10
+ constructor(message, exitCode = 1, hint) {
11
+ super(message);
12
+ this.exitCode = exitCode;
13
+ this.hint = hint;
14
+ }
15
+ }
16
+ /** Turns a non-2xx response into a CliError carrying the server's own message. */
17
+ async function fail(res, server) {
18
+ const body = (await res.json().catch(() => ({})));
19
+ const message = body.error ?? `${res.status} ${res.statusText}`;
20
+ if (res.status === 401) {
21
+ throw new CliError(`Not authenticated: ${message}`, 2, `Run: coe login --server ${server}`);
22
+ }
23
+ if (res.status === 403) {
24
+ throw new CliError(`Not permitted: ${message}`, 3, "You must be on that app's dev team (or a webadmin). Check with: coe apps list");
25
+ }
26
+ if (res.status === 404)
27
+ throw new CliError(`Not found: ${message}`, 4);
28
+ throw new CliError(message, 1);
29
+ }
30
+ /**
31
+ * Spread into a fetch init. Omits `body` entirely rather than passing
32
+ * `undefined` — fetch rejects a body on GET even when it's undefined.
33
+ */
34
+ function bodyInit(body) {
35
+ return body === undefined ? {} : { body: JSON.stringify(body) };
36
+ }
37
+ export function createClient(options = {}) {
38
+ const server = resolveServer(options.server);
39
+ const creds = readCredentials();
40
+ if (!options.anonymous && (!creds || creds.server !== server)) {
41
+ throw new CliError(creds ? `No session for ${server} (you're logged in to ${creds.server}).` : "Not logged in.", 2, `Run: coe login --server ${server}`);
42
+ }
43
+ const headers = { "Content-Type": "application/json" };
44
+ if (creds?.token)
45
+ headers.Authorization = `Bearer ${creds.token}`;
46
+ return {
47
+ server,
48
+ netid: creds?.netid ?? "",
49
+ async request(method, path, body) {
50
+ let res;
51
+ try {
52
+ res = await fetch(`${server}${path}`, { method, headers, ...bodyInit(body) });
53
+ }
54
+ catch (error) {
55
+ 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
+ }
57
+ if (!res.ok)
58
+ await fail(res, server);
59
+ if (res.status === 204)
60
+ return undefined;
61
+ return (await res.json());
62
+ },
63
+ async stream(method, path, body, onLine) {
64
+ const res = await fetch(`${server}${path}`, { method, headers, ...bodyInit(body) });
65
+ if (!res.ok)
66
+ await fail(res, server);
67
+ if (!res.body)
68
+ throw new CliError("The server sent no output stream.");
69
+ // The deploy endpoint writes plain newline-delimited text, not SSE, so
70
+ // buffer across chunk boundaries rather than assuming whole lines.
71
+ const reader = res.body.getReader();
72
+ const decoder = new TextDecoder();
73
+ let buffer = "";
74
+ for (;;) {
75
+ const { done, value } = await reader.read();
76
+ if (done)
77
+ break;
78
+ buffer += decoder.decode(value, { stream: true });
79
+ const lines = buffer.split("\n");
80
+ buffer = lines.pop() ?? "";
81
+ for (const line of lines)
82
+ onLine(line);
83
+ }
84
+ if (buffer)
85
+ onLine(buffer);
86
+ },
87
+ };
88
+ }
@@ -0,0 +1 @@
1
+ export declare function registerAccessCommands(): void;
@@ -0,0 +1,141 @@
1
+ import { CliError } from "../client.js";
2
+ import { details, emit, info } from "../output.js";
3
+ import { register } from "../registry.js";
4
+ import { resolveApp } from "./apps.js";
5
+ function currentAccess(app) {
6
+ return (app.access ?? { devTeam: [], staff: { everyone: true, members: [], rules: [], exceptions: [] } });
7
+ }
8
+ /** Comma- or space-separated NetIDs, deduped. */
9
+ function netids(raw) {
10
+ return [...new Set(raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean))];
11
+ }
12
+ async function saveAccess(client, appId, access) {
13
+ return client.request("PUT", `/registered-apps/${appId}/access`, { access });
14
+ }
15
+ export function registerAccessCommands() {
16
+ register({
17
+ name: "access show",
18
+ summary: "Show an app's dev team and staff access",
19
+ usage: "access show <app>",
20
+ async run({ args, client }) {
21
+ const app = await resolveApp(client(), args.arg(0, "app"));
22
+ const access = currentAccess(app);
23
+ emit({ appId: app.id, access }, () => {
24
+ details([
25
+ ["Dev team", access.devTeam.join(", ") || "(none — webadmins only)"],
26
+ ["Staff access", access.staff.everyone ? "everyone" : "restricted"],
27
+ [" Members", access.staff.members.join(", ") || "(none)"],
28
+ [" Blacklist", access.staff.exceptions.join(", ") || "(none)"],
29
+ [" Rules", String(access.staff.rules.length)],
30
+ ]);
31
+ });
32
+ },
33
+ }, {
34
+ name: "access add-dev",
35
+ summary: "Add NetIDs to an app's dev team",
36
+ usage: "access add-dev <app> <netid>[,<netid>...]",
37
+ async run({ args, client }) {
38
+ const c = client();
39
+ const app = await resolveApp(c, args.arg(0, "app"));
40
+ const toAdd = netids(args.arg(1, "netid"));
41
+ const access = currentAccess(app);
42
+ const devTeam = [...new Set([...access.devTeam, ...toAdd])];
43
+ await saveAccess(c, app.id, { ...access, devTeam });
44
+ emit({ appId: app.id, devTeam, added: toAdd }, () => info(`✓ Dev team for ${app.id}: ${devTeam.join(", ")}`));
45
+ },
46
+ }, {
47
+ name: "access remove-dev",
48
+ summary: "Remove NetIDs from an app's dev team",
49
+ usage: "access remove-dev <app> <netid>[,<netid>...]",
50
+ async run({ args, client }) {
51
+ const c = client();
52
+ const app = await resolveApp(c, args.arg(0, "app"));
53
+ const toRemove = new Set(netids(args.arg(1, "netid")));
54
+ const access = currentAccess(app);
55
+ const devTeam = access.devTeam.filter((n) => !toRemove.has(n));
56
+ // Emptying the dev team hands the app back to webadmins only — and can
57
+ // lock the caller out of their own app. Make that an explicit choice.
58
+ if (devTeam.length === 0 && access.devTeam.length > 0 && !args.bool("yes")) {
59
+ 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.");
60
+ }
61
+ await saveAccess(c, app.id, { ...access, devTeam });
62
+ emit({ appId: app.id, devTeam, removed: [...toRemove] }, () => info(`✓ Dev team for ${app.id}: ${devTeam.join(", ") || "(none)"}`));
63
+ },
64
+ }, {
65
+ name: "access set-staff",
66
+ summary: "Set staff access to everyone or restricted",
67
+ usage: "access set-staff <app> everyone|restricted",
68
+ async run({ args, client }) {
69
+ const c = client();
70
+ const app = await resolveApp(c, args.arg(0, "app"));
71
+ const mode = args.arg(1, "mode");
72
+ if (mode !== "everyone" && mode !== "restricted") {
73
+ throw new CliError(`Mode must be "everyone" or "restricted".`, 6);
74
+ }
75
+ const access = currentAccess(app);
76
+ const next = {
77
+ ...access,
78
+ staff: { ...access.staff, everyone: mode === "everyone" },
79
+ };
80
+ await saveAccess(c, app.id, next);
81
+ emit({ appId: app.id, staffAccess: mode }, () => info(`✓ Staff access for ${app.id}: ${mode}`));
82
+ },
83
+ }, {
84
+ name: "access add-staff",
85
+ summary: "Add NetIDs to the staff members list",
86
+ usage: "access add-staff <app> <netid>[,<netid>...]",
87
+ async run({ args, client }) {
88
+ const c = client();
89
+ const app = await resolveApp(c, args.arg(0, "app"));
90
+ const toAdd = netids(args.arg(1, "netid"));
91
+ const access = currentAccess(app);
92
+ const members = [...new Set([...access.staff.members, ...toAdd])];
93
+ await saveAccess(c, app.id, { ...access, staff: { ...access.staff, members } });
94
+ emit({ appId: app.id, members, added: toAdd }, () => {
95
+ info(`✓ Staff members for ${app.id}: ${members.join(", ")}`);
96
+ if (access.staff.everyone) {
97
+ info(" note: staff access is still 'everyone', so this list isn't enforced yet.");
98
+ }
99
+ });
100
+ },
101
+ }, {
102
+ name: "access remove-staff",
103
+ summary: "Remove NetIDs from the staff members list",
104
+ usage: "access remove-staff <app> <netid>[,<netid>...]",
105
+ async run({ args, client }) {
106
+ const c = client();
107
+ const app = await resolveApp(c, args.arg(0, "app"));
108
+ const toRemove = new Set(netids(args.arg(1, "netid")));
109
+ const access = currentAccess(app);
110
+ const members = access.staff.members.filter((n) => !toRemove.has(n));
111
+ await saveAccess(c, app.id, { ...access, staff: { ...access.staff, members } });
112
+ emit({ appId: app.id, members, removed: [...toRemove] }, () => info(`✓ Staff members for ${app.id}: ${members.join(", ") || "(none)"}`));
113
+ },
114
+ }, {
115
+ name: "access block",
116
+ summary: "Add NetIDs to the blacklist (overrides every grant)",
117
+ usage: "access block <app> <netid>[,<netid>...]",
118
+ async run({ args, client }) {
119
+ const c = client();
120
+ const app = await resolveApp(c, args.arg(0, "app"));
121
+ const toAdd = netids(args.arg(1, "netid"));
122
+ const access = currentAccess(app);
123
+ const exceptions = [...new Set([...access.staff.exceptions, ...toAdd])];
124
+ await saveAccess(c, app.id, { ...access, staff: { ...access.staff, exceptions } });
125
+ emit({ appId: app.id, blacklist: exceptions, added: toAdd }, () => info(`✓ Blacklist for ${app.id}: ${exceptions.join(", ")}`));
126
+ },
127
+ }, {
128
+ name: "access unblock",
129
+ summary: "Remove NetIDs from the blacklist",
130
+ usage: "access unblock <app> <netid>[,<netid>...]",
131
+ async run({ args, client }) {
132
+ const c = client();
133
+ const app = await resolveApp(c, args.arg(0, "app"));
134
+ const toRemove = new Set(netids(args.arg(1, "netid")));
135
+ const access = currentAccess(app);
136
+ const exceptions = access.staff.exceptions.filter((n) => !toRemove.has(n));
137
+ await saveAccess(c, app.id, { ...access, staff: { ...access.staff, exceptions } });
138
+ emit({ appId: app.id, blacklist: exceptions, removed: [...toRemove] }, () => info(`✓ Blacklist for ${app.id}: ${exceptions.join(", ") || "(none)"}`));
139
+ },
140
+ });
141
+ }
@@ -0,0 +1,9 @@
1
+ import type { Client } from "../client.js";
2
+ import type { RegisteredApp } from "../types.js";
3
+ /**
4
+ * Resolve an app by id, or by a unique name/URL fragment. Agents and humans
5
+ * both tend to type the app's display name, and a 404 on "Travel Form" when
6
+ * the id is "travel-form" is a bad first experience.
7
+ */
8
+ export declare function resolveApp(client: Client, ref: string): Promise<RegisteredApp>;
9
+ export declare function registerAppCommands(): void;
@@ -0,0 +1,63 @@
1
+ import { CliError } from "../client.js";
2
+ import { details, emit, table } from "../output.js";
3
+ import { register } from "../registry.js";
4
+ /**
5
+ * Resolve an app by id, or by a unique name/URL fragment. Agents and humans
6
+ * both tend to type the app's display name, and a 404 on "Travel Form" when
7
+ * the id is "travel-form" is a bad first experience.
8
+ */
9
+ export async function resolveApp(client, ref) {
10
+ const { apps } = await client.request("GET", "/registered-apps");
11
+ const exact = apps.find((a) => a.id === ref);
12
+ if (exact)
13
+ return client.request("GET", `/registered-apps/${exact.id}`);
14
+ const needle = ref.toLowerCase();
15
+ const matches = apps.filter((a) => a.name.toLowerCase().includes(needle) || a.url.toLowerCase().includes(needle));
16
+ if (matches.length === 1) {
17
+ return client.request("GET", `/registered-apps/${matches[0].id}`);
18
+ }
19
+ if (matches.length > 1) {
20
+ throw new CliError(`"${ref}" matches ${matches.length} apps: ${matches.map((a) => a.id).join(", ")}`, 4, "Use the exact app id.");
21
+ }
22
+ throw new CliError(`No app "${ref}" that you can manage.`, 4, apps.length > 0
23
+ ? `You manage: ${apps.map((a) => a.id).join(", ")}`
24
+ : "You aren't on any app's dev team.");
25
+ }
26
+ export function registerAppCommands() {
27
+ register({
28
+ name: "apps list",
29
+ summary: "List the apps you can manage",
30
+ usage: "apps list",
31
+ async run({ client }) {
32
+ const { apps } = await client().request("GET", "/registered-apps");
33
+ emit({ apps }, () => {
34
+ table(apps.map((a) => ({
35
+ id: a.id,
36
+ name: a.name,
37
+ url: a.url,
38
+ pipelines: String(a.pipelines?.length ?? 0),
39
+ })), ["id", "name", "url", "pipelines"]);
40
+ });
41
+ },
42
+ }, {
43
+ name: "apps show",
44
+ summary: "Show one app's full configuration",
45
+ usage: "apps show <app>",
46
+ async run({ args, client }) {
47
+ const app = await resolveApp(client(), args.arg(0, "app"));
48
+ emit(app, () => {
49
+ details([
50
+ ["Id", app.id],
51
+ ["Name", app.name],
52
+ ["URL", app.url],
53
+ ["Repo", app.repo ?? "—"],
54
+ ["Branch", app.branch ?? "—"],
55
+ ["Pipelines", (app.pipelines ?? []).map((p) => p.name).join(", ") || "(none)"],
56
+ ["Dev team", (app.access?.devTeam ?? []).join(", ") || "(none)"],
57
+ ["Env vars", Object.keys(app.env ?? {}).join(", ") || "(none)"],
58
+ ["Updated", new Date(app.updatedAt).toLocaleString()],
59
+ ]);
60
+ });
61
+ },
62
+ });
63
+ }
@@ -0,0 +1 @@
1
+ export declare function registerAuthCommands(): void;
@@ -0,0 +1,163 @@
1
+ import { spawn } from "node:child_process";
2
+ import { CliError, createClient } from "../client.js";
3
+ import { clearCredentials, credentialsPath, readCredentials, resolveServer, saveCredentials, saveServer, sessionLabel, } from "../config.js";
4
+ import { details, emit, info, isJsonMode } from "../output.js";
5
+ import { register } from "../registry.js";
6
+ /** Best-effort browser open. Never fatal — the URL is always printed too. */
7
+ function openBrowser(url) {
8
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
9
+ try {
10
+ spawn(command, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" })
11
+ .on("error", () => { })
12
+ .unref();
13
+ }
14
+ catch {
15
+ /* headless box or no opener — the printed URL is the fallback */
16
+ }
17
+ }
18
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
+ export function registerAuthCommands() {
20
+ register({
21
+ name: "login",
22
+ summary: "Start a session (approve it in your browser)",
23
+ usage: "login [--server <url>]",
24
+ details: [
25
+ "Prints a code, opens the approval page, and waits. The token is minted",
26
+ "against whichever NetID is signed in there, so a terminal can never hold",
27
+ "more permission than the human who approved it.",
28
+ ],
29
+ anonymous: true,
30
+ async run({ args }) {
31
+ const server = resolveServer(args.flag("server"));
32
+ const client = createClient({ server, anonymous: true });
33
+ const start = await client.request("POST", "/cli/auth/start", {
34
+ label: sessionLabel(),
35
+ });
36
+ const url = `${server}/cli/authorize?code=${encodeURIComponent(start.userCode)}`;
37
+ info(`\n Your code: ${start.userCode}`);
38
+ info(` Approve at: ${url}\n`);
39
+ if (!args.bool("no-browser"))
40
+ openBrowser(url);
41
+ info("Waiting for approval… (Ctrl-C to cancel)");
42
+ const deadline = Date.now() + start.expiresIn * 1000;
43
+ for (;;) {
44
+ if (Date.now() > deadline) {
45
+ throw new CliError("That code expired before it was approved.", 2, "Run: coe login");
46
+ }
47
+ await sleep(2000);
48
+ const poll = await client.request("GET", `/cli/auth/poll?deviceCode=${encodeURIComponent(start.deviceCode)}`);
49
+ if (poll.status === "pending")
50
+ continue;
51
+ if (poll.status === "denied")
52
+ throw new CliError("That login was denied.", 2);
53
+ if (poll.status === "expired") {
54
+ throw new CliError("That code expired or was already used.", 2, "Run: coe login");
55
+ }
56
+ // Expiry is tracked client-side purely to give a clean "run coe login"
57
+ // instead of a 401 mid-command; the server is still the authority.
58
+ const expiresAt = new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString();
59
+ saveCredentials({ server, netid: poll.netid, token: poll.token, expiresAt });
60
+ saveServer(server);
61
+ emit({ loggedIn: true, netid: poll.netid, server, expiresAt }, () => {
62
+ info(`\n✓ Signed in as ${poll.netid} on ${server}`);
63
+ info(` Session expires ${new Date(expiresAt).toLocaleString()}`);
64
+ });
65
+ return;
66
+ }
67
+ },
68
+ }, {
69
+ name: "logout",
70
+ summary: "End this session (or all of them with --all)",
71
+ usage: "logout [--all]",
72
+ anonymous: true,
73
+ async run({ args }) {
74
+ const creds = readCredentials();
75
+ if (!creds) {
76
+ emit({ loggedOut: false, reason: "no active session" }, () => info("No active session."));
77
+ return;
78
+ }
79
+ const client = createClient({ server: creds.server });
80
+ const all = args.bool("all");
81
+ const result = await client.request("DELETE", `/cli/sessions${all ? "?all=true" : ""}`);
82
+ clearCredentials();
83
+ emit({ loggedOut: true, revoked: result.revoked }, () => info(`✓ Signed out (${result.revoked} session${result.revoked === 1 ? "" : "s"} revoked)`));
84
+ },
85
+ }, {
86
+ name: "whoami",
87
+ summary: "Show the signed-in NetID, server, and what you can manage",
88
+ usage: "whoami",
89
+ async run({ client }) {
90
+ const c = client();
91
+ const me = await c.request("GET", "/me");
92
+ const apps = await c.request("GET", "/registered-apps");
93
+ emit({ ...me.user, server: c.server, manages: apps.apps.map((a) => a.id) }, () => {
94
+ details([
95
+ ["NetID", me.user.netid],
96
+ ["Server", c.server],
97
+ ["Roles", me.user.roles?.join(", ") || "(none)"],
98
+ ["Manages", apps.apps.map((a) => a.id).join(", ") || "(no apps)"],
99
+ ]);
100
+ });
101
+ },
102
+ }, {
103
+ name: "auth list",
104
+ summary: "List your active terminal sessions",
105
+ usage: "auth list",
106
+ async run({ client }) {
107
+ const { sessions } = await client().request("GET", "/cli/sessions");
108
+ emit({ sessions }, () => {
109
+ if (sessions.length === 0) {
110
+ info("(no active sessions)");
111
+ return;
112
+ }
113
+ for (const s of sessions) {
114
+ details([
115
+ ["Label", s.label],
116
+ ["Created", new Date(s.createdAt).toLocaleString()],
117
+ ["Expires", new Date(s.expiresAt).toLocaleString()],
118
+ ["Last used", s.lastUsedAt ? new Date(s.lastUsedAt).toLocaleString() : "never"],
119
+ ]);
120
+ info("");
121
+ }
122
+ });
123
+ },
124
+ }, {
125
+ name: "auth revoke",
126
+ summary: "Revoke every terminal session for your NetID",
127
+ usage: "auth revoke --all",
128
+ async run({ args, client }) {
129
+ if (!args.bool("all")) {
130
+ throw new CliError("Pass --all to confirm revoking every session.", 6);
131
+ }
132
+ const result = await client().request("DELETE", "/cli/sessions?all=true");
133
+ clearCredentials();
134
+ emit(result, () => info(`✓ Revoked ${result.revoked} session(s)`));
135
+ },
136
+ }, {
137
+ name: "auth status",
138
+ summary: "Where credentials are stored and whether they're valid",
139
+ usage: "auth status",
140
+ anonymous: true,
141
+ async run() {
142
+ const creds = readCredentials();
143
+ const payload = {
144
+ authenticated: Boolean(creds),
145
+ netid: creds?.netid ?? null,
146
+ server: creds?.server ?? resolveServer(),
147
+ expiresAt: creds?.expiresAt ?? null,
148
+ credentialsPath: credentialsPath(),
149
+ };
150
+ emit(payload, () => {
151
+ details([
152
+ ["Authenticated", payload.authenticated ? "yes" : "no — run: coe login"],
153
+ ["NetID", payload.netid ?? "—"],
154
+ ["Server", payload.server],
155
+ ["Expires", payload.expiresAt ? new Date(payload.expiresAt).toLocaleString() : "—"],
156
+ ["Credentials", payload.credentialsPath],
157
+ ]);
158
+ });
159
+ if (!payload.authenticated && !isJsonMode())
160
+ info("\nRun `coe login` to start a session.");
161
+ },
162
+ });
163
+ }
@@ -0,0 +1,4 @@
1
+ import type { RegisteredApp } from "../types.js";
2
+ export declare function registerDeployCommands(): void;
3
+ /** Re-exported for the docs generator and tests. */
4
+ export type { RegisteredApp };
@@ -0,0 +1,120 @@
1
+ import { CliError } from "../client.js";
2
+ import { emit, info, isJsonMode, table } from "../output.js";
3
+ import { register } from "../registry.js";
4
+ import { resolveApp } from "./apps.js";
5
+ /** Sentinels the deploy stream ends with — the server's own success signal. */
6
+ const OK = "__DEPLOY_OK__";
7
+ const FAILED = "__DEPLOY_FAILED__";
8
+ export function registerDeployCommands() {
9
+ register({
10
+ name: "deploy",
11
+ summary: "Run a deploy pipeline, streaming its output",
12
+ usage: "deploy <app> [--pipeline <p>] [--branch <b>|--current] [--version <v>]",
13
+ details: [
14
+ "Streams the live console. Exits 0 only if the deploy succeeded, so it's",
15
+ "safe to chain. A pipeline containing a DB migrate step needs",
16
+ "--confirm-migrate, matching the confirmation the web UI asks for.",
17
+ ],
18
+ async run({ args, client }) {
19
+ const c = client();
20
+ const app = await resolveApp(c, args.arg(0, "app"));
21
+ const pipelines = app.pipelines ?? [];
22
+ if (pipelines.length === 0) {
23
+ throw new CliError(`${app.id} has no deploy pipeline configured.`, 6, "Create one with: coe pipelines create");
24
+ }
25
+ const ref = args.flag("pipeline");
26
+ const chosen = ref
27
+ ? (pipelines.find((p) => p.id === ref) ??
28
+ pipelines.find((p) => p.name.toLowerCase() === ref.toLowerCase()))
29
+ : pipelines[0];
30
+ if (!chosen) {
31
+ throw new CliError(`No pipeline "${ref}" on ${app.id}.`, 4, `Available: ${pipelines.map((p) => p.name).join(", ")}`);
32
+ }
33
+ const branch = args.flag("branch");
34
+ const source = args.bool("current")
35
+ ? { mode: "current" }
36
+ : branch
37
+ ? { mode: "branch", branch }
38
+ : {};
39
+ const body = {
40
+ pipelineId: chosen.id,
41
+ version: args.flag("version"),
42
+ source,
43
+ confirmMigrate: args.bool("confirm-migrate"),
44
+ };
45
+ const needsMigrate = chosen.config.steps.some((s) => s.type === "migrate");
46
+ if (needsMigrate && !args.bool("confirm-migrate")) {
47
+ throw new CliError(`Pipeline "${chosen.name}" runs database migrations.`, 6, "Re-run with --confirm-migrate once you've checked the migration is safe.");
48
+ }
49
+ info(`Deploying ${app.id} · pipeline "${chosen.name}"${branch ? ` · branch ${branch}` : ""}`);
50
+ const lines = [];
51
+ const sentinels = [];
52
+ await c.stream("POST", `/registered-apps/${app.id}/deploy`, body, (line) => {
53
+ if (line === OK || line === FAILED) {
54
+ sentinels.push(line);
55
+ return;
56
+ }
57
+ lines.push(line);
58
+ // In JSON mode the log is part of the final payload instead, so stdout
59
+ // stays parseable; progress still shows on stderr.
60
+ if (isJsonMode())
61
+ info(line);
62
+ else
63
+ process.stdout.write(`${line}\n`);
64
+ });
65
+ // Derived after the stream rather than assigned inside the callback:
66
+ // "unknown" is a real third state — a dropped connection mid-deploy
67
+ // ends the stream with no sentinel at all.
68
+ const outcome = sentinels.includes(OK)
69
+ ? "ok"
70
+ : sentinels.includes(FAILED)
71
+ ? "failed"
72
+ : "unknown";
73
+ emit({ appId: app.id, pipeline: chosen.name, status: outcome, log: lines }, () => {
74
+ info(outcome === "ok" ? `\n✓ Deploy succeeded` : `\n✗ Deploy ${outcome}`);
75
+ });
76
+ // A stream that ended without a sentinel means the connection dropped
77
+ // mid-deploy — the deploy may still be running, so don't report success.
78
+ if (outcome !== "ok") {
79
+ throw new CliError(outcome === "failed"
80
+ ? "The deploy failed — see the output above."
81
+ : "The deploy stream ended without a result; check build history.", 7);
82
+ }
83
+ },
84
+ }, {
85
+ name: "builds",
86
+ summary: "Show an app's build history",
87
+ usage: "builds <app> [--limit <n>]",
88
+ async run({ args, client }) {
89
+ const c = client();
90
+ const app = await resolveApp(c, args.arg(0, "app"));
91
+ const { builds } = await c.request("GET", `/registered-apps/${app.id}/builds`);
92
+ const limit = Number(args.flag("limit") ?? 20);
93
+ const shown = builds.slice(0, Number.isFinite(limit) ? limit : 20);
94
+ emit({ appId: app.id, builds: shown }, () => {
95
+ table(shown.map((b) => ({
96
+ started: new Date(b.startedAt).toLocaleString(),
97
+ status: b.status,
98
+ version: b.version ?? "—",
99
+ pipeline: b.pipelineName ?? "—",
100
+ source: b.source,
101
+ by: b.by ?? "—",
102
+ commit: b.commit?.slice(0, 8) ?? "—",
103
+ })), ["started", "status", "version", "pipeline", "source", "by", "commit"]);
104
+ });
105
+ },
106
+ }, {
107
+ name: "branches",
108
+ summary: "List the branches of an app's repo",
109
+ usage: "branches <app>",
110
+ async run({ args, client }) {
111
+ const c = client();
112
+ const app = await resolveApp(c, args.arg(0, "app"));
113
+ const result = await c.request("GET", `/registered-apps/${app.id}/branches`);
114
+ emit({ appId: app.id, ...result }, () => {
115
+ for (const b of result.branches)
116
+ process.stdout.write(`${b}\n`);
117
+ });
118
+ },
119
+ });
120
+ }
@@ -0,0 +1 @@
1
+ export declare function registerEnvCommands(): void;