@slack/radar-mcp 1.5.0 → 1.7.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.
Files changed (46) hide show
  1. package/README.md +5 -4
  2. package/dist/mcp/api-paths.d.ts +1 -0
  3. package/dist/mcp/api-paths.js +108 -0
  4. package/dist/mcp/db-tools.d.ts +46 -0
  5. package/dist/mcp/db-tools.js +160 -0
  6. package/dist/mcp/index.js +84 -80
  7. package/dist/mcp/logs-result.d.ts +9 -0
  8. package/dist/mcp/logs-result.js +15 -0
  9. package/dist/mcp/tools.js +70 -7
  10. package/dist/shared/android.d.ts +2 -2
  11. package/dist/shared/android.js +30 -13
  12. package/dist/shared/constants.d.ts +15 -0
  13. package/dist/shared/constants.js +24 -0
  14. package/dist/shared/db.d.ts +31 -14
  15. package/dist/shared/db.js +44 -32
  16. package/dist/shared/debug-log.d.ts +50 -0
  17. package/dist/shared/debug-log.js +108 -0
  18. package/dist/shared/screen.d.ts +35 -2
  19. package/dist/shared/screen.js +75 -6
  20. package/dist/shared/stream.d.ts +1 -1
  21. package/dist/shared/transport.d.ts +22 -4
  22. package/dist/web/bin.d.ts +16 -1
  23. package/dist/web/bin.js +133 -19
  24. package/dist/web/public/index.html +244 -1069
  25. package/dist/web/public/next/app.js +210 -0
  26. package/dist/web/public/next/db.js +286 -0
  27. package/dist/web/public/next/esc.js +13 -0
  28. package/dist/web/public/next/feed.js +608 -0
  29. package/dist/web/public/next/filters.js +120 -0
  30. package/dist/web/public/next/hooks.js +57 -0
  31. package/dist/web/public/next/html.js +10 -0
  32. package/dist/web/public/next/inspector.js +258 -0
  33. package/dist/web/public/next/registry.js +65 -0
  34. package/dist/web/public/next/screen.js +180 -0
  35. package/dist/web/public/next/spec-types.js +1 -0
  36. package/dist/web/public/next/store.js +714 -0
  37. package/dist/web/public/next/widgets.js +194 -0
  38. package/dist/web/public/vendor/README.md +25 -0
  39. package/dist/web/public/vendor/hooks.module.js +2 -0
  40. package/dist/web/public/vendor/htm.LICENSE +202 -0
  41. package/dist/web/public/vendor/htm.module.js +1 -0
  42. package/dist/web/public/vendor/preact.LICENSE +21 -0
  43. package/dist/web/public/vendor/preact.module.js +2 -0
  44. package/dist/web/server.d.ts +17 -0
  45. package/dist/web/server.js +194 -16
  46. package/package.json +6 -2
@@ -49,9 +49,32 @@ export declare function cleanupRecordings(): void;
49
49
  export declare function resolveFfmpeg(): string | null;
50
50
  /** Whether scrcpy is installed (the no-ffmpeg fallback hint). Cached. */
51
51
  export declare function hasScrcpy(): boolean;
52
- /** The platform-specific copy-paste command to install ffmpeg. Never run automatically. */
52
+ /**
53
+ * Re-probe ffmpeg/scrcpy ASYNCHRONOUSLY and update the cache, so the overlay can
54
+ * recover after a user installs ffmpeg mid-session without restarting the server.
55
+ *
56
+ * Why async (not a force flag on the sync probes): the sync `execFileSync` probes are
57
+ * safe only BECAUSE they are cached and run at most once (see the same rule in db.ts).
58
+ * Re-running them synchronously on every panel reopen would block the single event loop
59
+ * (the live MJPEG stream, /health, every tab) for the spawn duration. This path uses
60
+ * execFile (non-blocking) instead. It also ONLY re-probes a binary that is currently
61
+ * cached as absent: if ffmpeg was already found there is nothing to recover, so the
62
+ * common case does no work.
63
+ */
64
+ export declare function refreshScreenCapabilities(): Promise<void>;
65
+ /**
66
+ * A single runnable command to install ffmpeg on this platform. Never run
67
+ * automatically. This is presented as a copy-paste command, so it must be ONE
68
+ * runnable line: no parentheticals or alternatives, which would error if pasted
69
+ * into a shell. Alternative package managers belong in surrounding prose, not here.
70
+ */
53
71
  export declare function ffmpegInstallHint(): string;
