@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.
- package/README.md +5 -4
- package/dist/mcp/api-paths.d.ts +1 -0
- package/dist/mcp/api-paths.js +108 -0
- package/dist/mcp/db-tools.d.ts +46 -0
- package/dist/mcp/db-tools.js +160 -0
- package/dist/mcp/index.js +84 -80
- package/dist/mcp/logs-result.d.ts +9 -0
- package/dist/mcp/logs-result.js +15 -0
- package/dist/mcp/tools.js +70 -7
- package/dist/shared/android.d.ts +2 -2
- package/dist/shared/android.js +30 -13
- package/dist/shared/constants.d.ts +15 -0
- package/dist/shared/constants.js +24 -0
- package/dist/shared/db.d.ts +31 -14
- package/dist/shared/db.js +44 -32
- package/dist/shared/debug-log.d.ts +50 -0
- package/dist/shared/debug-log.js +108 -0
- package/dist/shared/screen.d.ts +35 -2
- package/dist/shared/screen.js +75 -6
- package/dist/shared/stream.d.ts +1 -1
- package/dist/shared/transport.d.ts +22 -4
- package/dist/web/bin.d.ts +16 -1
- package/dist/web/bin.js +133 -19
- package/dist/web/public/index.html +244 -1069
- package/dist/web/public/next/app.js +210 -0
- package/dist/web/public/next/db.js +286 -0
- package/dist/web/public/next/esc.js +13 -0
- package/dist/web/public/next/feed.js +608 -0
- package/dist/web/public/next/filters.js +120 -0
- package/dist/web/public/next/hooks.js +57 -0
- package/dist/web/public/next/html.js +10 -0
- package/dist/web/public/next/inspector.js +258 -0
- package/dist/web/public/next/registry.js +65 -0
- package/dist/web/public/next/screen.js +180 -0
- package/dist/web/public/next/spec-types.js +1 -0
- package/dist/web/public/next/store.js +714 -0
- package/dist/web/public/next/widgets.js +194 -0
- package/dist/web/public/vendor/README.md +25 -0
- package/dist/web/public/vendor/hooks.module.js +2 -0
- package/dist/web/public/vendor/htm.LICENSE +202 -0
- package/dist/web/public/vendor/htm.module.js +1 -0
- package/dist/web/public/vendor/preact.LICENSE +21 -0
- package/dist/web/public/vendor/preact.module.js +2 -0
- package/dist/web/server.d.ts +17 -0
- package/dist/web/server.js +194 -16
- package/package.json +6 -2
package/dist/shared/screen.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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>;
|
package/dist/shared/screen.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
173
|
+
return "winget install ffmpeg";
|
|
122
174
|
default:
|
|
123
|
-
return "sudo apt install ffmpeg
|
|
175
|
+
return "sudo apt install ffmpeg";
|
|
124
176
|
}
|
|
125
177
|
}
|
|
126
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/dist/shared/stream.d.ts
CHANGED
|
@@ -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
|
|
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):
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|