@uic-coe-connect/cli 0.8.0 → 0.10.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,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
+ }
@@ -1,3 +1,5 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { basename } from "node:path";
1
3
  import { CliError } from "../client.js";
2
4
  import { details, emit, info, table } from "../output.js";
3
5
  import { register } from "../registry.js";
@@ -14,6 +16,31 @@ import { register } from "../registry.js";
14
16
  */
15
17
  const STATUSES = ["open", "planned", "in-progress", "done", "declined"];
16
18
  const CLOSED = ["done", "declined"];
19
+ function humanSize(bytes) {
20
+ if (bytes < 1024)
21
+ return `${bytes} B`;
22
+ if (bytes < 1024 * 1024)
23
+ return `${Math.round(bytes / 1024)} KB`;
24
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
25
+ }
26
+ /** Every file on the thread, wherever it hangs — the whole list is what you search. */
27
+ function allAttachments(report) {
28
+ return [...(report.attachments ?? []), ...(report.comments ?? []).flatMap((c) => c.attachments ?? [])];
29
+ }
30
+ /** Match an attachment by id or by filename, the way `files download` matches. */
31
+ function findAttachment(report, ref) {
32
+ const all = allAttachments(report);
33
+ const byId = all.find((a) => a.id === ref);
34
+ if (byId)
35
+ return byId;
36
+ const matches = all.filter((a) => a.name.toLowerCase().includes(ref.toLowerCase()));
37
+ if (matches.length === 1)
38
+ return matches[0];
39
+ if (matches.length > 1) {
40
+ throw new CliError(`"${ref}" matches ${matches.length} attachments: ${matches.map((a) => a.name).join(", ")}`, 4, "Use the exact attachment id.");
41
+ }
42
+ throw new CliError(`No attachment "${ref}" on that report.`, 4, all.length === 0 ? "It has no attachments." : `It has: ${all.map((a) => a.name).join(", ")}`);
43
+ }
17
44
  function assertStatus(value) {
18
45
  if (!STATUSES.includes(value)) {
19
46
  throw new CliError(`Unknown status "${value}".`, 6, `Use one of: ${STATUSES.join(", ")}`);
@@ -120,10 +147,21 @@ export function registerReportCommands() {
120
147
  ...(r.pageUrl ? [["Page", r.pageUrl]] : []),
121
148
  ]);
122
149
  process.stdout.write(`\n${r.body}\n`);
150
+ for (const a of r.attachments ?? []) {
151
+ process.stdout.write(` [attachment] ${a.name} (${humanSize(a.size)}) ${a.id}\n`);
152
+ }
123
153
  for (const comment of r.comments ?? []) {
124
154
  const change = comment.statusTo ? ` [→ ${comment.statusTo}]` : "";
125
155
  process.stdout.write(`\n--- ${comment.authorName ?? comment.authorNetid} · ${comment.createdAt}${change}\n` +
126
156
  (comment.body ? `${comment.body}\n` : ""));
157
+ for (const a of comment.attachments ?? []) {
158
+ process.stdout.write(` [attachment] ${a.name} (${humanSize(a.size)}) ${a.id}\n`);
159
+ }
160
+ }
161
+ const files = allAttachments(r);
162
+ if (files.length > 0) {
163
+ info("");
164
+ info(`${files.length} attachment(s) — fetch one with: coe reports download ${r.id} <name>`);
127
165
  }
128
166
  if (!data.canRespond) {
129
167
  info("");
@@ -162,14 +200,75 @@ export function registerReportCommands() {
162
200
  const { report } = await client().request("POST", `/reports/${id}/comments`, { body: args.flag("note") ?? "", status });
163
201
  emit({ report }, () => info(`✓ ${report.id} is now ${report.status}`));
164
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
+ },
227
+ }, {
228
+ name: "reports attach",
229
+ summary: "Attach a file to a report",
230
+ usage: "reports attach <id> <path>",
231
+ details: [
232
+ "Screenshots, logs, spreadsheets — anything that shows the problem.",
233
+ "Up to 10 MB each, 20 per report. It lands on the report itself; use the",
234
+ "web UI to attach a file to one particular reply.",
235
+ ],
236
+ async run({ args, client }) {
237
+ const id = args.arg(0, "id");
238
+ const filePath = args.arg(1, "path");
239
+ let bytes;
240
+ try {
241
+ bytes = readFileSync(filePath);
242
+ }
243
+ catch (error) {
244
+ throw new CliError(`Can't read ${filePath}: ${error instanceof Error ? error.message : String(error)}`, 4);
245
+ }
246
+ const file = await client().upload(`/reports/${id}/attachments`, basename(filePath), bytes);
247
+ emit({ attachment: file }, () => info(`✓ Attached ${file.name} (${humanSize(file.size)}) to ${id}`));
248
+ },
249
+ }, {
250
+ name: "reports download",
251
+ summary: "Download an attachment from a report",
252
+ usage: "reports download <id> <name|attachment-id> [--out <path>]",
253
+ async run({ args, client }) {
254
+ const c = client();
255
+ const id = args.arg(0, "id");
256
+ const { report } = await c.request("GET", `/reports/${id}`);
257
+ const file = findAttachment(report, args.arg(1, "name"));
258
+ const bytes = await c.download(`/reports/${id}/attachments/${file.id}`);
259
+ const out = args.flag("out") ?? file.name;
260
+ writeFileSync(out, bytes);
261
+ emit({ reportId: id, attachment: file.name, out, bytes: bytes.length }, () => info(`✓ Wrote ${out} (${humanSize(bytes.length)})`));
262
+ },
165
263
  }, {
166
264
  name: "reports file",
167
265
  summary: "File a bug report or feature request",
168
- usage: "reports file <app> <title> [--details <text>] [--request]",
266
+ usage: "reports file <app> <title> [--details <text>] [--request] [--attach <path>]",
169
267
  details: [
170
268
  " <app> The app it's about, or `coeconnect` for the portal itself",
171
269
  " --details The body of the report (defaults to the title)",
172
270
  " --request File it as a feature request rather than a bug",
271
+ " --attach A file to attach — a screenshot, a log excerpt",
173
272
  ],
174
273
  async run({ args, client }) {
175
274
  const c = client();
@@ -182,7 +281,22 @@ export function registerReportCommands() {
182
281
  title,
183
282
  body: args.flag("details") ?? title,
184
283
  });
185
- emit({ report }, () => info(`✓ Filed ${report.id} ${report.title}`));
284
+ // Attached after the report exists, because that is what it hangs off.
285
+ // A failure here must not read as "the report wasn't filed" — it was.
286
+ const attach = args.flag("attach");
287
+ let attachment = null;
288
+ if (attach) {
289
+ try {
290
+ attachment = await c.upload(`/reports/${report.id}/attachments`, basename(attach), readFileSync(attach));
291
+ }
292
+ catch (error) {
293
+ info(`✓ Filed ${report.id} — ${report.title}`);
294
+ throw new CliError(`The report was filed, but ${attach} did not attach: ` +
295
+ (error instanceof Error ? error.message : String(error)), 1, `Try again with: coe reports attach ${report.id} ${attach}`);
296
+ }
297
+ }
298
+ emit({ report, attachment }, () => info(`✓ Filed ${report.id} — ${report.title}` +
299
+ (attachment ? ` (attached ${attachment.name})` : "")));
186
300
  },
187
301
  });
188
302
  }
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ 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 { registerEventCommands } from "./commands/events.js";
9
+ import { registerIntegrationCommands } from "./commands/integration.js";
8
10
  import { registerLogCommands } from "./commands/logs.js";
9
11
  import { registerPipelineCommands } from "./commands/pipelines.js";
10
12
  import { registerReportCommands } from "./commands/reports.js";
@@ -17,6 +19,8 @@ registerAppCommands();
17
19
  registerPipelineCommands();
18
20
  registerDeployCommands();
19
21
  registerAccessCommands();
22
+ registerIntegrationCommands();
23
+ registerEventCommands();
20
24
  registerEnvCommands();
21
25
  registerResourceCommands();
22
26
  registerLogCommands();
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.8.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.10.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"