@slack/radar-mcp 1.4.0 → 1.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.
package/dist/web/bin.js CHANGED
@@ -1,23 +1,149 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync } from "child_process";
3
- import { createServer, WEB_PORT } from "./server.js";
3
+ import { realpathSync } from "fs";
4
+ import http from "http";
5
+ import { fileURLToPath } from "url";
6
+ import { cleanupOnBind, createServer, seedInitialSpec, WEB_PORT } from "./server.js";
7
+ import { RADAR_VERSION } from "../shared/constants.js";
8
+ /**
9
+ * One loopback GET against the dashboard's own port, used by the adopt path. Resolves with the
10
+ * status, body, and headers, or null on error/timeout. Centralizes the timeout + 127.0.0.1
11
+ * binding the probes share (the MCP side has its own copy in mcp/index.ts; this is the web
12
+ * bin's). Never throws.
13
+ */
14
+ function loopbackGet(path, timeoutMs) {
15
+ return new Promise((resolve) => {
16
+ const req = http.request({ host: "127.0.0.1", port: WEB_PORT, path, method: "GET", timeout: timeoutMs }, (res) => {
17
+ let body = "";
18
+ res.on("data", (c) => (body += c));
19
+ res.on("end", () => resolve({ status: res.statusCode, body, headers: res.headers }));
20
+ });
21
+ req.on("error", () => resolve(null));
22
+ req.on("timeout", () => {
23
+ req.destroy();
24
+ resolve(null);
25
+ });
26
+ req.end();
27
+ });
28
+ }
29
+ /**
30
+ * Decide whether a /spec response body identifies a Radar dashboard. A Radar /spec
31
+ * is always a plain object carrying a `viz` array (even a blank spec is
32
+ * { title, blank, viz: [] }). Requiring both excludes a foreign service that happens
33
+ * to 200 with arbitrary JSON (a bare array, a string, an unrelated object). Exported
34
+ * for unit testing. statusCode must be 200.
35
+ */
36
+ export function isRadarSpecBody(statusCode, body) {
37
+ if (statusCode !== 200)
38
+ return false;
39
+ let spec;
40
+ try {
41
+ spec = JSON.parse(body);
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ // typeof [] is "object" in JS, hence the explicit Array.isArray exclusion.
47
+ return (spec !== null &&
48
+ typeof spec === "object" &&
49
+ !Array.isArray(spec) &&
50
+ Array.isArray(spec.viz));
51
+ }
52
+ /** Open the dashboard in the default browser. Best-effort; never throws. */
53
+ function openBrowser(url) {
54
+ try {
55
+ if (process.platform === "darwin") {
56
+ execSync(`open ${url}`, { stdio: "ignore" });
57
+ }
58
+ else if (process.platform === "linux") {
59
+ execSync(`xdg-open ${url}`, { stdio: "ignore" });
60
+ }
61
+ }
62
+ catch {
63
+ // Browser open is best-effort
64
+ }
65
+ }
66
+ /**
67
+ * The port is busy. Decide whether the thing holding it is an existing Radar
68
+ * dashboard (adopt it: open the browser and exit cleanly) or something else
69
+ * (print the manual options and exit non-zero). We do NOT kill the holder:
70
+ * `lsof -ti :PORT` could be a browser, a teammate's server, or any unrelated
71
+ * process, and killing by port from a published tool is a footgun.
72
+ *
73
+ * Detection probes GET /spec on the loopback port. /spec is instant (it returns
74
+ * the current dashboard spec, no device round-trip) and is unique to a Radar
75
+ * dashboard, so a 200 with a JSON body is a reliable fingerprint. The response's
76
+ * X-Radar-Version header lets us notice when the running dashboard is a DIFFERENT
77
+ * Radar version than this launcher and say so — we still adopt it (never kill the
78
+ * holder), the note just explains why a feature you expect might be missing.
79
+ */
80
+ function handlePortInUse() {
81
+ const url = `http://localhost:${WEB_PORT}`;
82
+ void loopbackGet("/spec", 2000).then((r) => {
83
+ if (r && isRadarSpecBody(r.status, r.body)) {
84
+ const running = r.headers["x-radar-version"];
85
+ if (typeof running === "string" && running !== RADAR_VERSION) {
86
+ console.log(`Note: the running dashboard is Radar ${running}, this launcher is ${RADAR_VERSION}. ` +
87
+ `Adopting the running one as-is (it owns the port). Restart it to pick up ${RADAR_VERSION}.`);
88
+ }
89
+ console.log(`Slack Radar is already running at ${url}. Opening it.`);
90
+ if (!process.env.SLACK_RADAR_NO_OPEN)
91
+ openBrowser(url);
92
+ process.exit(0);
93
+ }
94
+ reportForeignPort();
95
+ });
96
+ }
97
+ /** Port is held by something that is not a Radar dashboard. Never kill it for the user. */
98
+ function reportForeignPort() {
99
+ console.error(`Port ${WEB_PORT} is already in use by another program (not a Radar dashboard).\n` +
100
+ `Free it, or run Radar on another port: SLACK_RADAR_WEB_PORT=8101 slack-radar web`);
101
+ process.exit(1);
102
+ }
4
103
  /**
5
104
  * Start the web dashboard server, open the browser, and wire shutdown signals.
6
105
  * Extracted so both the dedicated `slack-radar-web` bin and the default bin's
7
- * `web` subcommand dispatcher launch the dashboard the same way.
106
+ * `web` subcommand dispatcher launch the dashboard the same way. Exported so the
107
+ * dispatcher (mcp/index.ts) calls it explicitly rather than relying on an import
108
+ * side-effect, which keeps importing this module (e.g. in a test) side-effect-free.
8
109
  */
9
- function startWebServer() {
110
+ export function startWebServer() {
111
+ // Safety net: a single route handler must never take the whole dashboard down. An async
112
+ // callback (e.g. a device-probe that both ends and errors) can otherwise throw
113
+ // ERR_HTTP_HEADERS_SENT outside any try/catch and kill the process. Log and keep serving;
114
+ // handlers still guard their own writes, this is the backstop for anything they miss.
115
+ process.on("uncaughtException", (err) => {
116
+ console.error("uncaughtException (kept alive):", err);
117
+ });
118
+ process.on("unhandledRejection", (err) => {
119
+ console.error("unhandledRejection (kept alive):", err);
120
+ });
10
121
  const server = createServer();
11
122
  server.on("error", (err) => {
12
123
  if (err.code === "EADDRINUSE") {
13
- console.error(`Port ${WEB_PORT} is already in use. Another instance may be running.\n` +
14
- `Kill it with: lsof -ti :${WEB_PORT} | xargs kill\n` +
15
- `Or set a different port: SLACK_RADAR_WEB_PORT=8101 slack-radar web`);
16
- process.exit(1);
124
+ // Likely our own dashboard from an earlier launch. Adopt it if so; only error
125
+ // if the port is held by something else. Never kill the holder for the user.
126
+ handlePortInUse();
127
+ return;
17
128
  }
18
129
  throw err;
19
130
  });
20
- server.listen(WEB_PORT, () => {
131
+ // Bind loopback-only. The dashboard serves sensitive device data with no auth:
132
+ // live ring buffers, the on-device message store via the DB browser, and the live
133
+ // screen mirror (open DMs, notifications). Omitting the host arg binds all
134
+ // interfaces (0.0.0.0/::), which would let any same-network peer hit /dbquery or
135
+ // grant screen consent (a single unauthenticated process boolean) and watch the
136
+ // device. The consent gate only raises the bar for the local user; 127.0.0.1 is
137
+ // what actually closes the network. The device-side connection is already
138
+ // loopback-scoped (constants.ts); the dashboard's own listener must match. Remote
139
+ // access, if ever needed, belongs in a separate token-gated feature.
140
+ server.listen(WEB_PORT, "127.0.0.1", () => {
141
+ // Seed the starting spec AND wipe stale pulled DBs only now that THIS process owns the
142
+ // port. Doing both here (not at server.js import) means a second launcher that loses the
143
+ // bind never touches the running dashboard's spec file or its port-scoped pulled-DB cache,
144
+ // so adopting it leaves its view and open DB-browser tab intact.
145
+ seedInitialSpec();
146
+ cleanupOnBind();
21
147
  const url = `http://localhost:${WEB_PORT}`;
22
148
  console.log(`Slack Radar Web running at ${url}`);
23
149
  // Auto-open the browser for the HUMAN launcher (`slack-radar web` / `slack-radar-web`),
@@ -27,17 +153,7 @@ function startWebServer() {
27
153
  // /radar-ui. The tool returns the URL instead, for the Claude Code session to surface.
28
154
  if (process.env.SLACK_RADAR_NO_OPEN)
29
155
  return;
30
- try {
31
- if (process.platform === "darwin") {
32
- execSync(`open ${url}`, { stdio: "ignore" });
33
- }
34
- else if (process.platform === "linux") {
35
- execSync(`xdg-open ${url}`, { stdio: "ignore" });
36
- }
37
- }
38
- catch {
39
- // Browser open is best-effort
40
- }
156
+ openBrowser(url);
41
157
  });
42
158
  const shutdown = () => {
43
159
  console.log("\nShutting down…");
@@ -47,4 +163,21 @@ function startWebServer() {
47
163
  process.on("SIGINT", shutdown);
48
164
  process.on("SIGTERM", shutdown);
49
165
  }
50
- startWebServer();
166
+ // Only launch when run as the bin (`slack-radar-web` / `slack-radar web`), not when
167
+ // imported (e.g. by a test that exercises isRadarSpecBody). argv[1] is the script path
168
+ // node was invoked with; compare it to this module's own path. realpathSync resolves the
169
+ // npm bin symlink (node_modules/.bin) to the real dist path so the comparison holds.
170
+ function invokedDirectly() {
171
+ const arg = process.argv[1];
172
+ if (!arg)
173
+ return false;
174
+ try {
175
+ return fileURLToPath(import.meta.url) === realpathSync(arg);
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ }
181
+ if (invokedDirectly()) {
182
+ startWebServer();
183
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Session-long host-side logcat capture for the web dashboard.
3
+ *
4
+ * The device does not buffer logcat for us (the on-device radar server captures
5
+ * network/RTM/clog into ring buffers, but logcat is a host-side `adb logcat`
6
+ * stream with no replayable device buffer). So when a browser switches dashboard
7
+ * tabs, the SSE `/stream` connection is torn down and reopened, and anything that
8
+ * used to drive logcat off the per-connection lifecycle would lose every line
9
+ * emitted while no stream was attached — the user sees their log count shrink on
10
+ * a tab switch.
11
+ *
12
+ * This module fixes that the way Android Studio does: ONE logcat process per
13
+ * device for the whole server session, feeding a bounded session ring buffer.
14
+ * Each `/stream` connection subscribes as a listener and is first replayed the
15
+ * entire retained buffer, then receives live lines. Switching tabs only
16
+ * adds/removes a listener; the underlying capture and its history are untouched.
17
+ *
18
+ * The class takes its spawn and parse functions by injection so it is unit
19
+ * testable without spawning a real `adb logcat` (the integration path is covered
20
+ * by the smoke test on a real device).
21
+ */
22
+ import type { ChildProcess } from "child_process";
23
+ /** A parsed logcat frame, as delivered to the dashboard over SSE. */
24
+ export interface LogFrame {
25
+ id: number;
26
+ timestamp: number;
27
+ log_ts: string;
28
+ pid: number;
29
+ tid: number;
30
+ level: string;
31
+ tag: string;
32
+ message: string;
33
+ package?: string;
34
+ }
35
+ /** Spawn a process producing logcat lines on stdout. Injectable for tests. */
36
+ export type SpawnLogcat = () => ChildProcess;
37
+ /** Parse one raw logcat line into a frame, or null if it is not a log line. */
38
+ export type ParseLine = (line: string) => LogFrame | null;
39
+ /** Receives one frame. `replay` is true for buffered history, false for live. */
40
+ export type LogListener = (frame: LogFrame, replay: boolean) => void;
41
+ /**
42
+ * A single session-long logcat capture with a bounded ring buffer and listener
43
+ * fan-out. One instance is shared across every dashboard stream connection.
44
+ */
45
+ export declare class LogSession {
46
+ private readonly spawnLogcat;
47
+ private readonly parseLine;
48
+ private readonly cap;
49
+ private child;
50
+ private lineBuf;
51
+ private readonly ring;
52
+ private readonly listeners;
53
+ private cleanupRegistered;
54
+ constructor(spawnLogcat: SpawnLogcat, parseLine: ParseLine, cap?: number);
55
+ /** True while a logcat child is running. */
56
+ isRunning(): boolean;
57
+ /** Number of frames currently retained (for tests / diagnostics). */
58
+ bufferSize(): number;
59
+ /** Number of attached stream listeners (for tests / diagnostics). */
60
+ listenerCount(): number;
61
+ /**
62
+ * Start the capture if it is not already running. Idempotent: a second call
63
+ * while a child is alive is a no-op, so concurrent tab opens cannot fan out
64
+ * duplicate logcat processes. Returns true if a capture is running after the
65
+ * call (already-running counts as success), false if the spawn failed.
66
+ */
67
+ start(): boolean;
68
+ /** Stop the capture and drop the child. The ring buffer is preserved. */
69
+ stop(): void;
70
+ /**
71
+ * Install process-exit handlers (once) so the persistent `adb logcat` child is
72
+ * killed when the server process exits. Without this, the capture orphans when
73
+ * the server is terminated by signal — notably when `open_radar_dashboard`
74
+ * spawns the dashboard as a child and later kills it by PID — and orphaned adb
75
+ * logcat readers accumulate across dashboard open/close cycles, holding the
76
+ * device's logcat stream. Mirrors logcat-capture.ts's registerCleanup().
77
+ */
78
+ registerCleanup(): void;
79
+ /**
80
+ * Subscribe a stream to the session. The listener is FIRST replayed the entire
81
+ * retained ring (replay=true) so a freshly opened tab shows full history, then
82
+ * receives every subsequent live frame (replay=false). Returns an unsubscribe
83
+ * function; call it when the stream closes (this does NOT stop the capture).
84
+ */
85
+ subscribe(listener: LogListener): () => void;
86
+ private onChildGone;
87
+ private onData;
88
+ private push;
89
+ }
@@ -0,0 +1,144 @@
1
+ import { MAX_LOG_SESSION_BUFFER } from "../shared/constants.js";
2
+ /**
3
+ * A single session-long logcat capture with a bounded ring buffer and listener
4
+ * fan-out. One instance is shared across every dashboard stream connection.
5
+ */
6
+ export class LogSession {
7
+ spawnLogcat;
8
+ parseLine;
9
+ cap;
10
+ child = null;
11
+ lineBuf = "";
12
+ ring = [];
13
+ listeners = new Set();
14
+ cleanupRegistered = false;
15
+ constructor(spawnLogcat, parseLine, cap = MAX_LOG_SESSION_BUFFER) {
16
+ this.spawnLogcat = spawnLogcat;
17
+ this.parseLine = parseLine;
18
+ this.cap = cap > 0 ? cap : MAX_LOG_SESSION_BUFFER;
19
+ }
20
+ /** True while a logcat child is running. */
21
+ isRunning() {
22
+ return this.child !== null;
23
+ }
24
+ /** Number of frames currently retained (for tests / diagnostics). */
25
+ bufferSize() {
26
+ return this.ring.length;
27
+ }
28
+ /** Number of attached stream listeners (for tests / diagnostics). */
29
+ listenerCount() {
30
+ return this.listeners.size;
31
+ }
32
+ /**
33
+ * Start the capture if it is not already running. Idempotent: a second call
34
+ * while a child is alive is a no-op, so concurrent tab opens cannot fan out
35
+ * duplicate logcat processes. Returns true if a capture is running after the
36
+ * call (already-running counts as success), false if the spawn failed.
37
+ */
38
+ start() {
39
+ if (this.child)
40
+ return true;
41
+ let child;
42
+ try {
43
+ child = this.spawnLogcat();
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ this.child = child;
49
+ this.lineBuf = "";
50
+ child.stdout?.on("data", (c) => this.onData(c));
51
+ // A crashed/killed logcat (device unplugged, adb server restart) clears the
52
+ // child so the next connect() can restart it. The retained ring survives so
53
+ // history is not lost across a transient device drop.
54
+ child.on("error", () => this.onChildGone(child));
55
+ child.on("exit", () => this.onChildGone(child));
56
+ return true;
57
+ }
58
+ /** Stop the capture and drop the child. The ring buffer is preserved. */
59
+ stop() {
60
+ const child = this.child;
61
+ this.child = null;
62
+ this.lineBuf = "";
63
+ if (child) {
64
+ try {
65
+ child.kill();
66
+ }
67
+ catch {
68
+ // already dead
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Install process-exit handlers (once) so the persistent `adb logcat` child is
74
+ * killed when the server process exits. Without this, the capture orphans when
75
+ * the server is terminated by signal — notably when `open_radar_dashboard`
76
+ * spawns the dashboard as a child and later kills it by PID — and orphaned adb
77
+ * logcat readers accumulate across dashboard open/close cycles, holding the
78
+ * device's logcat stream. Mirrors logcat-capture.ts's registerCleanup().
79
+ */
80
+ registerCleanup() {
81
+ if (this.cleanupRegistered)
82
+ return;
83
+ this.cleanupRegistered = true;
84
+ process.on("exit", () => this.stop());
85
+ process.on("SIGINT", () => {
86
+ this.stop();
87
+ process.exit(130);
88
+ });
89
+ process.on("SIGTERM", () => {
90
+ this.stop();
91
+ process.exit(143);
92
+ });
93
+ }
94
+ /**
95
+ * Subscribe a stream to the session. The listener is FIRST replayed the entire
96
+ * retained ring (replay=true) so a freshly opened tab shows full history, then
97
+ * receives every subsequent live frame (replay=false). Returns an unsubscribe
98
+ * function; call it when the stream closes (this does NOT stop the capture).
99
+ */
100
+ subscribe(listener) {
101
+ // Replay a snapshot of the current ring. Snapshot first so a frame arriving
102
+ // mid-replay is not delivered twice (once via replay, once live).
103
+ const snapshot = this.ring.slice();
104
+ for (const frame of snapshot)
105
+ listener(frame, true);
106
+ this.listeners.add(listener);
107
+ return () => {
108
+ this.listeners.delete(listener);
109
+ };
110
+ }
111
+ onChildGone(child) {
112
+ // Guard against a late event from an already-replaced child.
113
+ if (this.child === child) {
114
+ this.child = null;
115
+ this.lineBuf = "";
116
+ }
117
+ }
118
+ onData(chunk) {
119
+ this.lineBuf += chunk.toString();
120
+ let nl;
121
+ while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
122
+ const line = this.lineBuf.slice(0, nl);
123
+ this.lineBuf = this.lineBuf.slice(nl + 1);
124
+ const frame = this.parseLine(line);
125
+ if (frame)
126
+ this.push(frame);
127
+ }
128
+ }
129
+ push(frame) {
130
+ this.ring.push(frame);
131
+ if (this.ring.length > this.cap)
132
+ this.ring.shift();
133
+ for (const listener of this.listeners) {
134
+ // A throwing listener (e.g. a closed response) must not kill the capture
135
+ // or starve the other listeners.
136
+ try {
137
+ listener(frame, false);
138
+ }
139
+ catch {
140
+ // best-effort fan-out
141
+ }
142
+ }
143
+ }
144
+ }