54
- /** Capability summary for /health: what the screen overlay can do on this machine. */
72
+ /**
73
+ * Capability summary for /health and /screen/caps: what the screen overlay can do
74
+ * on this machine. Reads the cached probe result. To recover after a mid-session
75
+ * ffmpeg install, call refreshScreenCapabilities() first (the /screen/caps?refresh=1
76
+ * path does this), then read here.
77
+ */
55
78
  export declare function screenCapabilities(): {
56
79
  screenshot: boolean;
57
80
  liveCast: boolean;
@@ -107,6 +130,16 @@ export declare function startLive(fps: number): LiveCast | null;
107
130
  /** Attach an HTTP response as a viewer of the live cast (after the multipart headers are written). */
108
131
  export declare function addViewer(L: LiveCast, res: NodeJS.WritableStream): void;
109
132
  export declare function removeViewer(res: NodeJS.WritableStream): void;
133
+ /**
134
+ * Tear down the live cast when nothing is keeping it alive: no viewers AND no active
135
+ * recording. Either a viewer leaving or a recording stopping can be the last thing
136
+ * holding the cast, so both paths call this. The close-while-recording case needs it:
137
+ * the browser aborts the <img> (dropping the last viewer) BEFORE its record/stop POST
138
+ * resolves, so the viewer-leave finds recording still set and cannot reap; the cast
139
+ * would then run forever (screenrecord + ffmpeg) once the recording ends. Reaping again
140
+ * after stopRecording closes that gap.
141
+ */
142
+ export declare function reapLiveIfIdle(): void;
110
143
  export declare function getLive(): LiveCast | null;
111
144
  export declare function lastFrame(): Buffer | null;
112
145
  export declare function primeIfNeeded(): Promise<void>;
@@ -40,6 +40,7 @@ import { spawn, execFile, execFileSync } from "child_process";
40
40
  import fs from "fs";
41
41
  import os from "os";
42
42
  import path from "path";
43
+ import { promisify } from "util";
43
44
  import { resolveAdbPath } from "./android.js";
44
45
  const SC_TMP = path.join(os.homedir(), ".slack-radar", "screen");
45
46
  /**
@@ -76,8 +77,10 @@ function adb() {
76
77
  return resolveAdbPath() ?? "adb";
77
78
  }
78
79
  // ---- host-binary detection (for the degrade tiers) --------------------------
80
+ const execFileP = promisify(execFile);
79
81
  let ffmpegPath; // undefined = not probed, null = absent
80
82
  let scrcpyPresent;
83
+ const FFMPEG_CANDIDATES = ["ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"];
81
84
  /** Resolve an ffmpeg binary, or null if none is installed. Cached. */
82
85
  export function resolveFfmpeg() {
83
86
  // Test hook: simulate a machine with no ffmpeg so the degrade path can be exercised
@@ -86,7 +89,7 @@ export function resolveFfmpeg() {
86
89
  return null;
87
90
  if (ffmpegPath !== undefined)
88
91
  return ffmpegPath;
89
- for (const c of ["ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"]) {
92
+ for (const c of FFMPEG_CANDIDATES) {
90
93
  try {
91
94
  execFileSync(c, ["-version"], { timeout: 3000, stdio: "ignore" });
92
95
  ffmpegPath = c;
@@ -112,18 +115,72 @@ export function hasScrcpy() {
112
115
  }
113
116
  return scrcpyPresent;
114
117
  }
115
- /** The platform-specific copy-paste command to install ffmpeg. Never run automatically. */
118
+ /**
119
+ * Re-probe ffmpeg/scrcpy ASYNCHRONOUSLY and update the cache, so the overlay can
120
+ * recover after a user installs ffmpeg mid-session without restarting the server.
121
+ *
122
+ * Why async (not a force flag on the sync probes): the sync `execFileSync` probes are
123
+ * safe only BECAUSE they are cached and run at most once (see the same rule in db.ts).
124
+ * Re-running them synchronously on every panel reopen would block the single event loop
125
+ * (the live MJPEG stream, /health, every tab) for the spawn duration. This path uses
126
+ * execFile (non-blocking) instead. It also ONLY re-probes a binary that is currently
127
+ * cached as absent: if ffmpeg was already found there is nothing to recover, so the
128
+ * common case does no work.
129
+ */
130
+ export async function refreshScreenCapabilities() {
131
+ if (process.env.SLACK_RADAR_NO_FFMPEG === "1") {
132
+ ffmpegPath = null;
133
+ }
134
+ else if (ffmpegPath === undefined || ffmpegPath === null) {
135
+ // Not yet probed, or last probe found nothing: (re-)check now. Probe into a local
136
+ // and assign the cache only once, at the end, so a concurrent reader (a second tab,
137
+ // /health) keeps seeing the prior value during the probe instead of a transient
138
+ // null. Recovery only moves absent -> present, never the reverse.
139
+ let found = null;
140
+ for (const c of FFMPEG_CANDIDATES) {
141
+ try {
142
+ await execFileP(c, ["-version"], { timeout: 3000 });
143
+ found = c;
144
+ break;
145
+ }
146
+ catch {
147
+ // try next candidate
148
+ }
149
+ }
150
+ ffmpegPath = found;
151
+ }
152
+ if (scrcpyPresent === undefined || scrcpyPresent === false) {
153
+ try {
154
+ await execFileP("scrcpy", ["--version"], { timeout: 3000 });
155
+ scrcpyPresent = true;
156
+ }
157
+ catch {
158
+ scrcpyPresent = false;
159
+ }
160
+ }
161
+ }
162
+ /**
163
+ * A single runnable command to install ffmpeg on this platform. Never run
164
+ * automatically. This is presented as a copy-paste command, so it must be ONE
165
+ * runnable line: no parentheticals or alternatives, which would error if pasted
166
+ * into a shell. Alternative package managers belong in surrounding prose, not here.
167
+ */
116
168
  export function ffmpegInstallHint() {
117
169
  switch (process.platform) {
118
170
  case "darwin":
119
171
  return "brew install ffmpeg";
120
172
  case "win32":
121
- return "winget install ffmpeg (or: choco install ffmpeg)";
173
+ return "winget install ffmpeg";
122
174
  default:
123
- return "sudo apt install ffmpeg (or your distro's package manager)";
175
+ return "sudo apt install ffmpeg";
124
176
  }
125
177
  }
126
- /** Capability summary for /health: what the screen overlay can do on this machine. */
178
+ /**
179
+ * Capability summary for /health and /screen/caps: what the screen overlay can do
180
+ * on this machine. Reads the cached probe result. To recover after a mid-session
181
+ * ffmpeg install, call refreshScreenCapabilities() first (the /screen/caps?refresh=1
182
+ * path does this), then read here.
183
+ */
127
184
  export function screenCapabilities() {
128
185
  const ffmpeg = resolveFfmpeg() !== null;
129
186
  return {
@@ -404,7 +461,19 @@ export function removeViewer(res) {
404
461
  if (!scLive)
405
462
  return;
406
463
  scLive.viewers.delete(res);
407
- if (!scLive.viewers.size && !scLive.recording) {
464
+ reapLiveIfIdle();
465
+ }
466
+ /**
467
+ * Tear down the live cast when nothing is keeping it alive: no viewers AND no active
468
+ * recording. Either a viewer leaving or a recording stopping can be the last thing
469
+ * holding the cast, so both paths call this. The close-while-recording case needs it:
470
+ * the browser aborts the <img> (dropping the last viewer) BEFORE its record/stop POST
471
+ * resolves, so the viewer-leave finds recording still set and cannot reap; the cast
472
+ * would then run forever (screenrecord + ffmpeg) once the recording ends. Reaping again
473
+ * after stopRecording closes that gap.
474
+ */
475
+ export function reapLiveIfIdle() {
476
+ if (scLive && !scLive.viewers.size && !scLive.recording) {
408
477
  cleanupLive(scLive);
409
478
  scLive = null;
410
479
  }
@@ -6,7 +6,7 @@ export interface RadarEvent {
6
6
  [key: string]: unknown;
7
7
  }
8
8
  export interface ParsedSSE {
9
- type: "network" | "rtm";
9
+ type: "network" | "rtm" | "clog" | "log" | "trace";
10
10
  event: RadarEvent;
11
11
  }
12
12
  export declare const localNetworkBuffer: RadarEvent[];
@@ -6,6 +6,13 @@
6
6
  * device-specific operations. The MCP server and tool handlers
7
7
  * interact only through this interface.
8
8
  */
9
+ /** Which underlying source served a getRecentLogs result. */
10
+ export type LogSource = "app_buffer" | "logcat_capture" | "logcat_ring";
11
+ /** Lines plus the source that produced them. */
12
+ export interface LogResult {
13
+ lines: string[];
14
+ source: LogSource;
15
+ }
9
16
  export interface DeviceTransport {
10
17
  /** Human-readable platform name (e.g. "android", "ios"). */
11
18
  readonly platform: string;
@@ -47,14 +54,15 @@ export interface DeviceTransport {
47
54
  */
48
55
  getAppState(): string;
49
56
  /**
50
- * Get recent log entries filtered by tag.
57
+ * Get recent log entries, optionally filtered by tag.
51
58
  *
52
- * @param tag Log tag to filter by
59
+ * @param tag Log tag to filter by. Empty string returns every line (no tag filter).
53
60
  * @param limit Max number of lines
54
61
  * @param grep Optional text to further filter
55
- * @returns Array of matching log lines
62
+ * @returns The matching lines plus which source served them, so the caller can tell a
63
+ * true-empty result from a silent fallback to an inferior source.
56
64
  */
57
- getRecentLogs(tag: string, limit: number, grep?: string): string[];
65
+ getRecentLogs(tag: string, limit: number, grep?: string): LogResult;
58
66
  /**
59
67
  * Returns the currently active user ID, or null if none is set. On Android
60
68
  * this is the profile (e.g. "0" for personal, "10" for work); on iOS it
@@ -64,6 +72,16 @@ export interface DeviceTransport {
64
72
  * unchecked casts when asking "which profile did this capture?".
65
73
  */
66
74
  getActiveUserId(): string | null;
75
+ /**
76
+ * Resolve the user the debug build is actually running as, never null. Unlike
77
+ * getActiveUserId() (which returns null when nothing has been detected), this always
78
+ * resolves to a concrete profile, preferring the non-zero/secondary profile the work
79
+ * build runs under rather than blindly defaulting to user 0. Callers that need a definite
80
+ * target (the on-device DB browser) use this so list/pull/query never silently disagree
81
+ * on which profile's store they touch. On a single-user platform (iOS) this returns the
82
+ * sole user or an empty string.
83
+ */
84
+ resolveActiveUser(): string;
67
85
  /**
68
86
  * Returns the last error message from an `ensureForward()` failure, or null
69
87
  * if the last attempt succeeded or has not happened. Lets callers surface
package/dist/web/bin.d.ts CHANGED
@@ -1,2 +1,17 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ /**
3
+ * Decide whether a /spec response body identifies a Radar dashboard. A Radar /spec
4
+ * is always a plain object carrying a `viz` array (even a blank spec is
5
+ * { title, blank, viz: [] }). Requiring both excludes a foreign service that happens
6
+ * to 200 with arbitrary JSON (a bare array, a string, an unrelated object). Exported
7
+ * for unit testing. statusCode must be 200.
8
+ */
9
+ export declare function isRadarSpecBody(statusCode: number | undefined, body: string): boolean;
10
+ /**
11
+ * Start the web dashboard server, open the browser, and wire shutdown signals.
12
+ * Extracted so both the dedicated `slack-radar-web` bin and the default bin's
13
+ * `web` subcommand dispatcher launch the dashboard the same way. Exported so the
14
+ * dispatcher (mcp/index.ts) calls it explicitly rather than relying on an import
15
+ * side-effect, which keeps importing this module (e.g. in a test) side-effect-free.
16
+ */
17
+ export declare function startWebServer(): void;
package/dist/web/bin.js CHANGED
@@ -1,12 +1,113 @@
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() {
10
111
  // Safety net: a single route handler must never take the whole dashboard down. An async
11
112
  // callback (e.g. a device-probe that both ends and errors) can otherwise throw
12
113
  // ERR_HTTP_HEADERS_SENT outside any try/catch and kill the process. Log and keep serving;
@@ -20,10 +121,10 @@ function startWebServer() {
20
121
  const server = createServer();
21
122
  server.on("error", (err) => {
22
123
  if (err.code === "EADDRINUSE") {
23
- console.error(`Port ${WEB_PORT} is already in use. Another instance may be running.\n` +
24
- `Kill it with: lsof -ti :${WEB_PORT} | xargs kill\n` +
25
- `Or set a different port: SLACK_RADAR_WEB_PORT=8101 slack-radar web`);
26
- 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;
27
128
  }
28
129
  throw err;
29
130
  });
@@ -37,6 +138,12 @@ function startWebServer() {
37
138
  // loopback-scoped (constants.ts); the dashboard's own listener must match. Remote
38
139
  // access, if ever needed, belongs in a separate token-gated feature.
39
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();
40
147
  const url = `http://localhost:${WEB_PORT}`;
41
148
  console.log(`Slack Radar Web running at ${url}`);
42
149
  // Auto-open the browser for the HUMAN launcher (`slack-radar web` / `slack-radar-web`),
@@ -46,17 +153,7 @@ function startWebServer() {
46
153
  // /radar-ui. The tool returns the URL instead, for the Claude Code session to surface.
47
154
  if (process.env.SLACK_RADAR_NO_OPEN)
48
155
  return;
49
- try {
50
- if (process.platform === "darwin") {
51
- execSync(`open ${url}`, { stdio: "ignore" });
52
- }
53
- else if (process.platform === "linux") {
54
- execSync(`xdg-open ${url}`, { stdio: "ignore" });
55
- }
56
- }
57
- catch {
58
- // Browser open is best-effort
59
- }
156
+ openBrowser(url);
60
157
  });
61
158
  const shutdown = () => {
62
159
  console.log("\nShutting down…");
@@ -66,4 +163,21 @@ function startWebServer() {
66
163
  process.on("SIGINT", shutdown);
67
164
  process.on("SIGTERM", shutdown);
68
165
  }
69
- 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
+ }