@bli-cockpit/cli 0.2.199 → 0.2.200

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.
@@ -164,6 +164,7 @@ export function repairReceiptLines(repairs) {
164
164
  }
165
165
  export function doctorNeedsPerson(rows, receipt) {
166
166
  const defaults = {
167
+ "slack-connected": "cockpit slack connect",
167
168
  "cli-latest": "npm i -g @bli-cockpit/cli && cockpit doctor",
168
169
  "authed": "cockpit doctor",
169
170
  "roots-ok": "cockpit doctor",
@@ -0,0 +1,25 @@
1
+ import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
2
+ import { asRecord, callTower, openTower } from "./tower-command.js";
3
+ export async function checkSlackConnected(context) {
4
+ const message = "Slack connected for private-channel coverage";
5
+ try {
6
+ const tower = await openTower("doctor", context.command, context.io);
7
+ const response = await callTower(tower, { path: "/api/slack/connections", label: "doctor-slack", timeoutMs: 5_000 });
8
+ if (!response.ok)
9
+ return { id: "slack-connected", status: "needs_fix", code: response.reason, message: `${message}: could not read connection state.`, nextAction: `${tower.dashboardUrl}/settings/personal` };
10
+ const connections = asRecord(response.body)["connections"];
11
+ if (!connections || connections.length !== 2)
12
+ throw new Error("connection_state_incomplete");
13
+ const missing = connections.filter(row => !row["connected"]);
14
+ const links = missing.map(row => new URL(String(row["connectPath"]), tower.dashboardUrl).toString());
15
+ console.error("[doctor slack] checked", JSON.stringify({ missing: missing.length, reason: missing.length ? "slack_connect_required" : "ok" }));
16
+ return { id: "slack-connected", status: missing.length ? "needs_fix" : "ok", code: missing.length ? "slack_connect_required" : "slack_connected",
17
+ message: missing.length ? `${message}: connect ${missing.map(row => row["workspace"]).join(", ")} at ${links.join(" or ")}` : message,
18
+ ...(missing.length ? { nextAction: links.join(" ") } : {}),
19
+ };
20
+ }
21
+ catch {
22
+ console.error("[doctor slack] refused", JSON.stringify({ reason: "connection_read_failed" }));
23
+ return { id: "slack-connected", status: "skipped", code: "connection_read_failed", message: `${message}: unavailable until this machine is signed in.`, nextAction: `${context.command.dashboardUrl ?? DEFAULT_DASHBOARD_URL}/settings/personal` };
24
+ }
25
+ }
@@ -1,3 +1,4 @@
1
+ import { checkSlackConnected } from "./doctor-slack.js";
1
2
  import { checkUsageTelemetry, fixUsageTelemetry } from "./doctor-usage-telemetry.js";
2
3
  import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
3
4
  import { isSemverBelow } from "../scheduled-self-update.js";
@@ -153,6 +154,7 @@ export function doctorInvariantTable() {
153
154
  function doctorInvariants() {
154
155
  return [
155
156
  { id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
157
+ { id: "slack-connected", check: context => context.deps.checkSlackConnected ? context.deps.checkSlackConnected(context) : Promise.resolve({ id: "slack-connected", status: "skipped", code: "not_probed", message: "Slack connection check not provided." }) },
156
158
  {
157
159
  id: "authed",
158
160
  check: (context) => context.deps.readAuth(context),
@@ -248,6 +250,7 @@ function defaultDoctorDeps(hooks) {
248
250
  readRootsCoverWork,
249
251
  resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
250
252
  checkSingleInstall: checkSingleInstallState,
253
+ checkSlackConnected,
251
254
  checkAutostart: checkAutostartState,
252
255
  fixAutostart: fixAutostartState,
253
256
  checkUsageTelemetry,
@@ -193,11 +193,11 @@ export function parseSlackArgs(args) {
193
193
  ],
194
194
  });
195
195
  if (values.positionals.length > 1) {
196
- throw new Error("slack accepts one action: coverage or read.");
196
+ throw new Error("slack accepts one action: coverage, read, connect or disconnect.");
197
197
  }
198
198
  const rawAction = values.positionals[0] ?? "coverage";
199
- if (rawAction !== "coverage" && rawAction !== "read") {
200
- throw new Error("slack action must be coverage or read.");
199
+ if (!["coverage", "read", "connect", "disconnect"].includes(rawAction)) {
200
+ throw new Error("slack action must be coverage, read, connect or disconnect.");
201
201
  }
202
202
  const base = {
203
203
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -210,6 +210,14 @@ export function parseSlackArgs(args) {
210
210
  if (values.booleans.has("--list") && rawAction !== "coverage") {
211
211
  throw new Error("--list belongs to `cockpit slack coverage`.");
212
212
  }
213
+ if (rawAction === "connect" || rawAction === "disconnect") {
214
+ const workspace = optionalNonEmpty(values.flags.get("--workspace"));
215
+ if (workspace && !SLACK_WORKSPACE_KEYS.includes(workspace))
216
+ throw new Error("Unknown Slack workspace.");
217
+ if (rawAction === "disconnect" && !workspace)
218
+ throw new Error("slack disconnect needs --workspace bli|blue_pearl.");
219
+ return { kind: "slack", action: rawAction, workspace, ...base };
220
+ }
213
221
  if (rawAction === "coverage") {
214
222
  // `--workspace` here is a Slack workspace key, NOT the collection-root
215
223
  // `--workspace <path>` every setup command takes. Named at the point of use
@@ -262,7 +262,7 @@ export const TOWER_COMMAND_HELP = [
262
262
  [
263
263
  "slack",
264
264
  [
265
- "Usage: cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
265
+ "Usage: cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
266
266
  "",
267
267
  " cockpit slack coverage",
268
268
  " What Cockpit's Slack bot can see: how many channels it is in, what each",
@@ -111,7 +111,7 @@ export function localCommandHelp(command) {
111
111
  " cockpit models [list|show <provider:model>|compare <provider:model> <provider:model> [...]] [--highlight] [--dashboard-url <url>] [--json]",
112
112
  " cockpit scout [board|start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
113
113
  " cockpit ops [status [--job <id>] [--skips] [--memory] [--models] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
114
- " cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [--person <p>] [--channel <c>] [--query <text> [--word]] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--format compact|full]] [--json]",
114
+ " cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [--person <p>] [--channel <c>] [--query <text> [--word]] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--format compact|full]] [--json]",
115
115
  " cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | cli-floor [<version>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
116
116
  " cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes] | device [list] | device revoke <id|name> --reason <label> [--note \"<text>\"] [--yes]] [--json]",
117
117
  " cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.199");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.200");
25
25
  return 0;
26
26
  }
27
27
 
@@ -24,6 +24,24 @@ import { colorEnabled, dim, writeLine } from "./cli-io.js";
24
24
  import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
25
25
  export async function runSlack(command, io) {
26
26
  const tower = await openTower("slack", command, io);
27
+ if (command.action === "connect" || command.action === "disconnect") {
28
+ const result = await callTower(tower, { path: `/api/slack/connections${command.action === "disconnect" ? `?workspace=${command.workspace}` : ""}`,
29
+ method: command.action === "disconnect" ? "DELETE" : "GET", label: "slack-connection" });
30
+ if (!result.ok)
31
+ return writeCommandFailure(io, command.json, result);
32
+ writeLine(io.stderr, `[slack cli] connection answered ${JSON.stringify({ action: command.action, workspace: command.workspace ?? "all", reason: "ok" })}`);
33
+ const body = asRecord(result.body);
34
+ if (command.json)
35
+ writeLine(io.stdout, JSON.stringify(body));
36
+ else if (command.action === "disconnect")
37
+ writeLine(io.stdout, `Disconnected Slack for ${command.workspace}.`);
38
+ else
39
+ for (const connection of (body["connections"] ?? [])) {
40
+ if (!command.workspace || command.workspace === connection["workspace"])
41
+ writeLine(io.stdout, `${connection["workspace"]}: ${connection["connected"] ? "connected" : "Connect Slack"} ${new URL(String(connection["connectPath"]), tower.dashboardUrl).toString()}`);
42
+ }
43
+ return 0;
44
+ }
27
45
  return command.action === "coverage"
28
46
  ? runCoverageAction(command, io, tower)
29
47
  : runReadAction(command, io, tower);
@@ -86,6 +104,12 @@ function coverageLines(coverage, workspaces, staleOnly, list, paint) {
86
104
  for (const workspace of workspaces) {
87
105
  writeWorkspaceCoverageLines(lines, workspace, staleAfter, staleOnly, list, paint);
88
106
  }
107
+ for (const entry of (coverage["privateCoverage"] ?? [])) {
108
+ lines.push(`${entry["workspace"]}: ${entry["botMemberCount"]}/${entry["privateChannelsKnown"]} known private channels have the bot; ${entry["peopleConnected"]} people connected; as of ${entry["asOf"] ?? "no scan yet"}`);
109
+ lines.push(` Not connected: ${(entry["peopleNotConnected"] ?? []).join(", ") || "none"}`);
110
+ }
111
+ if (coverage["privateCoverageError"])
112
+ lines.push(`Private coverage unavailable: ${coverage["privateCoverageError"]}`);
89
113
  writeCoverageFooter(lines, coverage, paint);
90
114
  return lines;
91
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.199",
3
+ "version": "0.2.200",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.60",
31
- "@bli-cockpit/mcp": "0.1.111",
31
+ "@bli-cockpit/mcp": "0.1.112",
32
32
  "@bli-cockpit/telemetry-core": "0.1.69"
33
33
  },
34
34
  "repository": {