@uic-coe-connect/cli 0.9.0 → 0.11.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.
@@ -50,6 +50,8 @@ export function registerAppCommands() {
50
50
  details([
51
51
  ["Id", app.id],
52
52
  ["Name", app.name],
53
+ ["Description", app.description || "(none — required; set it before other edits)"],
54
+ ["Visibility", app.stage === "dev" ? "development (dev team only)" : "live"],
53
55
  ["VM", app.vmId ?? "—"],
54
56
  ["URL", app.url],
55
57
  ["Repo", app.repo ?? "—"],
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The directory from the terminal.
3
+ *
4
+ * Two audiences. Somebody curating the directory wants `list`, `show` and
5
+ * `add`. Somebody *integrating* an app wants `version` and `changes`, which
6
+ * show exactly what that app's mirror polls and exactly what it would receive —
7
+ * the questions that are otherwise answered by adding console.log to the SDK.
8
+ */
9
+ export declare function registerDirectoryCommands(): void;
@@ -0,0 +1,173 @@
1
+ import { details, emit, info, table } from "../output.js";
2
+ import { register } from "../registry.js";
3
+ const when = (iso) => (iso ? iso.slice(0, 16).replace("T", " ") : "—");
4
+ /**
5
+ * The directory from the terminal.
6
+ *
7
+ * Two audiences. Somebody curating the directory wants `list`, `show` and
8
+ * `add`. Somebody *integrating* an app wants `version` and `changes`, which
9
+ * show exactly what that app's mirror polls and exactly what it would receive —
10
+ * the questions that are otherwise answered by adding console.log to the SDK.
11
+ */
12
+ export function registerDirectoryCommands() {
13
+ register({
14
+ name: "directory list",
15
+ summary: "People in the shared directory",
16
+ usage: "directory list [--search <q>] [--org <code>] [--status <s>] [--source <s>] [--limit <n>]",
17
+ details: [
18
+ " --search Substring match on netid, name or email.",
19
+ " --org One of BME, CHE, CME, CS, ECE, MIE, COE. Repeatable as a comma list.",
20
+ " --status active (default) | inactive | all.",
21
+ " --source login | manual | app — how the record got here.",
22
+ " --limit Default 50.",
23
+ ],
24
+ async run({ args, client }) {
25
+ const params = new URLSearchParams();
26
+ for (const [flag, key] of [
27
+ ["search", "search"],
28
+ ["org", "organizations"],
29
+ ["status", "status"],
30
+ ["source", "source"],
31
+ ["limit", "limit"],
32
+ ]) {
33
+ const v = args.flag(flag);
34
+ if (v)
35
+ params.set(key, v);
36
+ }
37
+ if (!params.has("limit"))
38
+ params.set("limit", "50");
39
+ const data = await client().request("GET", `/directory/users?${params}`);
40
+ emit(data, () => {
41
+ if (data.users.length === 0) {
42
+ info("Nobody matches.");
43
+ return;
44
+ }
45
+ table(data.users.map((u) => ({
46
+ netid: u.netid,
47
+ name: u.displayName,
48
+ org: u.organization ?? "—",
49
+ email: u.email ?? "—",
50
+ status: u.active ? "active" : u.masked ? "inactive (masked)" : "inactive",
51
+ from: u.source,
52
+ })), ["netid", "name", "org", "email", "status", "from"]);
53
+ info("");
54
+ info(`${data.users.length} of ${data.total}.`);
55
+ });
56
+ },
57
+ }, {
58
+ name: "directory show",
59
+ summary: "One person's full directory record",
60
+ usage: "directory show <netid>",
61
+ async run({ args, client }) {
62
+ const netid = args.arg(0, "netid");
63
+ const data = await client().request("GET", `/directory/users?search=${encodeURIComponent(netid)}&status=all&limit=50`);
64
+ const user = data.users.find((u) => u.netid === netid);
65
+ if (!user)
66
+ throw new Error(`${netid} is not in the directory`);
67
+ emit({ user }, () => {
68
+ details([
69
+ ["NetID", user.netid],
70
+ ["Name", user.displayName],
71
+ ["Email", user.email ?? "—"],
72
+ ["UIN", user.uin ?? "—"],
73
+ ["Organization", user.organization ?? "—"],
74
+ ["Affiliations", user.affiliations.length > 0 ? user.affiliations.join(", ") : "—"],
75
+ ["Status", user.active ? "active" : "inactive"],
76
+ ["Added", `${user.source}${user.createdByApp ? ` (${user.createdByApp})` : ""}`],
77
+ ["Created", when(user.createdAt)],
78
+ ["Updated", when(user.updatedAt)],
79
+ ["Last seen", when(user.lastSeen)],
80
+ ]);
81
+ if (!user.active) {
82
+ info("");
83
+ info(user.masked
84
+ ? `Masked: apps see "Disabled User", not ${user.displayName}.`
85
+ : `Not masked: apps still see ${user.displayName}.`);
86
+ }
87
+ if (user.affiliations.length === 0) {
88
+ info("");
89
+ info("No affiliations — they have never signed in. Do not gate access on that field.");
90
+ }
91
+ });
92
+ },
93
+ }, {
94
+ name: "directory add",
95
+ summary: "Add somebody to the shared directory",
96
+ usage: "directory add <netid> --name <full name> [--email <e>] [--org <code>] [--uin <u>]",
97
+ details: [
98
+ " --name Required for somebody new. Ignored if they are already here.",
99
+ " --org One of BME, CHE, CME, CS, ECE, MIE, COE.",
100
+ "",
101
+ " Adding somebody who already exists changes nothing and is not an error.",
102
+ ],
103
+ async run({ args, client }) {
104
+ const netid = args.arg(0, "netid");
105
+ const body = { netid };
106
+ for (const [flag, key] of [
107
+ ["name", "displayName"],
108
+ ["email", "email"],
109
+ ["org", "organization"],
110
+ ["uin", "uin"],
111
+ ]) {
112
+ const v = args.flag(flag);
113
+ if (v)
114
+ body[key] = v;
115
+ }
116
+ const data = await client().request("POST", "/directory/users", body);
117
+ emit(data, () => info(`Added ${data.user.displayName} (${data.user.netid}).`));
118
+ },
119
+ }, {
120
+ name: "directory version",
121
+ summary: "What an app's mirror polls — generation, count, last change",
122
+ usage: "directory version",
123
+ details: [
124
+ " The generation is opaque and changes whenever anything in the directory",
125
+ " changes. A mirror that already holds this generation does no work.",
126
+ ],
127
+ async run({ client }) {
128
+ const data = await client().request("GET", "/directory/version");
129
+ emit(data, () => details([
130
+ ["Generation", data.generation],
131
+ ["People", String(data.count)],
132
+ ["Last change", when(data.updatedAt)],
133
+ ]));
134
+ },
135
+ }, {
136
+ name: "directory changes",
137
+ summary: "Exactly what a mirror would receive since a given time",
138
+ usage: "directory changes [--since <iso>] [--limit <n>]",
139
+ details: [
140
+ " --since ISO-8601. Omit it to see a first full sync.",
141
+ "",
142
+ " This is the app-facing projection, so it is what actually lands in",
143
+ " coe_users — no UIN, and deactivated people masked unless excepted.",
144
+ " Use it to check an integration without instrumenting the SDK.",
145
+ ],
146
+ async run({ args, client }) {
147
+ const params = new URLSearchParams();
148
+ params.set("updatedSince", args.flag("since") ?? "");
149
+ params.set("limit", args.flag("limit") ?? "500");
150
+ const data = await client().request("GET", `/directory/changes?${params}`);
151
+ emit(data, () => {
152
+ if (data.users.length === 0) {
153
+ info("Nothing has changed since then.");
154
+ }
155
+ else {
156
+ table(data.users.map((u) => ({
157
+ netid: u.netid,
158
+ name: u.displayName,
159
+ org: u.organization ?? "—",
160
+ email: u.email ?? "—",
161
+ active: u.active ? "yes" : "no",
162
+ changed: when(u.updatedAt),
163
+ })), ["netid", "name", "org", "email", "active", "changed"]);
164
+ info("");
165
+ info(`${data.users.length} of ${data.total} changed rows.`);
166
+ }
167
+ if (data.deleted.length > 0)
168
+ info(`Removed: ${data.deleted.join(", ")}`);
169
+ info(`Generation ${data.version.generation}, ${data.version.count} people.`);
170
+ });
171
+ },
172
+ });
173
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Events and alarms from the terminal.
3
+ *
4
+ * The read side matters most here: an agent investigating "why is this app
5
+ * behaving oddly" wants the event names, a series, and which alarms are
6
+ * currently firing, and none of that should require a browser.
7
+ */
8
+ export declare function registerEventCommands(): void;
@@ -0,0 +1,135 @@
1
+ import { details, emit, info, table } from "../output.js";
2
+ import { register } from "../registry.js";
3
+ import { resolveApp } from "./apps.js";
4
+ /**
5
+ * Events and alarms from the terminal.
6
+ *
7
+ * The read side matters most here: an agent investigating "why is this app
8
+ * behaving oddly" wants the event names, a series, and which alarms are
9
+ * currently firing, and none of that should require a browser.
10
+ */
11
+ export function registerEventCommands() {
12
+ register({
13
+ name: "events list",
14
+ summary: "Event names an app reports, and anything refused today",
15
+ usage: "events list <app>",
16
+ async run({ args, client }) {
17
+ const c = client();
18
+ const app = await resolveApp(c, args.arg(0, "app"));
19
+ const data = await c.request("GET", `/registered-apps/${app.id}/events/names`);
20
+ emit(data, () => {
21
+ if (data.names.length === 0) {
22
+ info(`${app.id} has not reported any events.`);
23
+ }
24
+ else {
25
+ table(data.names.map((n) => ({
26
+ name: n.name,
27
+ "first seen": n.firstSeen.slice(0, 10),
28
+ "last seen": n.lastSeen.slice(0, 16).replace("T", " "),
29
+ })), ["name", "first seen", "last seen"]);
30
+ }
31
+ const dropped = Object.values(data.drops).reduce((a, b) => a + b, 0);
32
+ if (dropped > 0) {
33
+ info("");
34
+ info(`${dropped} refused today — rate ${data.drops.rate ?? 0}, ` +
35
+ `over the name cap ${data.drops.names ?? 0}, unusable name ${data.drops.invalid ?? 0}.`);
36
+ }
37
+ info("");
38
+ info(`Limits: ${data.limits.ratePerMin}/min, ${data.limits.maxNames} distinct names, 30-day retention.`);
39
+ });
40
+ },
41
+ }, {
42
+ name: "events series",
43
+ summary: "One event name as a time series",
44
+ usage: "events series <app> <name> [--days <n>] [--bucket <min>] [--agg <a>]",
45
+ details: [
46
+ " --days How far back. Default 7, max 30.",
47
+ " --bucket Bucket width in minutes. Default 60.",
48
+ " --agg count | sum | avg | max | min. Default count.",
49
+ ],
50
+ async run({ args, client }) {
51
+ const c = client();
52
+ const app = await resolveApp(c, args.arg(0, "app"));
53
+ const name = args.arg(1, "name");
54
+ const params = new URLSearchParams({ name });
55
+ for (const [flag, key] of [["days", "days"], ["bucket", "bucket"], ["agg", "agg"]]) {
56
+ const v = args.flag(flag);
57
+ if (v)
58
+ params.set(key, v);
59
+ }
60
+ const data = await c.request("GET", `/registered-apps/${app.id}/events/series?${params}`);
61
+ emit(data, () => {
62
+ if (data.points.length === 0) {
63
+ info(`No "${name}" events in that window.`);
64
+ return;
65
+ }
66
+ table(data.points.map((p) => ({
67
+ when: p.ts.slice(0, 16).replace("T", " "),
68
+ value: String(Math.round(p.value * 100) / 100),
69
+ })), ["when", "value"]);
70
+ });
71
+ },
72
+ }, {
73
+ name: "events recent",
74
+ summary: "The most recent raw events",
75
+ usage: "events recent <app> [--name <event>] [--limit <n>]",
76
+ async run({ args, client }) {
77
+ const c = client();
78
+ const app = await resolveApp(c, args.arg(0, "app"));
79
+ const params = new URLSearchParams();
80
+ if (args.flag("name"))
81
+ params.set("name", args.flag("name"));
82
+ if (args.flag("limit"))
83
+ params.set("limit", args.flag("limit"));
84
+ const data = await c.request("GET", `/registered-apps/${app.id}/events/recent?${params}`);
85
+ emit(data, () => table(data.events.map((e) => ({
86
+ when: e.ts.slice(0, 19).replace("T", " "),
87
+ name: e.name,
88
+ value: String(e.value),
89
+ props: e.props ? JSON.stringify(e.props) : "",
90
+ })), ["when", "name", "value", "props"]));
91
+ },
92
+ }, {
93
+ name: "alarms list",
94
+ summary: "An app's alarms and what each is currently reading",
95
+ usage: "alarms list <app>",
96
+ async run({ args, client }) {
97
+ const c = client();
98
+ const app = await resolveApp(c, args.arg(0, "app"));
99
+ const data = await c.request("GET", `/registered-apps/${app.id}/alarms`);
100
+ emit(data, () => {
101
+ if (data.alarms.length === 0) {
102
+ info(`${app.id} has no alarms. Add one in the portal's Events tab.`);
103
+ return;
104
+ }
105
+ table(data.alarms.map((a) => ({
106
+ state: a.enabled ? a.state : "off",
107
+ name: a.name,
108
+ condition: a.description,
109
+ now: a.lastValue === null ? "—" : String(Math.round(a.lastValue * 100) / 100),
110
+ emails: a.recipients.join(","),
111
+ })), ["state", "name", "condition", "now", "emails"]);
112
+ const firing = data.alarms.filter((a) => a.enabled && a.state === "firing");
113
+ if (firing.length > 0) {
114
+ info("");
115
+ info(`${firing.length} firing: ${firing.map((a) => a.name).join(", ")}`);
116
+ }
117
+ });
118
+ },
119
+ }, {
120
+ name: "alarms check",
121
+ summary: "Evaluate one alarm now, without emailing anyone",
122
+ usage: "alarms check <app> <alarm-id>",
123
+ async run({ args, client }) {
124
+ const c = client();
125
+ const app = await resolveApp(c, args.arg(0, "app"));
126
+ const id = args.arg(1, "alarm-id");
127
+ const data = await c.request("POST", `/registered-apps/${app.id}/alarms/${id}/evaluate`);
128
+ emit(data, () => details([
129
+ ["Condition", data.description],
130
+ ["Value", data.measurable ? String(Math.round((data.value ?? 0) * 100) / 100) : "not measurable"],
131
+ ["Would fire", data.wouldFire ? "yes" : "no"],
132
+ ]));
133
+ },
134
+ });
135
+ }
@@ -0,0 +1 @@
1
+ export declare function registerIntegrationCommands(): void;
@@ -0,0 +1,193 @@
1
+ import { details, emit, info } from "../output.js";
2
+ import { register } from "../registry.js";
3
+ import { resolveApp } from "./apps.js";
4
+ /**
5
+ * Everything the SDK reads, and what each one turns on. The CLI knows the
6
+ * *names* rather than the values — env values live behind their own route on
7
+ * purpose (see `coe env`), and an integration check should never be the thing
8
+ * that prints a secret into a terminal scrollback.
9
+ */
10
+ const REQUIRED_ENV = [
11
+ { key: "SHIB_MODE", enables: "real SAML instead of the dev login form" },
12
+ { key: "SHIB_DOMAIN", enables: "the app's own hostname, for the SP endpoints" },
13
+ { key: "SHIB_VM_HOST", enables: "which VM's SP entity the app authenticates under" },
14
+ { key: "SESSION_SECRET", enables: "signing the session cookie" },
15
+ { key: "COE_URL", enables: "reaching COEConnect" },
16
+ { key: "COE_APP_ID", enables: "identifying this app to COEConnect" },
17
+ { key: "COE_APP_TOKEN", enables: "access enforcement and login reporting" },
18
+ {
19
+ key: "SHIB_PRIVATE_KEY_PATH",
20
+ enables: "a non-default SP key path",
21
+ optional: true,
22
+ },
23
+ ];
24
+ /** The legacy fleet-wide credentials, still honoured but worth moving off. */
25
+ const LEGACY_ENV = ["COE_ACCESS_TOKEN", "COE_EVENTS_TOKEN", "COE_NOTIFY_TOKEN"];
26
+ export function registerIntegrationCommands() {
27
+ register({
28
+ name: "integration show",
29
+ summary: "Full integration status for an app — what's wired up and what's missing",
30
+ usage: "integration show <app>",
31
+ async run({ args, client }) {
32
+ const c = client();
33
+ const app = await resolveApp(c, args.arg(0, "app"));
34
+ const token = await c
35
+ .request("GET", `/registered-apps/${app.id}/token`)
36
+ .then((r) => r.token)
37
+ .catch(() => null);
38
+ const usage = await c
39
+ .request("GET", `/registered-apps/${app.id}/logins?days=30`)
40
+ .catch(() => null);
41
+ const envKeys = new Set(app.envKeys ?? []);
42
+ const missing = REQUIRED_ENV.filter((e) => !e.optional && !envKeys.has(e.key));
43
+ const legacy = LEGACY_ENV.filter((k) => envKeys.has(k));
44
+ // Stored env only reaches the VM through a pipeline's `env` step. An
45
+ // app can look perfectly configured here and have none of it on disk,
46
+ // which is the single most common reason an integration "doesn't work".
47
+ const pipelines = app.pipelines ?? [];
48
+ const writesEnv = pipelines.some((p) => p.config.steps.some((s) => s.type === "env"));
49
+ const catalogOnly = app.integration === "catalog";
50
+ const next = nextSteps({
51
+ catalogOnly,
52
+ hasToken: Boolean(token),
53
+ tokenUsed: Boolean(token?.lastUsedAt),
54
+ missing: missing.map((m) => m.key),
55
+ legacy,
56
+ hasPipeline: pipelines.length > 0,
57
+ writesEnv,
58
+ reporting: usage?.reporting ?? false,
59
+ });
60
+ emit({
61
+ appId: app.id,
62
+ integration: app.integration ?? "coeconnect",
63
+ url: app.url,
64
+ vmId: app.vmId,
65
+ token,
66
+ env: {
67
+ present: REQUIRED_ENV.filter((e) => envKeys.has(e.key)).map((e) => e.key),
68
+ missing: missing.map((e) => e.key),
69
+ legacy,
70
+ },
71
+ pipeline: { count: pipelines.length, writesEnv },
72
+ usage: usage && {
73
+ reporting: usage.reporting,
74
+ distinctUsers30d: usage.distinctUsers,
75
+ },
76
+ nextSteps: next,
77
+ }, () => {
78
+ details([
79
+ ["App", `${app.id} (${app.url})`],
80
+ ["Integration", catalogOnly ? "catalogue entry — the app does not call COEConnect" : "coeconnect"],
81
+ [
82
+ "Service token",
83
+ token
84
+ ? `${token.preview}… created ${token.createdAt.slice(0, 10)} by ${token.createdBy}` +
85
+ (token.lastUsedAt ? `, last used ${token.lastUsedAt.slice(0, 16).replace("T", " ")}` : ", NEVER USED")
86
+ : "none — run: coe token generate " + app.id,
87
+ ],
88
+ ["Env present", REQUIRED_ENV.filter((e) => envKeys.has(e.key)).map((e) => e.key).join(", ") || "(none)"],
89
+ ["Env missing", missing.map((e) => e.key).join(", ") || "(none)"],
90
+ ["Legacy env", legacy.join(", ") || "(none)"],
91
+ ["Pipelines", `${pipelines.length}${pipelines.length > 0 ? (writesEnv ? ", writes .env" : ", NO env step") : ""}`],
92
+ ["Login reporting", usage ? (usage.reporting ? `yes — ${usage.distinctUsers} people in 30d` : "no logins reported yet") : "unknown"],
93
+ ]);
94
+ if (next.length > 0) {
95
+ info("");
96
+ info("NEXT STEPS");
97
+ for (const step of next)
98
+ info(` • ${step}`);
99
+ }
100
+ });
101
+ },
102
+ }, {
103
+ name: "token show",
104
+ summary: "Show an app's service token metadata (never the token itself)",
105
+ usage: "token show <app>",
106
+ async run({ args, client }) {
107
+ const c = client();
108
+ const app = await resolveApp(c, args.arg(0, "app"));
109
+ const { token } = await c.request("GET", `/registered-apps/${app.id}/token`);
110
+ emit({ appId: app.id, token }, () => {
111
+ if (!token) {
112
+ info(`No service token for ${app.id}. Run: coe token generate ${app.id}`);
113
+ return;
114
+ }
115
+ details([
116
+ ["Token", `${token.preview}…`],
117
+ ["Created", `${token.createdAt} by ${token.createdBy}`],
118
+ ["Last used", token.lastUsedAt ?? "never — the app has not called COEConnect"],
119
+ ]);
120
+ });
121
+ },
122
+ }, {
123
+ name: "token generate",
124
+ summary: "Mint a new service token, printing the three env lines an app needs",
125
+ usage: "token generate <app>",
126
+ async run({ args, client }) {
127
+ const c = client();
128
+ const app = await resolveApp(c, args.arg(0, "app"));
129
+ const existing = await c
130
+ .request("GET", `/registered-apps/${app.id}/token`)
131
+ .then((r) => r.token)
132
+ .catch(() => null);
133
+ const { token } = await c.request("POST", `/registered-apps/${app.id}/token`);
134
+ const env = {
135
+ COE_URL: c.server,
136
+ COE_APP_ID: app.id,
137
+ COE_APP_TOKEN: token,
138
+ };
139
+ emit({ appId: app.id, token, env, rotated: Boolean(existing) }, () => {
140
+ if (existing) {
141
+ info(`Rotated ${app.id}'s token — the previous one no longer works.`);
142
+ }
143
+ info("Shown once. Add these to the app's environment:");
144
+ info("");
145
+ for (const [k, v] of Object.entries(env))
146
+ info(` ${k}=${v}`);
147
+ info("");
148
+ info(`Set them with: coe env set ${app.id} ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(" ")}`);
149
+ info("They reach the VM on the next deploy, and only if a pipeline has an env step.");
150
+ });
151
+ },
152
+ });
153
+ }
154
+ /**
155
+ * Turns the facts into an ordered list of things to actually do.
156
+ *
157
+ * Ordered by dependency, not severity: telling someone to redeploy before they
158
+ * have a token to deploy is noise. An agent reads this top-down and stops
159
+ * having to infer the sequence from a pile of booleans.
160
+ */
161
+ function nextSteps(f) {
162
+ const steps = [];
163
+ if (f.catalogOnly) {
164
+ steps.push("This is a catalogue entry: it has a dev team but no staff access, and cannot enforce " +
165
+ "or report anything. Integrate it with @uic-coe-connect/auth first, then change its " +
166
+ "integration setting in the portal.");
167
+ return steps;
168
+ }
169
+ if (!f.hasToken)
170
+ steps.push("Generate a service token: coe token generate <app>");
171
+ if (f.missing.length > 0) {
172
+ steps.push(`Set the missing environment variables: ${f.missing.join(", ")}`);
173
+ }
174
+ if (f.legacy.length > 0) {
175
+ steps.push(`Replace the legacy fleet-wide credentials (${f.legacy.join(", ")}) with COE_APP_TOKEN. ` +
176
+ "They still work, but they authenticate against every app at once.");
177
+ }
178
+ if (!f.hasPipeline) {
179
+ steps.push("Create a deploy pipeline: coe pipelines create <app> ...");
180
+ }
181
+ else if (!f.writesEnv) {
182
+ steps.push("Add an env step to the pipeline, or stored variables never reach the VM: " +
183
+ "coe pipelines create <app> --name Deploy pull scan env:backend ...");
184
+ }
185
+ if (f.hasToken && !f.tokenUsed) {
186
+ steps.push("The token has never been used, so the app has not called COEConnect. Deploy it, then " +
187
+ "check the app's startup log for the [coe-auth] lines.");
188
+ }
189
+ else if (!f.reporting) {
190
+ steps.push("No logins reported yet. Sign in to the app once, then re-check.");
191
+ }
192
+ return steps;
193
+ }
@@ -200,6 +200,30 @@ export function registerReportCommands() {
200
200
  const { report } = await client().request("POST", `/reports/${id}/comments`, { body: args.flag("note") ?? "", status });
201
201
  emit({ report }, () => info(`✓ ${report.id} is now ${report.status}`));
202
202
  },
203
+ }, {
204
+ name: "reports close",
205
+ summary: "Close a report as done, or as won't do",
206
+ usage: "reports close <id> [--wont-do] [--note <message>]",
207
+ details: [
208
+ " --wont-do Close as declined rather than done",
209
+ " --note A message to send with it — closing in silence is how you",
210
+ " teach people to stop filing reports",
211
+ ],
212
+ async run({ args, client }) {
213
+ const id = args.arg(0, "id");
214
+ const status = args.bool("wont-do") ? "declined" : "done";
215
+ const { report } = await client().request("POST", `/reports/${id}/comments`, { body: args.flag("note") ?? "", status });
216
+ emit({ report }, () => info(`✓ Closed ${report.id} as ${report.status}`));
217
+ },
218
+ }, {
219
+ name: "reports reopen",
220
+ summary: "Reopen a closed report",
221
+ usage: "reports reopen <id> [--note <message>]",
222
+ async run({ args, client }) {
223
+ const id = args.arg(0, "id");
224
+ const { report } = await client().request("POST", `/reports/${id}/comments`, { body: args.flag("note") ?? "", status: "open" });
225
+ emit({ report }, () => info(`✓ Reopened ${report.id}`));
226
+ },
203
227
  }, {
204
228
  name: "reports attach",
205
229
  summary: "Attach a file to a report",
package/dist/index.js CHANGED
@@ -4,7 +4,10 @@ import { registerAccessCommands } from "./commands/access.js";
4
4
  import { registerAppCommands } from "./commands/apps.js";
5
5
  import { registerAuthCommands } from "./commands/auth.js";
6
6
  import { registerDeployCommands } from "./commands/deploy.js";
7
+ import { registerDirectoryCommands } from "./commands/directory.js";
7
8
  import { registerEnvCommands } from "./commands/env.js";
9
+ import { registerEventCommands } from "./commands/events.js";
10
+ import { registerIntegrationCommands } from "./commands/integration.js";
8
11
  import { registerLogCommands } from "./commands/logs.js";
9
12
  import { registerPipelineCommands } from "./commands/pipelines.js";
10
13
  import { registerReportCommands } from "./commands/reports.js";
@@ -17,11 +20,14 @@ registerAppCommands();
17
20
  registerPipelineCommands();
18
21
  registerDeployCommands();
19
22
  registerAccessCommands();
23
+ registerIntegrationCommands();
24
+ registerEventCommands();
20
25
  registerEnvCommands();
21
26
  registerResourceCommands();
22
27
  registerLogCommands();
23
28
  registerVmCommands();
24
29
  registerReportCommands();
30
+ registerDirectoryCommands();
25
31
  function parseArgs(argv) {
26
32
  const positional = [];
27
33
  const flags = {};
package/dist/types.d.ts CHANGED
@@ -64,6 +64,10 @@ export interface AppAccess {
64
64
  export interface RegisteredApp {
65
65
  id: string;
66
66
  name: string;
67
+ /** One line shown under the app's name in the launcher. Required on write. */
68
+ description: string;
69
+ /** "dev" hides the app from everyone but its dev team and webadmins. */
70
+ stage?: "production" | "dev";
67
71
  url: string;
68
72
  /** Which VM hosts this app — deploys, logs and env all act on that machine. */
69
73
  vmId: string;
@@ -77,6 +81,12 @@ export interface RegisteredApp {
77
81
  envKeys: string[];
78
82
  pipelines?: NamedPipeline[];
79
83
  access?: AppAccess;
84
+ /**
85
+ * Whether the app actually calls COEConnect, or is only listed in the
86
+ * portal's inventory. A catalogue entry has a dev team but can't enforce
87
+ * staff access or report usage. Absent means "coeconnect".
88
+ */
89
+ integration?: "coeconnect" | "catalog";
80
90
  createdAt: string;
81
91
  updatedAt: string;
82
92
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@uic-coe-connect/cli",
3
- "version": "0.9.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.",
3
+ "version": "0.11.0",
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": {
7
7
  "coe": "./dist/index.js"