@fudrouter/fsrouter 0.6.37 → 0.6.39

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/bin/cli.cjs ADDED
@@ -0,0 +1,764 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn, exec, execSync } = require("child_process");
4
+ const path = require("path");
5
+ const fs = require("fs");
6
+ const https = require("https");
7
+ const os = require("os");
8
+
9
+ // Native spinner - no external dependency
10
+ function createSpinner(text) {
11
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
+ let i = 0;
13
+ let interval = null;
14
+ let currentText = text;
15
+ return {
16
+ start() {
17
+ if (process.stdout.isTTY) {
18
+ process.stdout.write(`\r${frames[0]} ${currentText}`);
19
+ interval = setInterval(() => {
20
+ process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
21
+ }, 80);
22
+ }
23
+ return this;
24
+ },
25
+ stop() {
26
+ if (interval) {
27
+ clearInterval(interval);
28
+ interval = null;
29
+ }
30
+ if (process.stdout.isTTY) {
31
+ process.stdout.write("\r\x1b[K");
32
+ }
33
+ },
34
+ succeed(msg) {
35
+ this.stop();
36
+ console.log(`✅ ${msg}`);
37
+ },
38
+ fail(msg) {
39
+ this.stop();
40
+ console.log(`❌ ${msg}`);
41
+ }
42
+ };
43
+ }
44
+
45
+ const pkg = require("../package.json");
46
+ const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime.cjs");
47
+ const { ensureTrayRuntime } = require("./hooks/trayRuntime.cjs");
48
+ const args = process.argv.slice(2);
49
+
50
+ // Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
51
+ // so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
52
+ // better-sqlite3 is optional. Logs to stderr only on failure.
53
+ try { ensureSqliteRuntime({ silent: true }); } catch {}
54
+
55
+ // Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
56
+ try { ensureTrayRuntime({ silent: true }); } catch {}
57
+
58
+ // Configuration constants
59
+ const APP_NAME = pkg.name; // Use from package.json
60
+ const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
61
+
62
+ const DEFAULT_PORT = 20128;
63
+ const DEFAULT_HOST = "0.0.0.0";
64
+ const MAX_PORT_ATTEMPTS = 10;
65
+ // Identifiers for killAllAppProcesses - only kill 9router specifically
66
+ const PROCESS_IDENTIFIERS = [
67
+ '9router' // Only package name - avoid killing other apps
68
+ ];
69
+
70
+ // Parse arguments
71
+ let port = DEFAULT_PORT;
72
+ let host = DEFAULT_HOST;
73
+ let noBrowser = false;
74
+ let skipUpdate = false;
75
+ let showLog = false;
76
+ let trayMode = false;
77
+
78
+ for (let i = 0; i < args.length; i++) {
79
+ if (args[i] === "--port" || args[i] === "-p") {
80
+ port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
81
+ i++;
82
+ } else if (args[i] === "--host" || args[i] === "-H") {
83
+ host = args[i + 1] || DEFAULT_HOST;
84
+ i++;
85
+ } else if (args[i] === "--no-browser" || args[i] === "-n") {
86
+ noBrowser = true;
87
+ } else if (args[i] === "--log" || args[i] === "-l") {
88
+ showLog = true;
89
+ } else if (args[i] === "--skip-update") {
90
+ skipUpdate = true;
91
+ } else if (args[i] === "--tray" || args[i] === "-t") {
92
+ trayMode = true;
93
+ process.env.TRAY_MODE = "1";
94
+ } else if (args[i] === "--help" || args[i] === "-h") {
95
+ console.log(`
96
+ Usage: ${APP_NAME} [options]
97
+
98
+ Options:
99
+ -p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
100
+ -H, --host <host> Host to bind (default: ${DEFAULT_HOST})
101
+ -n, --no-browser Don't open browser automatically
102
+ -l, --log Show server logs (default: hidden)
103
+ -t, --tray Run in system tray mode (background)
104
+ --skip-update Skip auto-update check
105
+ -h, --help Show this help message
106
+ -v, --version Show version
107
+ `);
108
+ process.exit(0);
109
+ } else if (args[i] === "--version" || args[i] === "-v") {
110
+ console.log(pkg.version);
111
+ process.exit(0);
112
+ }
113
+ }
114
+
115
+ // Auto-relaunch after update: detached process has no TTY → fallback to tray (only if desktop display is available)
116
+ const hasDisplay = process.platform === "win32" || process.platform === "darwin" || process.env.DISPLAY;
117
+ if (skipUpdate && !trayMode && !process.stdin.isTTY && hasDisplay) {
118
+ trayMode = true;
119
+ process.env.TRAY_MODE = "1";
120
+ }
121
+
122
+ // Always use Node.js runtime with absolute path
123
+ const RUNTIME = process.execPath;
124
+
125
+ // Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
126
+ function compareVersions(a, b) {
127
+ const partsA = a.split(".").map(Number);
128
+ const partsB = b.split(".").map(Number);
129
+ for (let i = 0; i < 3; i++) {
130
+ if (partsA[i] > partsB[i]) return 1;
131
+ if (partsA[i] < partsB[i]) return -1;
132
+ }
133
+ return 0;
134
+ }
135
+
136
+ // Get app data dir (matches app/src/lib/dataDir.js convention)
137
+ function getAppDataDir() {
138
+ return process.platform === "win32"
139
+ ? path.join(process.env.APPDATA || "", "9router")
140
+ : path.join(os.homedir(), ".9router");
141
+ }
142
+
143
+ // Kill PID from file (best-effort, removes file after)
144
+ function killByPidFile(pidFile) {
145
+ try {
146
+ if (!fs.existsSync(pidFile)) return;
147
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
148
+ if (!pid) return;
149
+ try {
150
+ if (process.platform === "win32") {
151
+ execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
152
+ } else {
153
+ process.kill(pid, "SIGKILL");
154
+ }
155
+ } catch { }
156
+ try { fs.unlinkSync(pidFile); } catch { }
157
+ } catch { }
158
+ }
159
+
160
+ // Kill tunnel processes (cloudflared/tailscale) by their PID files
161
+ function killTunnelByPidFile() {
162
+ const tunnelDir = path.join(getAppDataDir(), "tunnel");
163
+ killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
164
+ killByPidFile(path.join(tunnelDir, "tailscale.pid"));
165
+ }
166
+
167
+ // Kill cloudflared whose --url targets this app's port (covers stale PID file case)
168
+ function killCloudflaredByAppPort(appPort) {
169
+ if (!appPort) return [];
170
+ const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
171
+ const pids = [];
172
+ try {
173
+ if (process.platform === "win32") {
174
+ const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
175
+ const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
176
+ const lines = output.split("\n").slice(1).filter(l => l.trim());
177
+ lines.forEach(line => {
178
+ if (portMatchers.some(m => line.includes(m))) {
179
+ const match = line.match(/^"(\d+)"/);
180
+ if (match && match[1]) pids.push(match[1]);
181
+ }
182
+ });
183
+ } else {
184
+ const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
185
+ output.split("\n").forEach(line => {
186
+ if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
187
+ const parts = line.trim().split(/\s+/);
188
+ const pid = parts[0];
189
+ if (pid && !isNaN(pid)) pids.push(pid);
190
+ }
191
+ });
192
+ }
193
+ } catch { }
194
+ return pids;
195
+ }
196
+
197
+ // Kill all 9router processes
198
+ function killAllAppProcesses(appPort) {
199
+ return new Promise((resolve) => {
200
+ try {
201
+ // Kill MIT first (privileged process, needs special handling)
202
+ killProxyByPidFile();
203
+ // Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
204
+ killTunnelByPidFile();
205
+
206
+ const platform = process.platform;
207
+ let pids = [];
208
+
209
+ // Catch stale PID files: kill cloudflared bound to this app's port
210
+ pids.push(...killCloudflaredByAppPort(appPort));
211
+
212
+ if (platform === "win32") {
213
+ // Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
214
+ try {
215
+ const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
216
+ const output = execSync(psCmd, {
217
+ encoding: "utf8",
218
+ windowsHide: true,
219
+ timeout: 5000
220
+ });
221
+ const lines = output.split("\n").slice(1).filter(l => l.trim());
222
+ lines.forEach(line => {
223
+ // Whitelist: real node process running 9router/cli.js, or next-server.
224
+ // Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline.
225
+ const cmd = line.toLowerCase();
226
+ const isAppProcess =
227
+ (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router")))
228
+ || cmd.includes("next-server");
229
+ if (isAppProcess) {
230
+ const match = line.match(/^"(\d+)"/);
231
+ if (match && match[1] && match[1] !== process.pid.toString()) {
232
+ pids.push(match[1]);
233
+ }
234
+ }
235
+ });
236
+ } catch (e) {
237
+ // No processes found or error - continue
238
+ }
239
+ } else {
240
+ // macOS/Linux: use ps to find all matching processes
241
+ try {
242
+ const output = execSync('ps aux 2>/dev/null', {
243
+ encoding: 'utf8',
244
+ timeout: 5000
245
+ });
246
+ const lines = output.split('\n');
247
+
248
+ lines.forEach(line => {
249
+ // Whitelist: real node process running 9router/cli.js, or next-server.
250
+ // Avoids killing grep/strace/editors/cursor that incidentally match "9router".
251
+ const cmd = line.toLowerCase();
252
+ const isAppProcess =
253
+ (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router")))
254
+ || cmd.includes("next-server");
255
+ if (isAppProcess) {
256
+ const parts = line.trim().split(/\s+/);
257
+ const pid = parts[1];
258
+ if (pid && !isNaN(pid) && pid !== process.pid.toString()) {
259
+ pids.push(pid);
260
+ }
261
+ }
262
+ });
263
+ } catch (e) {
264
+ // No processes found or error - continue
265
+ }
266
+ }
267
+
268
+ // Kill all found processes
269
+ if (pids.length > 0) {
270
+ pids.forEach(pid => {
271
+ try {
272
+ if (platform === "win32") {
273
+ execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
274
+ } else {
275
+ execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
276
+ }
277
+ } catch (err) {
278
+ // Process already dead or can't kill - continue
279
+ }
280
+ });
281
+
282
+ // Wait for processes to fully terminate
283
+ setTimeout(() => resolve(), 1000);
284
+ } else {
285
+ resolve();
286
+ }
287
+ } catch (err) {
288
+ // Silent fail - continue anyway
289
+ resolve();
290
+ }
291
+ });
292
+ }
293
+
294
+ // Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
295
+ function sleepSync(ms) {
296
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
297
+ }
298
+
299
+ // Wait until process dies or timeout reached
300
+ function waitForExit(pid, timeoutMs) {
301
+ const deadline = Date.now() + timeoutMs;
302
+ while (Date.now() < deadline) {
303
+ try { process.kill(pid, 0); } catch { return true; }
304
+ sleepSync(100);
305
+ }
306
+ return false;
307
+ }
308
+
309
+ // Kill MIT server by PID file (runs privileged, needs special handling)
310
+ // Sends SIGTERM first so MIT can clean up host entries before dying.
311
+ function killProxyByPidFile() {
312
+ try {
313
+ const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
314
+ if (!fs.existsSync(pidFile)) return;
315
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
316
+ if (!pid) return;
317
+
318
+ if (process.platform === "win32") {
319
+ // Graceful first (lets server cleanup hosts), then force
320
+ try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { }
321
+ if (!waitForExit(pid, 1500)) {
322
+ try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
323
+ }
324
+ // Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes)
325
+ if (!waitForExit(pid, 500)) {
326
+ try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
327
+ }
328
+ } else {
329
+ // SIGTERM via cached sudo token first
330
+ try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
331
+ catch { try { process.kill(pid, "SIGTERM"); } catch { } }
332
+ if (!waitForExit(pid, 1500)) {
333
+ try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
334
+ catch { try { process.kill(pid, "SIGKILL"); } catch { } }
335
+ }
336
+ }
337
+ try { fs.unlinkSync(pidFile); } catch { }
338
+ } catch { }
339
+ }
340
+
341
+ // Kill any process on specific port
342
+ function killProcessOnPort(port) {
343
+ return new Promise((resolve) => {
344
+ try {
345
+ const platform = process.platform;
346
+ let pid;
347
+
348
+ if (platform === "win32") {
349
+ try {
350
+ const output = execSync(`netstat -ano | findstr :${port}`, {
351
+ encoding: 'utf8',
352
+ shell: true,
353
+ windowsHide: true,
354
+ timeout: 5000
355
+ }).trim();
356
+ const lines = output.split('\n').filter(l => l.includes('LISTENING'));
357
+ if (lines.length > 0) {
358
+ pid = lines[0].trim().split(/\s+/).pop();
359
+ execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
360
+ }
361
+ } catch (e) {
362
+ // Port is free or error
363
+ }
364
+ } else {
365
+ // macOS/Linux
366
+ try {
367
+ const pidOutput = execSync(`lsof -ti:${port}`, {
368
+ encoding: 'utf8',
369
+ stdio: ['pipe', 'pipe', 'ignore']
370
+ }).trim();
371
+ if (pidOutput) {
372
+ pid = pidOutput.split('\n')[0];
373
+ execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
374
+ }
375
+ } catch (e) {
376
+ // Port is free or error
377
+ }
378
+ }
379
+
380
+ // Wait for port to be released
381
+ setTimeout(() => resolve(), 500);
382
+ } catch (err) {
383
+ // Silent fail - continue anyway
384
+ resolve();
385
+ }
386
+ });
387
+ }
388
+
389
+
390
+ // Detect if running in restricted environment (Codespaces, Docker)
391
+ function isRestrictedEnvironment() {
392
+ // Check for Codespaces
393
+ if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
394
+ return "GitHub Codespaces";
395
+ }
396
+
397
+ // Check for Docker
398
+ if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) {
399
+ return "Docker";
400
+ }
401
+
402
+ return null;
403
+ }
404
+
405
+ function checkForUpdate() {
406
+ return new Promise((resolve) => {
407
+ resolve(null);
408
+ });
409
+ }
410
+
411
+ // Open browser
412
+ function openBrowser(url) {
413
+ const platform = process.platform;
414
+ let cmd;
415
+
416
+ if (platform === "darwin") {
417
+ cmd = `open "${url}"`;
418
+ } else if (platform === "win32") {
419
+ cmd = `start "" "${url}"`;
420
+ } else {
421
+ cmd = `xdg-open "${url}"`;
422
+ }
423
+
424
+ exec(cmd, { windowsHide: true }, (err) => {
425
+ if (err) {
426
+ console.log(`Open browser manually: ${url}`);
427
+ }
428
+ });
429
+ }
430
+
431
+ // Find standalone server (compiled in dist/server.js for published package)
432
+ const standaloneDir = path.join(__dirname, "..");
433
+ const serverPath = path.join(standaloneDir, "dist/server.js");
434
+
435
+ if (!fs.existsSync(serverPath)) {
436
+ console.error("Error: Standalone build not found.");
437
+ console.error("Please run 'npm run build:cli' first.");
438
+ process.exit(1);
439
+ }
440
+
441
+ // Check for updates FIRST, then start server
442
+ checkForUpdate().then((latestVersion) => {
443
+ killAllAppProcesses(port).then(() => {
444
+ return killProcessOnPort(port);
445
+ }).then(() => {
446
+ startServer(latestVersion);
447
+ });
448
+ });
449
+
450
+ // Show interface selection menu
451
+ async function showInterfaceMenu(latestVersion) {
452
+ const { selectMenu } = require("../dist/cli/utils/input");
453
+ const { clearScreen } = require("../dist/cli/utils/display");
454
+ const { getEndpoint } = require("../dist/cli/utils/endpoint");
455
+
456
+ clearScreen();
457
+
458
+ const displayHost = host === DEFAULT_HOST ? "localhost" : host;
459
+
460
+ // Detect tunnel/local mode for server URL display
461
+ let serverUrl;
462
+ try {
463
+ const { endpoint, tunnelEnabled } = await getEndpoint(port);
464
+ serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
465
+ } catch (e) {
466
+ serverUrl = `http://${displayHost}:${port}`;
467
+ }
468
+
469
+ const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
470
+
471
+ const menuItems = [];
472
+
473
+ if (latestVersion) {
474
+ menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
475
+ }
476
+
477
+ menuItems.push(
478
+ { label: "Web UI (Open in Browser)", icon: "🌐" },
479
+ { label: "Terminal UI (Interactive CLI)", icon: "💻" },
480
+ { label: "Hide to Tray (Background)", icon: "🔔" },
481
+ { label: "Exit", icon: "🚪" }
482
+ );
483
+
484
+ const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
485
+
486
+ const offset = latestVersion ? 1 : 0;
487
+
488
+ if (latestVersion && selected === 0) return "update";
489
+ if (selected === offset) return "web";
490
+ if (selected === offset + 1) return "terminal";
491
+ if (selected === offset + 2) return "hide";
492
+ return "exit";
493
+ }
494
+
495
+ const MAX_RESTARTS = 2;
496
+ const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
497
+
498
+ function startServer(latestVersion) {
499
+ const displayHost = host === DEFAULT_HOST ? "localhost" : host;
500
+ const url = `http://${displayHost}:${port}/dashboard`;
501
+
502
+ let restartCount = 0;
503
+ let serverStartTime = Date.now();
504
+
505
+ const CRASH_LOG_LINES = 50;
506
+ let crashLog = [];
507
+
508
+ function spawnServer() {
509
+ serverStartTime = Date.now();
510
+ crashLog = [];
511
+ const child = spawn(RUNTIME, ["--import", "tsx", "--max-old-space-size=6144", serverPath], {
512
+ cwd: standaloneDir,
513
+ stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
514
+ detached: true,
515
+ windowsHide: true,
516
+ env: {
517
+ ...buildEnvWithRuntime(process.env),
518
+ PORT: port.toString(),
519
+ HOSTNAME: host
520
+ }
521
+ });
522
+ if (!showLog && child.stderr) {
523
+ child.stderr.on("data", (data) => {
524
+ const lines = data.toString().split("\n").filter(Boolean);
525
+ crashLog.push(...lines);
526
+ if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
527
+ });
528
+ }
529
+ return child;
530
+ }
531
+
532
+ let server = spawnServer();
533
+
534
+ // Cleanup function - force kill server process
535
+ let isCleaningUp = false;
536
+ function cleanup() {
537
+ if (isCleaningUp) return;
538
+ isCleaningUp = true;
539
+ try {
540
+ // Kill tray if running
541
+ try {
542
+ const { killTray } = require("../dist/cli/tray/tray");
543
+ killTray();
544
+ } catch (e) { }
545
+ // Kill MIT server (privileged process) via PID file
546
+ killProxyByPidFile();
547
+ // Kill cloudflared/tailscale via PID file (only this app's tunnel)
548
+ killTunnelByPidFile();
549
+ // Kill server process directly
550
+ if (server.pid) {
551
+ process.kill(server.pid, "SIGKILL");
552
+ }
553
+ // Also try to kill process group
554
+ process.kill(-server.pid, "SIGKILL");
555
+ } catch (e) { }
556
+ }
557
+
558
+ // Suppress all errors during shutdown (systray lib throws JSON parse errors)
559
+ let isShuttingDown = false;
560
+ process.on("uncaughtException", (err) => {
561
+ if (isShuttingDown) return;
562
+ console.error("Error:", err.message);
563
+ });
564
+
565
+ // Handle all exit scenarios
566
+ process.on("SIGINT", () => {
567
+ if (isShuttingDown) return;
568
+ isShuttingDown = true;
569
+ console.log("\nExiting...");
570
+ cleanup();
571
+ setTimeout(() => process.exit(0), 100);
572
+ });
573
+ process.on("SIGTERM", () => {
574
+ if (isShuttingDown) return;
575
+ isShuttingDown = true;
576
+ cleanup();
577
+ setTimeout(() => process.exit(0), 100);
578
+ });
579
+ process.on("SIGHUP", () => {
580
+ if (isShuttingDown) return;
581
+ isShuttingDown = true;
582
+ cleanup();
583
+ setTimeout(() => process.exit(0), 100);
584
+ });
585
+
586
+ // Initialize tray icon (runs alongside TUI)
587
+ const initTrayIcon = () => {
588
+ try {
589
+ const { initTray } = require("../dist/cli/tray/tray");
590
+ initTray({
591
+ port,
592
+ onQuit: () => {
593
+ isShuttingDown = true;
594
+ console.log("\n👋 Shutting down from tray...");
595
+ cleanup();
596
+ setTimeout(() => process.exit(0), 100);
597
+ },
598
+ onOpenDashboard: () => openBrowser(url)
599
+ });
600
+ } catch (err) {
601
+ // Tray not available - continue without it
602
+ }
603
+ };
604
+
605
+ // Tray-only mode: no TUI, just tray icon
606
+ if (trayMode) {
607
+ // Ignore SIGHUP so macOS terminal close doesn't kill the background tray process
608
+ process.removeAllListeners("SIGHUP");
609
+ process.on("SIGHUP", () => {});
610
+
611
+ console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
612
+ console.log(`Server: http://${displayHost}:${port}`);
613
+
614
+ setTimeout(() => {
615
+ initTrayIcon();
616
+ console.log("\n💡 Router is now running in system tray. Close this terminal if you want.");
617
+ console.log(" Right-click tray icon to open dashboard or quit.\n");
618
+ }, 2000);
619
+
620
+ return;
621
+ }
622
+
623
+ // Wait for server to be ready, then show interface menu loop + tray
624
+ setTimeout(async () => {
625
+ // Start tray icon alongside TUI
626
+ initTrayIcon();
627
+
628
+ try {
629
+ while (true) {
630
+ const choice = await showInterfaceMenu(latestVersion);
631
+
632
+ if (choice === "update") {
633
+ isShuttingDown = true;
634
+ const { clearScreen } = require("../dist/cli/utils/display");
635
+ clearScreen();
636
+ console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
637
+ console.log(`Run this after exit:\n`);
638
+ console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
639
+ cleanup();
640
+ await killAllAppProcesses(port);
641
+ await killProcessOnPort(port);
642
+ setTimeout(() => process.exit(0), 200);
643
+ return;
644
+ } else if (choice === "web") {
645
+ openBrowser(url);
646
+ // Wait for user to come back
647
+ const { pause } = require("../dist/cli/utils/input");
648
+ await pause("\nPress Enter to go back to menu...");
649
+ } else if (choice === "terminal") {
650
+ // Start Terminal UI - it will return when user selects Back
651
+ const { startTerminalUI } = require("../dist/cli/terminalUI");
652
+ await startTerminalUI(port);
653
+ // Loop continues, show menu again
654
+ } else if (choice === "hide") {
655
+ const { clearScreen } = require("../dist/cli/utils/display");
656
+ clearScreen();
657
+
658
+ // Enable auto startup on OS boot
659
+ try {
660
+ const { enableAutoStart } = require("../dist/cli/tray/autostart");
661
+ enableAutoStart(__filename);
662
+ } catch (e) { }
663
+
664
+ if (process.platform === "darwin") {
665
+ // macOS: keep current process alive — spawning a detached child puts
666
+ // it outside the login session so NSStatusItem silently fails.
667
+ process.removeAllListeners("SIGHUP");
668
+ process.on("SIGHUP", () => {});
669
+
670
+ console.log(`\n⏳ Switching to tray mode... (icon already visible in menu bar)`);
671
+ console.log(`🔔 9Router is running in tray (PID: ${process.pid})`);
672
+ console.log(` Server: http://${displayHost}:${port}`);
673
+ console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`);
674
+
675
+ // Tray already init'd at startup — just keep event loop alive.
676
+ return;
677
+ }
678
+
679
+ // Windows/Linux: spawn detached bgProcess (systray works fine in child)
680
+ console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`);
681
+
682
+ const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
683
+ detached: true,
684
+ stdio: "ignore",
685
+ windowsHide: true,
686
+ env: { ...process.env }
687
+ });
688
+ bgProcess.unref();
689
+
690
+ console.log(`🔔 9Router is now running in background (PID: ${bgProcess.pid})`);
691
+ console.log(` Server: http://${displayHost}:${port}`);
692
+ console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`);
693
+
694
+ // cleanup() kills server so bgProcess can claim the port fresh
695
+ cleanup();
696
+ process.exit(0);
697
+ } else if (choice === "exit") {
698
+ isShuttingDown = true;
699
+ console.log("\nExiting...");
700
+ cleanup();
701
+ setTimeout(() => process.exit(0), 100);
702
+ }
703
+ }
704
+ } catch (err) {
705
+ console.error("Error:", err.message);
706
+ cleanup();
707
+ process.exit(1);
708
+ }
709
+ }, 3000);
710
+
711
+ function attachServerEvents() {
712
+ server.on("error", (err) => {
713
+ console.error("Failed to start server:", err.message);
714
+ if (!isShuttingDown) tryRestart();
715
+ else { cleanup(); process.exit(1); }
716
+ });
717
+
718
+ server.on("close", (code) => {
719
+ if (isShuttingDown || code === 0) {
720
+ process.exit(code || 0);
721
+ return;
722
+ }
723
+ tryRestart(code);
724
+ });
725
+ }
726
+
727
+ function tryRestart(code) {
728
+ const aliveMs = Date.now() - serverStartTime;
729
+ // Reset counter if last run was stable
730
+ if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
731
+
732
+ if (restartCount >= MAX_RESTARTS) {
733
+ console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`);
734
+ try {
735
+ const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
736
+ if (fs.existsSync(dbPath)) {
737
+ const db = JSON.parse(fs.readFileSync(dbPath, "utf-8"));
738
+ if (db.settings) db.settings.mitmEnabled = false;
739
+ fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
740
+ }
741
+ } catch { /* best effort */ }
742
+ restartCount = 0;
743
+ server = spawnServer();
744
+ attachServerEvents();
745
+ return;
746
+ }
747
+
748
+ restartCount++;
749
+ const delay = Math.min(1000 * restartCount, 10000);
750
+ console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
751
+ if (crashLog.length) {
752
+ console.error("\n--- Server crash log ---");
753
+ crashLog.forEach(l => console.error(l));
754
+ console.error("--- End crash log ---\n");
755
+ }
756
+
757
+ setTimeout(() => {
758
+ server = spawnServer();
759
+ attachServerEvents();
760
+ }, delay);
761
+ }
762
+
763
+ attachServerEvents();
764
+ }
package/bin/copyJs.js ADDED
@@ -0,0 +1,114 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const srcDir = path.join(process.cwd(), "src");
5
+ const distDir = path.join(process.cwd(), "dist");
6
+
7
+ function rewriteImports(content, filePath, isJsCopy = false) {
8
+ const dir = path.dirname(filePath);
9
+ const relToRoot = path.relative(dir, process.cwd()).replace(/\\/g, '/') || '.';
10
+ const relToDist = path.relative(dir, distDir).replace(/\\/g, '/') || '.';
11
+
12
+ let updated = content;
13
+
14
+ // 1. Fix @/ shared/lib/store/etc -> relative imports
15
+ // If we are copying JS/TS files to dist, they need to resolve relative to dist root
16
+ updated = updated.replace(/from\s+['"]@\/lib\/([^'"]+)['"]/g, (m, impPath) => {
17
+ let resolved = impPath;
18
+ if (!resolved.endsWith('.js') && !resolved.endsWith('.ts') && !resolved.endsWith('.json')) {
19
+ resolved += '.js';
20
+ }
21
+ return `from '${relToDist}/lib/${resolved}'`;
22
+ });
23
+ updated = updated.replace(/from\s+['"]@\/lib['"]/g, `from '${relToDist}/lib'`);
24
+ updated = updated.replace(/require\(['"]@\/lib\/([^'"]+)['"]\)/g, (m, impPath) => `require('${relToDist}/lib/${impPath}')`);
25
+ updated = updated.replace(/require\(['"]@\/lib['"]\)/g, `require('${relToDist}/lib')`);
26
+
27
+ updated = updated.replace(/from\s+['"]@\/shared\/([^'"]+)['"]/g, (m, impPath) => {
28
+ let resolved = impPath;
29
+ if (!resolved.endsWith('.js') && !resolved.endsWith('.ts') && !resolved.endsWith('.json')) {
30
+ resolved += '.js';
31
+ }
32
+ return `from '${relToDist}/shared/${resolved}'`;
33
+ });
34
+ updated = updated.replace(/require\(['"]@\/shared\/([^'"]+)['"]\)/g, (m, impPath) => `require('${relToDist}/shared/${impPath}')`);
35
+
36
+ updated = updated.replace(/from\s+['"]@\/store\/([^'"]+)['"]/g, (m, impPath) => `from '${relToDist}/store/${impPath}'`);
37
+ updated = updated.replace(/require\(['"]@\/store\/([^'"]+)['"]\)/g, (m, impPath) => `require('${relToDist}/store/${impPath}')`);
38
+
39
+ updated = updated.replace(/from\s+['"]@\/services\/([^'"]+)['"]/g, (m, impPath) => `from '${relToDist}/services/${impPath}'`);
40
+ updated = updated.replace(/require\(['"]@\/services\/([^'"]+)['"]\)/g, (m, impPath) => `require('${relToDist}/services/${impPath}')`);
41
+
42
+ updated = updated.replace(/from\s+['"]@\/utils\/([^'"]+)['"]/g, (m, impPath) => `from '${relToDist}/utils/${impPath}'`);
43
+ updated = updated.replace(/require\(['"]@\/utils\/([^'"]+)['"]\)/g, (m, impPath) => `require('${relToDist}/utils/${impPath}')`);
44
+
45
+ // 2. Fix open-sse bare imports to use relative imports from package root
46
+ // Rel to root points to package root. open-sse is at package root.
47
+ // Wait, if files are under dist/ (e.g. dist/lib/mcp/stdioSseBridge.js), the package root is relToRoot/.. because processed dir is inside dist.
48
+ // Wait, copyFiles writes directly to dist. So path.relative(dir, distDir) will resolve back to dist.
49
+ // Package root is parent of dist. So we can use:
50
+ const relToPkgRoot = isJsCopy ? path.relative(dir, process.cwd()).replace(/\\/g, '/') : relToRoot;
51
+
52
+ updated = updated.replace(/from\s+['"]open-sse\/([^'"]+)['"]/g, (m, impPath) => `from '${relToPkgRoot}/open-sse/${impPath}'`);
53
+ updated = updated.replace(/from\s+['"]open-sse['"]/g, `from '${relToPkgRoot}/open-sse/index.js'`);
54
+ updated = updated.replace(/import\s+['"]open-sse\/([^'"]+)['"]/g, (m, impPath) => `import '${relToPkgRoot}/open-sse/${impPath}'`);
55
+ updated = updated.replace(/import\s+['"]open-sse['"]/g, `import '${relToPkgRoot}/open-sse/index.js'`);
56
+
57
+ // 3. Fix local relative imports that mistakenly double dist
58
+ updated = updated.replace(/\.\.\/\.\.\/\.\.\/src\//g, '../../../');
59
+ updated = updated.replace(/\.\.\/\.\.\/src\//g, '../../');
60
+
61
+ // Specific fix for tokenRefresh/providers
62
+ if (filePath.includes('tokenRefresh')) {
63
+ updated = updated.replace(/\.\.\/\.\.\/\.\.\/dist\/lib\/oauth\/kiroExternalIdp.js/g, '../../../dist/lib/oauth/kiroExternalIdp.js');
64
+ }
65
+
66
+ return updated;
67
+ }
68
+
69
+ function copyFiles(src, dist) {
70
+ if (!fs.existsSync(src)) return;
71
+ if (!fs.existsSync(dist)) {
72
+ fs.mkdirSync(dist, { recursive: true });
73
+ }
74
+
75
+ const entries = fs.readdirSync(src, { withFileTypes: true });
76
+
77
+ for (const entry of entries) {
78
+ const srcPath = path.join(src, entry.name);
79
+ const distPath = path.join(dist, entry.name);
80
+
81
+ if (entry.isDirectory()) {
82
+ if (entry.name === "node_modules" || entry.name === "dist") continue;
83
+ copyFiles(srcPath, distPath);
84
+ } else if (entry.isFile() && (entry.name.endsWith(".js") || entry.name.endsWith(".json"))) {
85
+ let content = fs.readFileSync(srcPath, 'utf8');
86
+ content = rewriteImports(content, distPath, true);
87
+ fs.writeFileSync(distPath, content);
88
+ }
89
+ }
90
+ }
91
+
92
+ // Copy open-sse directory recursively to dist
93
+ function copyDirRecursive(src, dest) {
94
+ if (!fs.existsSync(src)) return;
95
+ if (!fs.existsSync(dest)) {
96
+ fs.mkdirSync(dest, { recursive: true });
97
+ }
98
+ const entries = fs.readdirSync(src, { withFileTypes: true });
99
+ for (const entry of entries) {
100
+ const srcPath = path.join(src, entry.name);
101
+ const destPath = path.join(dest, entry.name);
102
+ if (entry.isDirectory()) {
103
+ copyDirRecursive(srcPath, destPath);
104
+ } else {
105
+ let content = fs.readFileSync(srcPath, 'utf8');
106
+ content = rewriteImports(content, destPath, false);
107
+ fs.writeFileSync(destPath, content);
108
+ }
109
+ }
110
+ }
111
+ copyDirRecursive(path.join(process.cwd(), "open-sse"), path.join(distDir, "open-sse"));
112
+
113
+ copyFiles(srcDir, distDir);
114
+ console.log("Copied all JS files from src/ to dist/ with resolved path imports successfully!");
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first
4
+ // `9router` start doesn't need network. Failure here is non-fatal —
5
+ // cli.js will retry at runtime if anything is missing.
6
+ const { ensureSqliteRuntime } = require("./sqliteRuntime");
7
+ const { ensureTrayRuntime } = require("./trayRuntime");
8
+
9
+ try {
10
+ ensureSqliteRuntime({ silent: false });
11
+ console.log("[9router] runtime SQLite deps ready");
12
+ } catch (e) {
13
+ console.warn(`[9router] runtime warm-up skipped: ${e.message}`);
14
+ }
15
+
16
+ try {
17
+ ensureTrayRuntime({ silent: false });
18
+ } catch (e) {
19
+ console.warn(`[9router] tray runtime skipped: ${e.message}`);
20
+ }
21
+
22
+ process.exit(0);
@@ -0,0 +1,139 @@
1
+ // Ensure better-sqlite3 is installed in USER_DATA_DIR/runtime/node_modules
2
+ // (user-writable, avoids Windows EBUSY locks during npm i -g updates).
3
+ // sql.js is bundled in bin/app already; node:sqlite / bun:sqlite are built-in.
4
+ const { execSync, spawnSync } = require("child_process");
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+
9
+ const BETTER_SQLITE3_VERSION = "12.6.2";
10
+
11
+ function getDataDir() {
12
+ if (process.env.DATA_DIR) return process.env.DATA_DIR;
13
+ return process.platform === "win32"
14
+ ? path.join(process.env.APPDATA || os.homedir(), "9router")
15
+ : path.join(os.homedir(), ".9router");
16
+ }
17
+
18
+ function getRuntimeDir() {
19
+ return path.join(getDataDir(), "runtime");
20
+ }
21
+
22
+ function getRuntimeNodeModules() {
23
+ return path.join(getRuntimeDir(), "node_modules");
24
+ }
25
+
26
+ function ensureRuntimeDir() {
27
+ const dir = getRuntimeDir();
28
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
29
+
30
+ // Minimal package.json so npm treats it as a project root
31
+ const pkgPath = path.join(dir, "package.json");
32
+ if (!fs.existsSync(pkgPath)) {
33
+ fs.writeFileSync(pkgPath, JSON.stringify({
34
+ name: "9router-runtime",
35
+ version: "1.0.0",
36
+ private: true,
37
+ description: "User-writable runtime deps for 9router (better-sqlite3 native binary)",
38
+ }, null, 2));
39
+ }
40
+ return dir;
41
+ }
42
+
43
+ function hasModule(name) {
44
+ return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json"));
45
+ }
46
+
47
+ function isBetterSqliteBinaryValid() {
48
+ const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node");
49
+ if (!fs.existsSync(binary)) return false;
50
+ try {
51
+ const fd = fs.openSync(binary, "r");
52
+ const buf = Buffer.alloc(4);
53
+ fs.readSync(fd, buf, 0, 4, 0);
54
+ fs.closeSync(fd);
55
+ const magic = buf.toString("hex");
56
+ if (process.platform === "linux") return magic.startsWith("7f454c46");
57
+ if (process.platform === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe");
58
+ if (process.platform === "win32") return magic.startsWith("4d5a");
59
+ return true;
60
+ } catch { return false; }
61
+ }
62
+
63
+ // Extract a short, user-friendly reason from npm stderr.
64
+ function summarizeNpmError(stderr = "") {
65
+ const text = String(stderr);
66
+ if (/ENOTFOUND|ETIMEDOUT|EAI_AGAIN|network|getaddrinfo/i.test(text)) return "No internet connection or registry unreachable";
67
+ if (/EACCES|EPERM|permission denied/i.test(text)) return "Permission denied (check folder permissions)";
68
+ if (/ENOSPC|no space/i.test(text)) return "Not enough disk space";
69
+ if (/node-gyp|gyp ERR|python|MSBuild|Visual Studio|Xcode/i.test(text)) return "Missing build tools (Xcode CLT / Python / VS Build Tools)";
70
+ if (/ETARGET|version.*not found/i.test(text)) return "Package version not found on registry";
71
+ const m = text.match(/npm ERR! (.+)/);
72
+ if (m) return m[1].slice(0, 200);
73
+ const lastLine = text.trim().split(/\r?\n/).filter(Boolean).pop();
74
+ return lastLine ? lastLine.slice(0, 200) : "Unknown error";
75
+ }
76
+
77
+ function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) {
78
+ const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online", ...extraArgs];
79
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
80
+ const res = spawnSync(npmCmd, args, {
81
+ cwd,
82
+ stdio: ["ignore", "pipe", "pipe"],
83
+ timeout,
84
+ shell: process.platform === "win32",
85
+ encoding: "utf8",
86
+ });
87
+ return { ok: res.status === 0, code: res.status, stderr: res.stderr || "", stdout: res.stdout || "" };
88
+ }
89
+
90
+ function npmInstall(pkgs, opts = {}) {
91
+ const cwd = ensureRuntimeDir();
92
+ const extra = opts.optional ? ["--no-save"] : [];
93
+ if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)...");
94
+ const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 });
95
+ if (!res.ok && !opts.silent) {
96
+ const reason = summarizeNpmError(res.stderr);
97
+ console.warn("⚠️ SQLite engine install failed — using fallback");
98
+ console.warn(` Reason: ${reason}`);
99
+ console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`);
100
+ }
101
+ return res.ok;
102
+ }
103
+
104
+ // Public: ensure better-sqlite3 native module is installed in user-writable
105
+ // runtime dir. sql.js is bundled in bin/app already; node:sqlite is built-in.
106
+ // This is purely a *speed optimization* — app works without it via fallbacks.
107
+ function ensureSqliteRuntime({ silent = false } = {}) {
108
+ ensureRuntimeDir();
109
+
110
+ const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid();
111
+ if (!needBetterSqlite) {
112
+ if (!silent) console.log("✅ SQLite engine ready");
113
+ return { betterSqlite: true };
114
+ }
115
+
116
+ const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent });
117
+ return {
118
+ betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(),
119
+ };
120
+ }
121
+
122
+ // Inject runtime + bundled node_modules into NODE_PATH so child Node processes
123
+ // resolve sql.js (bundled in bin/app/node_modules) and better-sqlite3 (runtime).
124
+ function buildEnvWithRuntime(baseEnv = process.env) {
125
+ const runtimeNm = getRuntimeNodeModules();
126
+ const bundledNm = path.join(__dirname, "..", "app", "node_modules");
127
+ const existing = baseEnv.NODE_PATH || "";
128
+ const NODE_PATH = [runtimeNm, bundledNm, existing].filter(Boolean).join(path.delimiter);
129
+ return { ...baseEnv, NODE_PATH };
130
+ }
131
+
132
+ module.exports = {
133
+ ensureSqliteRuntime,
134
+ buildEnvWithRuntime,
135
+ getRuntimeDir,
136
+ getRuntimeNodeModules,
137
+ runNpmInstall,
138
+ summarizeNpmError,
139
+ };
@@ -0,0 +1,107 @@
1
+ // Lazy install systray2 for macOS/Linux into USER_DATA_DIR/runtime/node_modules.
2
+ // Windows uses PowerShell NotifyIcon (no binary) → no systray needed.
3
+ // This keeps the published npm tarball free of unsigned Go binaries that
4
+ // trigger antivirus false positives (e.g. Kaspersky flagging tray_windows.exe).
5
+ //
6
+ // We use the maintained `systray2` fork. The original `systray@1.0.5` package
7
+ // bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern
8
+ // dyld (macOS 14+), so the tray silently fails to register on Apple Silicon.
9
+ const { spawnSync } = require("child_process");
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime.cjs");
13
+
14
+ const SYSTRAY_PKG = "systray2";
15
+ const SYSTRAY_VERSION = "2.1.4";
16
+ const LEGACY_SYSTRAY_PKG = "systray";
17
+
18
+ function hasSystray() {
19
+ return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json"));
20
+ }
21
+
22
+ // Remove the legacy `systray` package from all known locations.
23
+ // On Windows it was an AV false-positive risk; on macOS/Linux its bundled
24
+ // binary is broken on modern OS versions.
25
+ function cleanupLegacySystray({ silent = false } = {}) {
26
+ // 1) Runtime dir: ~/.9router/runtime/node_modules/systray (or %APPDATA% on Win)
27
+ // 2) npm global nested: <npm_prefix>/node_modules/9router/node_modules/systray
28
+ // __dirname here = <pkg root>/hooks → up 1 = pkg root
29
+ const targets = [
30
+ path.join(getRuntimeNodeModules(), LEGACY_SYSTRAY_PKG),
31
+ path.join(__dirname, "..", "node_modules", LEGACY_SYSTRAY_PKG)
32
+ ];
33
+ for (const dir of targets) {
34
+ if (fs.existsSync(dir)) {
35
+ try {
36
+ fs.rmSync(dir, { recursive: true, force: true });
37
+ if (!silent) console.log(`[9router][runtime] removed legacy systray: ${dir}`);
38
+ } catch (e) {
39
+ if (!silent) console.warn(`[9router][runtime] failed to remove ${dir}: ${e.message}`);
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ // systray2's npm tarball sometimes ships the bundled Go binary without the
46
+ // executable bit set on macOS, causing spawn() to fail with EACCES. Set +x
47
+ // best-effort so the tray actually starts.
48
+ function chmodSystrayBin({ silent = false } = {}) {
49
+ if (process.platform === "win32") return;
50
+ const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
51
+ const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", binName);
52
+ if (!fs.existsSync(binPath)) return;
53
+ try {
54
+ fs.chmodSync(binPath, 0o755);
55
+ } catch (e) {
56
+ if (!silent) console.warn(`[9router][runtime] chmod tray bin failed: ${e.message}`);
57
+ }
58
+ }
59
+
60
+ function ensureRuntimeDir() {
61
+ const dir = getRuntimeDir();
62
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
63
+ const pkgPath = path.join(dir, "package.json");
64
+ if (!fs.existsSync(pkgPath)) {
65
+ fs.writeFileSync(pkgPath, JSON.stringify({
66
+ name: "9router-runtime",
67
+ version: "1.0.0",
68
+ private: true
69
+ }, null, 2));
70
+ }
71
+ return dir;
72
+ }
73
+
74
+ function npmInstall(pkgs, { silent = false } = {}) {
75
+ const cwd = ensureRuntimeDir();
76
+ if (!silent) console.log("⏳ Installing system tray (first run)...");
77
+ const res = runNpmInstall({ cwd, pkgs, extraArgs: ["--no-save"], timeout: 120000 });
78
+ if (!res.ok && !silent) {
79
+ const reason = summarizeNpmError(res.stderr);
80
+ console.warn("⚠️ System tray install failed — tray disabled");
81
+ console.warn(` Reason: ${reason}`);
82
+ console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`);
83
+ }
84
+ return res.ok;
85
+ }
86
+
87
+ // Public: ensure systray2 is installed on macOS/Linux only.
88
+ // Windows skips entirely (uses PowerShell tray).
89
+ function ensureTrayRuntime({ silent = false } = {}) {
90
+ // Always evict the legacy `systray` package — its binary is broken on
91
+ // modern macOS and an AV false-positive on Windows.
92
+ cleanupLegacySystray({ silent });
93
+
94
+ if (process.platform === "win32") {
95
+ return { systray: false, skipped: true };
96
+ }
97
+ if (hasSystray()) {
98
+ chmodSystrayBin({ silent });
99
+ if (!silent) console.log("✅ System tray ready");
100
+ return { systray: true };
101
+ }
102
+ const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent });
103
+ if (ok) chmodSystrayBin({ silent });
104
+ return { systray: ok && hasSystray() };
105
+ }
106
+
107
+ module.exports = { ensureTrayRuntime };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fudrouter/fsrouter",
3
- "version": "0.6.37",
3
+ "version": "0.6.39",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "9Router v2 \u2014 Express Backend (API, SSE, DB, Auth, MITM)",
@@ -30,22 +30,14 @@
30
30
  "socks-proxy-agent": "^8.0.5",
31
31
  "sql.js": "^1.14.1",
32
32
  "undici": "^7.10.0",
33
- "uuid": "^11.1.0"
33
+ "uuid": "^11.1.0",
34
+ "tsx": "^4.19.4",
35
+ "@modelcontextprotocol/sdk": "^1.0.0",
36
+ "@types/node": "^24.0.0"
34
37
  },
35
38
  "imports": {
36
39
  "@/*": "./dist/*",
37
40
  "open-sse": "./open-sse/index.js",
38
41
  "open-sse/*": "./open-sse/*"
39
- },
40
- "devDependencies": {
41
- "@modelcontextprotocol/sdk": "^1.0.0",
42
- "@types/bcryptjs": "^2.4.6",
43
- "@types/better-sqlite3": "^7.6.13",
44
- "@types/cookie-parser": "^1.4.8",
45
- "@types/cors": "^2.8.17",
46
- "@types/express": "^5.0.3",
47
- "@types/node": "^24.0.0",
48
- "tsx": "^4.19.4",
49
- "typescript": "^5.8.3"
50
42
  }
51
43
  }