switchboard-router 0.6.2 → 0.6.4

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 (151) hide show
  1. package/README.md +40 -0
  2. package/app/.next-cli-build/BUILD_ID +1 -1
  3. package/app/.next-cli-build/app-path-routes-manifest.json +7 -7
  4. package/app/.next-cli-build/build-manifest.json +2 -2
  5. package/app/.next-cli-build/prerender-manifest.json +3 -3
  6. package/app/.next-cli-build/server/app/(dashboard)/dashboard/agent-library/page_client-reference-manifest.js +1 -1
  7. package/app/.next-cli-build/server/app/(dashboard)/dashboard/basic-chat/page_client-reference-manifest.js +1 -1
  8. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/[toolId]/page_client-reference-manifest.js +1 -1
  9. package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/page_client-reference-manifest.js +1 -1
  10. package/app/.next-cli-build/server/app/(dashboard)/dashboard/combos/page_client-reference-manifest.js +1 -1
  11. package/app/.next-cli-build/server/app/(dashboard)/dashboard/combos/routing/page_client-reference-manifest.js +1 -1
  12. package/app/.next-cli-build/server/app/(dashboard)/dashboard/console-log/page_client-reference-manifest.js +1 -1
  13. package/app/.next-cli-build/server/app/(dashboard)/dashboard/endpoint/page_client-reference-manifest.js +1 -1
  14. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page_client-reference-manifest.js +1 -1
  15. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/page_client-reference-manifest.js +1 -1
  16. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/combo/[id]/page_client-reference-manifest.js +1 -1
  17. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/page_client-reference-manifest.js +1 -1
  18. package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/web/page_client-reference-manifest.js +1 -1
  19. package/app/.next-cli-build/server/app/(dashboard)/dashboard/mitm/page_client-reference-manifest.js +1 -1
  20. package/app/.next-cli-build/server/app/(dashboard)/dashboard/page_client-reference-manifest.js +1 -1
  21. package/app/.next-cli-build/server/app/(dashboard)/dashboard/profile/page_client-reference-manifest.js +1 -1
  22. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/[id]/page_client-reference-manifest.js +1 -1
  23. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/new/page_client-reference-manifest.js +1 -1
  24. package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/page_client-reference-manifest.js +1 -1
  25. package/app/.next-cli-build/server/app/(dashboard)/dashboard/quota/page_client-reference-manifest.js +1 -1
  26. package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/[id]/page_client-reference-manifest.js +1 -1
  27. package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/page_client-reference-manifest.js +1 -1
  28. package/app/.next-cli-build/server/app/(dashboard)/dashboard/token-saver/page_client-reference-manifest.js +1 -1
  29. package/app/.next-cli-build/server/app/(dashboard)/dashboard/translator/page_client-reference-manifest.js +1 -1
  30. package/app/.next-cli-build/server/app/(dashboard)/dashboard/usage/page_client-reference-manifest.js +1 -1
  31. package/app/.next-cli-build/server/app/_global-error/page_client-reference-manifest.js +1 -1
  32. package/app/.next-cli-build/server/app/_global-error.html +1 -1
  33. package/app/.next-cli-build/server/app/_global-error.rsc +1 -1
  34. package/app/.next-cli-build/server/app/_global-error.segments/_full.segment.rsc +1 -1
  35. package/app/.next-cli-build/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  36. package/app/.next-cli-build/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  37. package/app/.next-cli-build/server/app/_global-error.segments/_head.segment.rsc +1 -1
  38. package/app/.next-cli-build/server/app/_global-error.segments/_index.segment.rsc +1 -1
  39. package/app/.next-cli-build/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  40. package/app/.next-cli-build/server/app/_not-found/page_client-reference-manifest.js +1 -1
  41. package/app/.next-cli-build/server/app/_not-found.html +1 -1
  42. package/app/.next-cli-build/server/app/_not-found.rsc +4 -4
  43. package/app/.next-cli-build/server/app/_not-found.segments/_full.segment.rsc +4 -4
  44. package/app/.next-cli-build/server/app/_not-found.segments/_head.segment.rsc +1 -1
  45. package/app/.next-cli-build/server/app/_not-found.segments/_index.segment.rsc +3 -3
  46. package/app/.next-cli-build/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  47. package/app/.next-cli-build/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  48. package/app/.next-cli-build/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  49. package/app/.next-cli-build/server/app/api/cli-tools/aider-settings/route.js +2 -2
  50. package/app/.next-cli-build/server/app/api/cli-tools/all-statuses/route.js +4 -4
  51. package/app/.next-cli-build/server/app/api/cli-tools/antigravity-mitm/alias/route.js +2 -2
  52. package/app/.next-cli-build/server/app/api/cli-tools/claude-settings/route.js +2 -2
  53. package/app/.next-cli-build/server/app/api/cli-tools/cline-settings/route.js +2 -2
  54. package/app/.next-cli-build/server/app/api/cli-tools/codex-settings/route.js +2 -2
  55. package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-registry/route.js +2 -2
  56. package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-tools/route.js +1 -1
  57. package/app/.next-cli-build/server/app/api/cli-tools/cowork-settings/route.js +2 -2
  58. package/app/.next-cli-build/server/app/api/cli-tools/deepseek-tui-settings/route.js +2 -2
  59. package/app/.next-cli-build/server/app/api/cli-tools/droid-settings/route.js +2 -2
  60. package/app/.next-cli-build/server/app/api/cli-tools/gemini-cli-settings/route.js +2 -2
  61. package/app/.next-cli-build/server/app/api/cli-tools/grok-settings/route.js +2 -2
  62. package/app/.next-cli-build/server/app/api/cli-tools/hermes-settings/route.js +2 -2
  63. package/app/.next-cli-build/server/app/api/cli-tools/jcode-settings/route.js +2 -2
  64. package/app/.next-cli-build/server/app/api/cli-tools/kilo-settings/route.js +2 -2
  65. package/app/.next-cli-build/server/app/api/cli-tools/openclaw-settings/route.js +1 -1
  66. package/app/.next-cli-build/server/app/api/cli-tools/opencode-settings/route.js +2 -2
  67. package/app/.next-cli-build/server/app/api/cli-tools/pi-settings/route.js +1 -1
  68. package/app/.next-cli-build/server/app/api/version/route.js +1 -1
  69. package/app/.next-cli-build/server/app/api/version/shutdown/route.js +1 -1
  70. package/app/.next-cli-build/server/app/api/version/update/route.js +1 -1
  71. package/app/.next-cli-build/server/app/callback/page_client-reference-manifest.js +1 -1
  72. package/app/.next-cli-build/server/app/callback.html +1 -1
  73. package/app/.next-cli-build/server/app/callback.rsc +4 -4
  74. package/app/.next-cli-build/server/app/callback.segments/_full.segment.rsc +4 -4
  75. package/app/.next-cli-build/server/app/callback.segments/_head.segment.rsc +1 -1
  76. package/app/.next-cli-build/server/app/callback.segments/_index.segment.rsc +3 -3
  77. package/app/.next-cli-build/server/app/callback.segments/_tree.segment.rsc +1 -1
  78. package/app/.next-cli-build/server/app/callback.segments/callback/__PAGE__.segment.rsc +1 -1
  79. package/app/.next-cli-build/server/app/callback.segments/callback.segment.rsc +1 -1
  80. package/app/.next-cli-build/server/app/dashboard/settings/pricing/page_client-reference-manifest.js +1 -1
  81. package/app/.next-cli-build/server/app/dashboard/settings/pricing.html +1 -1
  82. package/app/.next-cli-build/server/app/dashboard/settings/pricing.rsc +4 -4
  83. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_full.segment.rsc +4 -4
  84. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_head.segment.rsc +1 -1
  85. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_index.segment.rsc +3 -3
  86. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_tree.segment.rsc +1 -1
  87. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/dashboard/settings/pricing/__PAGE__.segment.rsc +1 -1
  88. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/dashboard/settings/pricing.segment.rsc +1 -1
  89. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/dashboard/settings.segment.rsc +1 -1
  90. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/dashboard.segment.rsc +1 -1
  91. package/app/.next-cli-build/server/app/favicon.ico/route.js +1 -1
  92. package/app/.next-cli-build/server/app/favicon.ico.body +0 -0
  93. package/app/.next-cli-build/server/app/index.html +1 -1
  94. package/app/.next-cli-build/server/app/index.rsc +4 -4
  95. package/app/.next-cli-build/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  96. package/app/.next-cli-build/server/app/index.segments/_full.segment.rsc +4 -4
  97. package/app/.next-cli-build/server/app/index.segments/_head.segment.rsc +1 -1
  98. package/app/.next-cli-build/server/app/index.segments/_index.segment.rsc +3 -3
  99. package/app/.next-cli-build/server/app/index.segments/_tree.segment.rsc +1 -1
  100. package/app/.next-cli-build/server/app/landing/page_client-reference-manifest.js +1 -1
  101. package/app/.next-cli-build/server/app/landing.html +1 -1
  102. package/app/.next-cli-build/server/app/landing.rsc +4 -4
  103. package/app/.next-cli-build/server/app/landing.segments/_full.segment.rsc +4 -4
  104. package/app/.next-cli-build/server/app/landing.segments/_head.segment.rsc +1 -1
  105. package/app/.next-cli-build/server/app/landing.segments/_index.segment.rsc +3 -3
  106. package/app/.next-cli-build/server/app/landing.segments/_tree.segment.rsc +1 -1
  107. package/app/.next-cli-build/server/app/landing.segments/landing/__PAGE__.segment.rsc +1 -1
  108. package/app/.next-cli-build/server/app/landing.segments/landing.segment.rsc +1 -1
  109. package/app/.next-cli-build/server/app/login/page_client-reference-manifest.js +1 -1
  110. package/app/.next-cli-build/server/app/login.html +1 -1
  111. package/app/.next-cli-build/server/app/login.rsc +4 -4
  112. package/app/.next-cli-build/server/app/login.segments/_full.segment.rsc +4 -4
  113. package/app/.next-cli-build/server/app/login.segments/_head.segment.rsc +1 -1
  114. package/app/.next-cli-build/server/app/login.segments/_index.segment.rsc +3 -3
  115. package/app/.next-cli-build/server/app/login.segments/_tree.segment.rsc +1 -1
  116. package/app/.next-cli-build/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  117. package/app/.next-cli-build/server/app/login.segments/login.segment.rsc +1 -1
  118. package/app/.next-cli-build/server/app/page_client-reference-manifest.js +1 -1
  119. package/app/.next-cli-build/server/app-paths-manifest.json +7 -7
  120. package/app/.next-cli-build/server/chunks/194.js +1 -1
  121. package/app/.next-cli-build/server/chunks/4126.js +1 -1
  122. package/app/.next-cli-build/server/chunks/4746.js +1 -1
  123. package/app/.next-cli-build/server/chunks/7141.js +1 -1
  124. package/app/.next-cli-build/server/chunks/869.js +1 -1
  125. package/app/.next-cli-build/server/chunks/8998.js +1 -1
  126. package/app/.next-cli-build/server/middleware-build-manifest.js +1 -1
  127. package/app/.next-cli-build/server/middleware.js +1 -1
  128. package/app/.next-cli-build/server/pages/404.html +1 -1
  129. package/app/.next-cli-build/server/pages/500.html +1 -1
  130. package/app/.next-cli-build/server/server-reference-manifest.js +1 -1
  131. package/app/.next-cli-build/server/server-reference-manifest.json +1 -1
  132. package/app/.next-cli-build/static/chunks/{1321-ffb163200498083f.js → 1321-5429c7d81eb38c6f.js} +1 -1
  133. package/app/package.json +1 -1
  134. package/app/public/icons/icon-192.svg +3 -6
  135. package/app/public/icons/icon-512.svg +3 -6
  136. package/app/src/lib/updater/updater.js +41 -12
  137. package/cli.js +401 -320
  138. package/package.json +1 -1
  139. package/src/cli/cliOptions.js +139 -0
  140. package/src/cli/menus/settings.js +2 -2
  141. package/src/cli/processManager.js +186 -0
  142. package/src/cli/serverStatus.js +90 -0
  143. package/src/cli/terminalUI.js +12 -11
  144. package/src/cli/tray/autostart.js +35 -14
  145. package/src/cli/tray/icon.ico +0 -0
  146. package/src/cli/tray/icon.png +0 -0
  147. package/src/cli/tray/tray.js +34 -32
  148. package/src/cli/tray/trayWin.js +23 -9
  149. package/src/cli/utils/input.js +10 -1
  150. /package/app/.next-cli-build/static/{tuUk9HIFwmM61jSjfHmA7 → 88rQB4DdrBDjq9aWvSEH4}/_buildManifest.js +0 -0
  151. /package/app/.next-cli-build/static/{tuUk9HIFwmM61jSjfHmA7 → 88rQB4DdrBDjq9aWvSEH4}/_ssgManifest.js +0 -0
package/cli.js CHANGED
@@ -6,6 +6,30 @@ const fs = require("fs");
6
6
  const https = require("https");
7
7
  const os = require("os");
8
8
  const { openBrowser: openBrowserWithoutShell } = require("./src/shared/openBrowser");
9
+ const pkg = require("./package.json");
10
+ const { DEFAULT_HOST, DEFAULT_PORT, formatHelp, isLoopbackHost, parseCliArgs } = require("./src/cli/cliOptions");
11
+ const processTools = require("./src/cli/processManager");
12
+ const { findListeningPids } = processTools;
13
+ const { probeSwitchboard, probeTcp, waitForSwitchboard } = require("./src/cli/serverStatus");
14
+
15
+ let cliOptions;
16
+ try {
17
+ cliOptions = parseCliArgs(process.argv.slice(2));
18
+ } catch (error) {
19
+ console.error(`Error: ${error.message}\n`);
20
+ console.error(formatHelp({ version: pkg.version }));
21
+ process.exit(2);
22
+ }
23
+
24
+ if (cliOptions.help) {
25
+ console.log(formatHelp({ version: pkg.version }));
26
+ process.exit(0);
27
+ }
28
+ if (cliOptions.version) {
29
+ console.log(pkg.version);
30
+ process.exit(0);
31
+ }
32
+ const startsServer = ["start", "restart"].includes(cliOptions.command);
9
33
 
10
34
  // Native spinner - no external dependency
11
35
  function createSpinner(text) {
@@ -43,12 +67,9 @@ function createSpinner(text) {
43
67
  };
44
68
  }
45
69
 
46
- const pkg = require("./package.json");
47
70
  const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
48
71
  const { ensureTrayRuntime } = require("./hooks/trayRuntime");
49
72
  const { pinDataDir, getDataDir } = require("./src/shared/dataDir");
50
- const { disableMitm } = require("./src/shared/disableMitm");
51
- const args = process.argv.slice(2);
52
73
 
53
74
  // Resolve the data directory ONCE, before anything can create cache dirs inside
54
75
  // a candidate, and export it so the spawned server and the CLI's token client
@@ -58,10 +79,14 @@ pinDataDir();
58
79
  // Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.switchboard/runtime
59
80
  // so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
60
81
  // better-sqlite3 is optional. Logs to stderr only on failure.
61
- try { ensureSqliteRuntime({ silent: true }); } catch {}
82
+ if (startsServer) {
83
+ try { ensureSqliteRuntime({ silent: true }); } catch {}
84
+ }
62
85
 
63
86
  // Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
64
- try { ensureTrayRuntime({ silent: true }); } catch {}
87
+ if (startsServer) {
88
+ try { ensureTrayRuntime({ silent: true }); } catch {}
89
+ }
65
90
 
66
91
  // Configuration constants
67
92
  // npm package name (switchboard-router) vs CLI bin (switchboard)
@@ -69,12 +94,34 @@ const PKG_NAME = pkg.name || "switchboard-router";
69
94
  const APP_NAME =
70
95
  (pkg.bin && Object.keys(pkg.bin)[0]) || "switchboard";
71
96
  const INSTALL_CMD_LATEST = `npm i -g ${PKG_NAME}@latest --prefer-online`;
97
+ const INSTANCE_ID = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
98
+
99
+ function commandFor(action = "") {
100
+ const commandName = action ? `${APP_NAME} ${action}` : APP_NAME;
101
+ return `${commandName}${port === DEFAULT_PORT ? "" : ` --port ${port}`}`;
102
+ }
72
103
 
73
- const DEFAULT_PORT = 20128;
74
104
  // C1: bind loopback by default — LAN exposure is an explicit opt-in via --host 0.0.0.0
75
- const DEFAULT_HOST = "127.0.0.1";
76
105
  const ALL_INTERFACES = "0.0.0.0";
77
106
 
107
+ function isNetworkExposed() {
108
+ return !isLoopbackHost(host);
109
+ }
110
+
111
+ function getProbeHost() {
112
+ if (host === ALL_INTERFACES) return "127.0.0.1";
113
+ if (host === "::") return "::1";
114
+ return host;
115
+ }
116
+
117
+ function needsOwnedTcpProbe() {
118
+ return isNetworkExposed() && host !== ALL_INTERFACES && host !== "::";
119
+ }
120
+
121
+ function hasOwnedServerListener() {
122
+ return findListeningPids(port).some((pid) => processTools.processMatchesRecordedPath(pid, serverPath));
123
+ }
124
+
78
125
  // First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0.
79
126
  function getLanIp() {
80
127
  for (const ifaces of Object.values(os.networkInterfaces())) {
@@ -87,58 +134,24 @@ function getLanIp() {
87
134
 
88
135
  // Local URL stays "localhost" for loopback binds.
89
136
  function getDisplayHost() {
90
- if (host === DEFAULT_HOST || host === "localhost" || host === "::1") return "localhost";
137
+ if ([DEFAULT_HOST, "localhost", "::1", ALL_INTERFACES, "::"].includes(host)) return "localhost";
91
138
  return host;
92
139
  }
93
- const MAX_PORT_ATTEMPTS = 10;
94
- // Parse arguments
95
- let port = DEFAULT_PORT;
96
- let host = DEFAULT_HOST;
97
- let noBrowser = false;
98
- let skipUpdate = false;
99
- let showLog = false;
100
- let trayMode = false;
101
-
102
- for (let i = 0; i < args.length; i++) {
103
- if (args[i] === "--port" || args[i] === "-p") {
104
- port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
105
- i++;
106
- } else if (args[i] === "--host" || args[i] === "-H") {
107
- host = args[i + 1] || DEFAULT_HOST;
108
- i++;
109
- } else if (args[i] === "--no-browser" || args[i] === "-n") {
110
- noBrowser = true;
111
- } else if (args[i] === "--log" || args[i] === "-l") {
112
- showLog = true;
113
- } else if (args[i] === "--skip-update") {
114
- skipUpdate = true;
115
- } else if (args[i] === "--tray" || args[i] === "-t") {
116
- trayMode = true;
117
- process.env.TRAY_MODE = "1";
118
- } else if (args[i] === "--help" || args[i] === "-h") {
119
- console.log(`
120
- Usage: ${APP_NAME} [options]
121
-
122
- Options:
123
- -p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
124
- -H, --host <host> Host to bind (default: ${DEFAULT_HOST})
125
- -n, --no-browser Don't open browser automatically
126
- -l, --log Show server logs (default: hidden)
127
- -t, --tray Run in system tray mode (background)
128
- --skip-update Skip auto-update check
129
- -h, --help Show this help message
130
- -v, --version Show version
131
- `);
132
- process.exit(0);
133
- } else if (args[i] === "--version" || args[i] === "-v") {
134
- console.log(pkg.version);
135
- process.exit(0);
136
- }
140
+ const command = cliOptions.command;
141
+ let port = cliOptions.port;
142
+ let host = cliOptions.host;
143
+ const noBrowser = cliOptions.noBrowser;
144
+ const skipUpdate = cliOptions.skipUpdate;
145
+ const showLog = cliOptions.showLog;
146
+ let trayMode = cliOptions.trayMode;
147
+
148
+ if (noBrowser) {
149
+ console.warn("Note: --no-browser is retained for compatibility; Switchboard only opens a browser when you explicitly select Web UI.");
137
150
  }
138
151
 
139
152
  // A detached or piped CLI cannot answer the interactive menu. Keep it in the
140
153
  // long-lived tray lifecycle even when it was launched without lifecycle flags.
141
- if ((!process.stdin.isTTY || !process.stdout.isTTY) && !trayMode) {
154
+ if (startsServer && (!process.stdin.isTTY || !process.stdout.isTTY) && !trayMode) {
142
155
  trayMode = true;
143
156
  process.env.TRAY_MODE = "1";
144
157
  }
@@ -174,36 +187,40 @@ function readOwnedProcessState() {
174
187
  function writeOwnedProcessState(serverPid) {
175
188
  try {
176
189
  const file = getProcessStateFile();
190
+ const tmp = `${file}.tmp-${process.pid}`;
177
191
  fs.mkdirSync(path.dirname(file), { recursive: true });
178
- fs.writeFileSync(file, JSON.stringify({
192
+ const state = JSON.stringify({
179
193
  cliPid: process.pid,
180
194
  cliPath: __filename,
181
195
  serverPid,
182
196
  serverPath,
197
+ port,
198
+ host,
199
+ instanceId: INSTANCE_ID,
183
200
  writtenAt: Date.now(),
184
- }), { mode: 0o600 });
201
+ });
202
+ fs.writeFileSync(tmp, state, { mode: 0o600 });
203
+ fs.renameSync(tmp, file);
185
204
  } catch { /* best effort */ }
186
205
  }
187
206
 
188
207
  function clearOwnedProcessState() {
189
208
  try {
190
209
  const state = readOwnedProcessState();
191
- if (Number(state.cliPid) === process.pid) fs.unlinkSync(getProcessStateFile());
210
+ if (Number(state.cliPid) === process.pid && state.instanceId === INSTANCE_ID) fs.unlinkSync(getProcessStateFile());
192
211
  } catch { /* best effort */ }
193
212
  }
194
213
 
195
- function processMatchesRecordedPath(pid, expectedPath) {
196
- if (!expectedPath) return false;
214
+ function clearRecordedProcessState(state) {
197
215
  try {
198
- const command = process.platform === "win32"
199
- ? execSync(`powershell -NonInteractive -NoProfile -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId = ${Number(pid)}').CommandLine"`, { encoding: "utf8", windowsHide: true, timeout: 3000 })
200
- : execSync(`ps -p ${Number(pid)} -o command=`, { encoding: "utf8", timeout: 3000 });
201
- return command.includes(expectedPath);
202
- } catch {
203
- // If identity cannot be verified, fail closed rather than risking a PID
204
- // reuse killing an unrelated process.
205
- return false;
206
- }
216
+ const current = readOwnedProcessState();
217
+ const sameGeneration = current.instanceId
218
+ ? current.instanceId === state.instanceId
219
+ : Number(current.cliPid) === Number(state.cliPid)
220
+ && Number(current.serverPid) === Number(state.serverPid)
221
+ && Number(current.writtenAt) === Number(state.writtenAt);
222
+ if (sameGeneration) fs.unlinkSync(getProcessStateFile());
223
+ } catch { /* best effort */ }
207
224
  }
208
225
 
209
226
  function readOwnedAppPids() {
@@ -214,16 +231,21 @@ function readOwnedAppPids() {
214
231
  ]
215
232
  .filter(([, expectedPath]) => typeof expectedPath === "string" && expectedPath.length > 0)
216
233
  .map(([pid, expectedPath]) => [Number(pid), expectedPath])
217
- .filter(([pid, expectedPath]) => Number.isInteger(pid) && pid > 1 && pid !== process.pid && processMatchesRecordedPath(pid, expectedPath))
234
+ .filter(([pid, expectedPath]) => Number.isInteger(pid) && pid > 1 && pid !== process.pid && processTools.processMatchesRecordedPath(pid, expectedPath))
218
235
  .map(([pid]) => pid);
219
236
  }
220
237
 
221
238
  // Kill PID from file (best-effort, removes file after)
222
- function killByPidFile(pidFile) {
239
+ function killByPidFile(pidFile, expectedProcessNames = []) {
223
240
  try {
224
241
  if (!fs.existsSync(pidFile)) return;
225
242
  const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
226
243
  if (!pid) return;
244
+ const commandLine = processTools.getProcessCommand(pid).toLowerCase();
245
+ if (!commandLine || !expectedProcessNames.some((name) => commandLine.includes(name.toLowerCase()))) {
246
+ console.warn(`[switchboard] refusing to signal unverifiable PID ${pid} from ${pidFile}`);
247
+ return;
248
+ }
227
249
  try {
228
250
  if (process.platform === "win32") {
229
251
  execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
@@ -231,65 +253,35 @@ function killByPidFile(pidFile) {
231
253
  process.kill(pid, "SIGKILL");
232
254
  }
233
255
  } catch { }
234
- try { fs.unlinkSync(pidFile); } catch { }
256
+ if (!processTools.isPidAlive(pid)) {
257
+ try { fs.unlinkSync(pidFile); } catch { }
258
+ }
235
259
  } catch { }
236
260
  }
237
261
 
238
262
  // Kill tunnel processes (cloudflared/tailscale) by their PID files
239
263
  function killTunnelByPidFile() {
240
264
  const tunnelDir = path.join(getAppDataDir(), "tunnel");
241
- killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
242
- killByPidFile(path.join(tunnelDir, "tailscale.pid"));
265
+ killByPidFile(path.join(tunnelDir, "cloudflared.pid"), ["cloudflared"]);
266
+ killByPidFile(path.join(tunnelDir, "tailscale.pid"), ["tailscale"]);
243
267
  }
244
268
 
245
269
  // Kill only processes previously recorded by this launcher.
246
- function killAllAppProcesses(appPort) {
247
- return new Promise((resolve) => {
248
- try {
249
- // Kill MIT first (privileged process, needs special handling)
250
- killProxyByPidFile();
251
- // Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
252
- killTunnelByPidFile();
253
-
254
- const platform = process.platform;
255
- const pids = readOwnedAppPids();
256
-
257
- // Gracefully stop owned processes, then force only survivors.
258
- if (pids.length > 0) {
259
- const uniquePids = [...new Set(pids.map(Number))];
260
- uniquePids.forEach(pid => {
261
- try {
262
- if (platform === "win32") {
263
- execSync(`taskkill /T /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
264
- } else {
265
- process.kill(pid, "SIGTERM");
266
- }
267
- } catch (err) {
268
- // Process already dead or can't kill - continue
269
- }
270
- });
271
- setTimeout(() => {
272
- uniquePids.forEach((pid) => {
273
- try {
274
- process.kill(pid, 0);
275
- if (platform === "win32") {
276
- execSync(`taskkill /F /T /PID ${pid} 2>nul`, { stdio: "ignore", shell: true, windowsHide: true, timeout: 3000 });
277
- } else {
278
- process.kill(pid, "SIGKILL");
279
- }
280
- } catch { /* already stopped */ }
281
- });
282
- try { fs.unlinkSync(getProcessStateFile()); } catch { }
283
- resolve();
284
- }, 2000);
285
- } else {
286
- resolve();
287
- }
288
- } catch (err) {
289
- // Silent fail - continue anyway
290
- resolve();
291
- }
292
- });
270
+ async function killAllAppProcesses(appPort) {
271
+ killProxyByPidFile();
272
+ killTunnelByPidFile();
273
+ const state = readOwnedProcessState();
274
+ const recoverableServerPids = findListeningPids(appPort)
275
+ .filter((pid) => processTools.processMatchesRecordedPath(pid, serverPath));
276
+ const uniquePids = [...new Set([...readOwnedAppPids(), ...recoverableServerPids].map(Number))];
277
+ const serverPids = new Set([Number(state.serverPid), ...recoverableServerPids]);
278
+ const results = await Promise.allSettled(uniquePids.map((pid) => processTools.terminatePid(pid, {
279
+ timeoutMs: 2500,
280
+ processGroup: serverPids.has(pid),
281
+ })));
282
+ const allStopped = results.every((result) => result.status === "fulfilled" && result.value === true);
283
+ if (allStopped) clearRecordedProcessState(state);
284
+ return allStopped;
293
285
  }
294
286
 
295
287
  // Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
@@ -315,6 +307,11 @@ function killProxyByPidFile() {
315
307
  if (!fs.existsSync(pidFile)) return;
316
308
  const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
317
309
  if (!pid) return;
310
+ const commandLine = processTools.getProcessCommand(pid).toLowerCase();
311
+ if (!commandLine || !commandLine.includes("mitm") || !commandLine.includes("server")) {
312
+ console.warn(`[switchboard] refusing to signal unverifiable MITM PID ${pid}`);
313
+ return;
314
+ }
318
315
 
319
316
  if (process.platform === "win32") {
320
317
  // Graceful first (lets server cleanup hosts), then force
@@ -335,74 +332,23 @@ function killProxyByPidFile() {
335
332
  catch { try { process.kill(pid, "SIGKILL"); } catch { } }
336
333
  }
337
334
  }
338
- try { fs.unlinkSync(pidFile); } catch { }
339
- } catch { }
340
- }
341
-
342
- function findListeningPids(port) {
343
- const numericPort = Number(port);
344
- if (!Number.isInteger(numericPort) || numericPort <= 0) return [];
345
-
346
- try {
347
- if (process.platform === "win32") {
348
- const output = execSync(`netstat -ano | findstr :${numericPort}`, {
349
- encoding: "utf8",
350
- shell: true,
351
- windowsHide: true,
352
- timeout: 5000,
353
- });
354
- return output.split("\n")
355
- .filter((line) => line.includes("LISTENING"))
356
- .filter((line) => line.trim().split(/\s+/).some((field) => field.endsWith(`:${numericPort}`)))
357
- .map((line) => Number(line.trim().split(/\s+/).pop()))
358
- .filter((pid) => Number.isInteger(pid) && pid > 1);
335
+ if (!processTools.isPidAlive(pid)) {
336
+ try { fs.unlinkSync(pidFile); } catch { }
359
337
  }
360
-
361
- const output = execSync(`lsof -tiTCP:${numericPort} -sTCP:LISTEN`, {
362
- encoding: "utf8",
363
- stdio: ["pipe", "pipe", "ignore"],
364
- });
365
- return output.split(/\s+/)
366
- .map(Number)
367
- .filter((pid) => Number.isInteger(pid) && pid > 1);
368
- } catch {
369
- return [];
370
- }
338
+ } catch { }
371
339
  }
372
340
 
373
341
  // Never kill an arbitrary listener that happens to share the app port. The
374
342
  // launcher may only signal PIDs recorded in its ownership state file.
375
- function killProcessOnPort(port) {
376
- const ownedPids = new Set(readOwnedAppPids());
377
- const pids = findListeningPids(port).filter((pid) => ownedPids.has(pid));
378
- if (pids.length === 0) return Promise.resolve();
379
-
380
- return new Promise((resolve) => {
381
- const platform = process.platform;
382
- pids.forEach((pid) => {
383
- try {
384
- if (platform === "win32") {
385
- execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", shell: true, windowsHide: true, timeout: 3000 });
386
- } else {
387
- process.kill(pid, "SIGTERM");
388
- }
389
- } catch { /* already dead or inaccessible */ }
390
- });
391
-
392
- setTimeout(() => {
393
- pids.forEach((pid) => {
394
- try {
395
- process.kill(pid, 0);
396
- if (platform === "win32") {
397
- execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", shell: true, windowsHide: true, timeout: 3000 });
398
- } else {
399
- process.kill(pid, "SIGKILL");
400
- }
401
- } catch { /* already stopped */ }
402
- });
403
- resolve();
404
- }, 500);
405
- });
343
+ async function killProcessOnPort(port) {
344
+ const listeningPids = findListeningPids(port);
345
+ const ownedPids = new Set([
346
+ ...readOwnedAppPids(),
347
+ ...listeningPids.filter((pid) => processTools.processMatchesRecordedPath(pid, serverPath)),
348
+ ]);
349
+ const pids = listeningPids.filter((pid) => ownedPids.has(pid));
350
+ const results = await Promise.allSettled(pids.map((pid) => processTools.terminatePid(pid, { timeoutMs: 1000 })));
351
+ return results.every((result) => result.status === "fulfilled" && result.value === true);
406
352
  }
407
353
 
408
354
 
@@ -501,20 +447,141 @@ const serverPath = fs.existsSync(customServerPath)
501
447
  ? customServerPath
502
448
  : path.join(standaloneDir, "server.js");
503
449
 
504
- if (!fs.existsSync(serverPath)) {
450
+ if (startsServer && !fs.existsSync(serverPath)) {
505
451
  console.error("Error: Standalone build not found.");
506
452
  console.error("Please run 'npm run build:cli' first.");
507
453
  process.exit(1);
508
454
  }
509
455
 
510
- // Check for updates FIRST, then start server
511
- checkForUpdate().then((latestVersion) => {
512
- killAllAppProcesses(port).then(() => {
513
- return killProcessOnPort(port);
514
- }).then(() => {
456
+ async function probeRunningSwitchboard() {
457
+ const serverInfo = await probeSwitchboard(port, 1000, getProbeHost());
458
+ if (serverInfo) return serverInfo;
459
+ if (needsOwnedTcpProbe() && hasOwnedServerListener() && await probeTcp(port, 1000, getProbeHost())) {
460
+ return { name: "switchboard-app", version: null, startedAt: null, tcpOnly: true };
461
+ }
462
+ return null;
463
+ }
464
+
465
+ async function showStatus() {
466
+ const listeners = findListeningPids(port);
467
+ const serverInfo = await probeSwitchboard(port, 1000, getProbeHost());
468
+ const state = readOwnedProcessState();
469
+ const stateApplies = Number(state.port) === port;
470
+ const recordedPids = stateApplies ? new Set(readOwnedAppPids()) : new Set();
471
+ const owned = listeners.filter((pid) =>
472
+ recordedPids.has(pid) || processTools.processMatchesRecordedPath(pid, serverPath)
473
+ );
474
+ const tcpVerified = !serverInfo && needsOwnedTcpProbe() && owned.length > 0
475
+ ? await probeTcp(port, 1000, getProbeHost())
476
+ : false;
477
+ const verifiedServer = (!!serverInfo && (!serverInfo.legacyHealth || owned.length > 0)) || tcpVerified;
478
+ const result = {
479
+ running: verifiedServer,
480
+ port,
481
+ url: `http://localhost:${port}`,
482
+ listenerPids: listeners,
483
+ ownedPids: owned,
484
+ cliPid: stateApplies && recordedPids.has(Number(state.cliPid)) ? Number(state.cliPid) : null,
485
+ serverPid: verifiedServer ? (owned[0] || listeners[0] || null) : null,
486
+ serverVersion: serverInfo?.version || null,
487
+ cliVersion: pkg.version,
488
+ startedAt: serverInfo?.startedAt || null,
489
+ conflict: listeners.length > 0 && !verifiedServer,
490
+ };
491
+
492
+ if (cliOptions.json) {
493
+ console.log(JSON.stringify(result, null, 2));
494
+ } else if (result.running) {
495
+ console.log(`Switchboard is running at ${result.url}`);
496
+ console.log(`Server: v${result.serverVersion || "unknown"} (PID: ${result.serverPid || "unknown"})`);
497
+ console.log(`CLI: v${result.cliVersion}`);
498
+ console.log(`Managed: ${owned.length ? "yes" : "no (this CLI will not signal an unverified process)"}`);
499
+ if (result.startedAt) console.log(`Started: ${result.startedAt}`);
500
+ if (owned.length) {
501
+ console.log(`Stop: ${commandFor("stop")}`);
502
+ console.log(`Restart:${commandFor("restart")}`);
503
+ }
504
+ } else if (result.conflict) {
505
+ console.error(`Switchboard is stopped, but port ${port} is used by another process (PID: ${listeners.join(", ")}).`);
506
+ } else {
507
+ console.log(`Switchboard is not running on port ${port}.`);
508
+ console.log(`Start: ${commandFor()}`);
509
+ }
510
+ return result;
511
+ }
512
+
513
+ async function stopExistingInstance({ quiet = false } = {}) {
514
+ const before = quiet ? null : await probeRunningSwitchboard();
515
+ if (!quiet) console.log(before ? `Stopping Switchboard on port ${port}...` : `Checking for an owned Switchboard instance on port ${port}...`);
516
+ await killAllAppProcesses(port);
517
+ await killProcessOnPort(port);
518
+ await new Promise((resolve) => setTimeout(resolve, 250));
519
+ const after = await probeRunningSwitchboard();
520
+ const survivors = readOwnedAppPids();
521
+ if (after || survivors.length > 0) {
522
+ const listeners = findListeningPids(port);
523
+ console.error(`Could not safely stop Switchboard on port ${port}${listeners.length ? ` (PID: ${listeners.join(", ")})` : ""}.`);
524
+ console.error(`Run "${commandFor("status")}" for details.`);
525
+ return false;
526
+ }
527
+ clearRecordedProcessState(readOwnedProcessState());
528
+ if (!quiet) console.log(before ? "Switchboard stopped gracefully." : "No running owned instance found.");
529
+ return true;
530
+ }
531
+
532
+ async function run() {
533
+ if (["status", "stop", "restart"].includes(command)) {
534
+ const recorded = readOwnedProcessState();
535
+ if (!cliOptions.portProvided && Number.isInteger(Number(recorded.port)) && Number(recorded.port) > 0 && Number(recorded.port) <= 65535) {
536
+ port = Number(recorded.port);
537
+ }
538
+ if (!cliOptions.hostProvided && typeof recorded.host === "string" && recorded.host) host = recorded.host;
539
+ }
540
+ if (command === "status") {
541
+ const status = await showStatus();
542
+ return status.running ? 0 : status.conflict ? 1 : 3;
543
+ }
544
+ const releaseLock = await processTools.acquireLifecycleLock(getAppDataDir(), { instanceId: INSTANCE_ID });
545
+ try {
546
+ if (command === "stop") {
547
+ const stopped = await stopExistingInstance();
548
+ return stopped ? 0 : 1;
549
+ }
550
+
551
+ if (command === "restart") console.log(`Restarting Switchboard on port ${port}...`);
552
+ const stopped = await stopExistingInstance({ quiet: command === "start" });
553
+ if (!stopped) return 1;
554
+
555
+ const remaining = findListeningPids(port);
556
+ if (remaining.length > 0) {
557
+ const existing = await probeSwitchboard(port, 1000, getProbeHost());
558
+ const recoverable = remaining.some((pid) => processTools.processMatchesRecordedPath(pid, serverPath));
559
+ if (existing && (!existing.legacyHealth || recoverable)) {
560
+ console.log(`Switchboard is already running at http://localhost:${port} (v${existing.version || "unknown"}).`);
561
+ console.log(`Use "${commandFor("restart")}" to replace it.`);
562
+ return 0;
563
+ }
564
+ console.error(`Port ${port} is already in use by another process (PID: ${remaining.join(", ")}).`);
565
+ console.error(`Choose another port with --port or stop that process first.`);
566
+ return 1;
567
+ }
568
+
569
+ const latestVersion = await checkForUpdate();
515
570
  startServer(latestVersion);
571
+ return null;
572
+ } finally {
573
+ releaseLock();
574
+ }
575
+ }
576
+
577
+ run()
578
+ .then((exitCode) => {
579
+ if (Number.isInteger(exitCode)) process.exit(exitCode);
580
+ })
581
+ .catch((error) => {
582
+ console.error(`Failed to start Switchboard: ${error.message}`);
583
+ process.exit(1);
516
584
  });
517
- });
518
585
 
519
586
  // Show interface selection menu
520
587
  async function showInterfaceMenu(latestVersion) {
@@ -567,10 +634,10 @@ const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
567
634
  function startServer(latestVersion) {
568
635
  const displayHost = getDisplayHost();
569
636
  const url = `http://${displayHost}:${port}/dashboard`;
570
- // Surface real network exposure when bound to all interfaces (explicit opt-in).
571
- if (host === ALL_INTERFACES || host === "::") {
572
- const lanIp = getLanIp();
573
- if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound ${host}). Use --host 127.0.0.1 for local-only. Non-loopback /v1 requires an API key by default.\x1b[0m`);
637
+ // Surface real network exposure for wildcard and specific non-loopback binds.
638
+ if (isNetworkExposed()) {
639
+ const reachableHost = host === ALL_INTERFACES || host === "::" ? getLanIp() : host;
640
+ if (reachableHost) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${reachableHost}:${port} (bound ${host}). Use --host 127.0.0.1 for local-only. Non-loopback /v1 requires an API key by default.\x1b[0m`);
574
641
  }
575
642
 
576
643
  let restartCount = 0;
@@ -584,7 +651,7 @@ function startServer(latestVersion) {
584
651
  crashLog = [];
585
652
  const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
586
653
  cwd: standaloneDir,
587
- stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
654
+ stdio: ["ignore", "pipe", "pipe"],
588
655
  detached: true,
589
656
  windowsHide: true,
590
657
  env: {
@@ -594,90 +661,89 @@ function startServer(latestVersion) {
594
661
  }
595
662
  });
596
663
  if (child.pid) writeOwnedProcessState(child.pid);
597
- if (!showLog && child.stderr) {
598
- child.stderr.on("data", (data) => {
664
+ const captureOutput = (stream, destination) => {
665
+ if (!stream) return;
666
+ stream.on("data", (data) => {
599
667
  const lines = data.toString().split("\n").filter(Boolean);
600
668
  crashLog.push(...lines);
601
669
  if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
670
+ if (showLog && destination?.writable) destination.write(data);
602
671
  });
603
- }
672
+ };
673
+ captureOutput(child.stdout, process.stdout);
674
+ captureOutput(child.stderr, process.stderr);
604
675
  return child;
605
676
  }
606
677
 
607
678
  let server = spawnServer();
608
679
 
609
- // Cleanup function - graceful SIGTERM → wait → SIGKILL
680
+ // One bounded, idempotent shutdown path owns every exit trigger.
610
681
  let isCleaningUp = false;
611
- function cleanup() {
682
+ let isShuttingDown = false;
683
+ let shutdownPromise = null;
684
+ let restartTimer = null;
685
+
686
+ async function cleanup() {
612
687
  if (isCleaningUp) return;
613
688
  isCleaningUp = true;
614
689
  try {
615
- // Kill tray if running
690
+ let trayCleanup = Promise.resolve();
616
691
  try {
617
692
  const { killTray } = require("./src/cli/tray/tray");
618
- killTray();
693
+ trayCleanup = killTray();
619
694
  } catch (e) { }
620
- // Kill MIT server (privileged process) via PID file
621
695
  killProxyByPidFile();
622
- // Kill cloudflared/tailscale via PID file (only this app's tunnel)
623
696
  killTunnelByPidFile();
624
- // Graceful shutdown: SIGTERM first so server can flush DB, then SIGKILL
625
- if (server.pid) {
626
- try { process.kill(server.pid, "SIGTERM"); } catch { }
627
- if (!waitForExit(server.pid, 2000)) {
628
- try { process.kill(server.pid, "SIGKILL"); } catch { }
629
- }
630
- // Also try to kill process group
631
- try { process.kill(-server.pid, "SIGKILL"); } catch { }
632
- }
633
- clearOwnedProcessState();
697
+ const serverCleanup = server?.pid
698
+ ? processTools.terminatePid(server.pid, { timeoutMs: 2500, processGroup: true })
699
+ : Promise.resolve(true);
700
+ const [, serverResult] = await Promise.allSettled([trayCleanup, serverCleanup]);
701
+ if (serverResult.status === "fulfilled" && serverResult.value === true) clearOwnedProcessState();
634
702
  } catch (e) { }
635
703
  }
636
704
 
637
- // Suppress all errors during shutdown (systray lib throws JSON parse errors)
638
- let isShuttingDown = false;
705
+ function shutdown(exitCode = 0, message = "") {
706
+ if (shutdownPromise) return shutdownPromise;
707
+ isShuttingDown = true;
708
+ if (restartTimer) clearTimeout(restartTimer);
709
+ if (message) console.log(message);
710
+ shutdownPromise = cleanup().finally(() => process.exit(exitCode));
711
+ return shutdownPromise;
712
+ }
713
+
639
714
  process.on("uncaughtException", (err) => {
640
715
  if (isShuttingDown) return;
641
716
  console.error("Error:", err.message);
717
+ void shutdown(1);
642
718
  });
643
719
 
644
- // Handle all exit scenarios
645
- process.on("SIGINT", () => {
646
- if (isShuttingDown) return;
647
- isShuttingDown = true;
648
- console.log("\nExiting...");
649
- cleanup();
650
- setTimeout(() => process.exit(0), 100);
651
- });
652
- process.on("SIGTERM", () => {
653
- if (isShuttingDown) return;
654
- isShuttingDown = true;
655
- cleanup();
656
- setTimeout(() => process.exit(0), 100);
657
- });
658
- process.on("SIGHUP", () => {
659
- if (isShuttingDown) return;
660
- isShuttingDown = true;
661
- cleanup();
662
- setTimeout(() => process.exit(0), 100);
663
- });
720
+ process.on("SIGINT", () => { void shutdown(0, "\nStopping Switchboard gracefully..."); });
721
+ process.on("SIGTERM", () => { void shutdown(0); });
722
+ process.on("SIGHUP", () => { void shutdown(0); });
664
723
 
665
- // Initialize tray icon (runs alongside TUI)
666
- const initTrayIcon = () => {
724
+ const initTrayIcon = async () => {
667
725
  try {
668
726
  const { initTray } = require("./src/cli/tray/tray");
669
- initTray({
727
+ const tray = initTray({
670
728
  port,
671
- onQuit: () => {
672
- isShuttingDown = true;
729
+ host,
730
+ onQuit: async () => {
673
731
  console.log("\nšŸ‘‹ Shutting down from tray...");
674
- cleanup();
675
- setTimeout(() => process.exit(0), 100);
732
+ await shutdown(0);
676
733
  },
677
734
  onOpenDashboard: () => openBrowser(url)
678
735
  });
736
+ if (!tray) return false;
737
+ if (typeof tray.ready === "function") {
738
+ await Promise.race([
739
+ tray.ready(),
740
+ new Promise((_, reject) => setTimeout(() => reject(new Error("tray startup timed out")), 5000)),
741
+ ]);
742
+ }
743
+ return true;
679
744
  } catch (err) {
680
- // Tray not available - continue without it
745
+ console.error(`[switchboard] tray unavailable: ${err.message}`);
746
+ return false;
681
747
  }
682
748
  };
683
749
 
@@ -688,37 +754,64 @@ function startServer(latestVersion) {
688
754
  process.on("SIGHUP", () => {});
689
755
 
690
756
  console.log(`\nšŸš€ ${pkg.name} v${pkg.version}`);
691
- console.log(`Server: http://${displayHost}:${port}`);
757
+ console.log(`Starting server at http://${displayHost}:${port}...`);
692
758
 
693
- setTimeout(() => {
694
- initTrayIcon();
695
- console.log("\nšŸ’” Router is now running in system tray. Close this terminal if you want.");
696
- console.log(" Right-click tray icon to open dashboard or quit.\n");
697
- }, 2000);
759
+ void (async () => {
760
+ const ready = await waitForSwitchboard(port, {
761
+ hostname: getProbeHost(),
762
+ acceptTcpFallback: needsOwnedTcpProbe() ? hasOwnedServerListener : null,
763
+ });
764
+ if (!ready || isShuttingDown) {
765
+ if (!isShuttingDown) {
766
+ console.error(`Server did not become ready on port ${port}.`);
767
+ await shutdown(1);
768
+ }
769
+ return;
770
+ }
771
+ const trayReady = await initTrayIcon();
772
+ if (trayReady) {
773
+ console.log("\nSwitchboard is ready in the system tray.");
774
+ console.log("Right-click the tray icon to open the dashboard or quit.");
775
+ } else {
776
+ console.warn("\nSwitchboard is running without a tray icon.");
777
+ console.warn(`Stop it gracefully with: ${commandFor("stop")}`);
778
+ }
779
+ console.log(`Server: http://${displayHost}:${port}`);
780
+ console.log(`Status: ${commandFor("status")}\n`);
781
+ })();
698
782
 
699
783
  return;
700
784
  }
701
785
 
702
- // Wait for server to be ready, then show interface menu loop + tray
703
- setTimeout(async () => {
704
- // Start tray icon alongside TUI
705
- initTrayIcon();
786
+ // Wait for the actual management endpoint before exposing controls.
787
+ void (async () => {
788
+ const ready = await waitForSwitchboard(port, {
789
+ hostname: getProbeHost(),
790
+ acceptTcpFallback: needsOwnedTcpProbe() ? hasOwnedServerListener : null,
791
+ });
792
+ if (!ready || isShuttingDown) {
793
+ if (!isShuttingDown) {
794
+ console.error(`Server did not become ready on port ${port}.`);
795
+ await shutdown(1);
796
+ }
797
+ return;
798
+ }
799
+ const trayReady = await initTrayIcon();
800
+ if (!trayReady) {
801
+ console.warn(`Tray icon unavailable. You can stop safely with Ctrl+C or "${APP_NAME} stop".`);
802
+ }
706
803
 
707
804
  try {
708
805
  while (true) {
709
806
  const choice = await showInterfaceMenu(latestVersion);
710
807
 
711
808
  if (choice === "update") {
712
- isShuttingDown = true;
713
809
  const { clearScreen } = require("./src/cli/utils/display");
714
810
  clearScreen();
715
811
  console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
716
812
  console.log(`Run this after exit:\n`);
717
813
  console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
718
- cleanup();
719
- await killAllAppProcesses(port);
720
- await killProcessOnPort(port);
721
- setTimeout(() => process.exit(0), 200);
814
+ await shutdown(0);
722
815
  return;
723
816
  } else if (choice === "web") {
724
817
  openBrowser(url);
@@ -728,18 +821,12 @@ function startServer(latestVersion) {
728
821
  } else if (choice === "terminal") {
729
822
  // Start Terminal UI - it will return when user selects Back
730
823
  const { startTerminalUI } = require("./src/cli/terminalUI");
731
- await startTerminalUI(port);
824
+ await startTerminalUI(port, { networkExposed: isNetworkExposed() });
732
825
  // Loop continues, show menu again
733
826
  } else if (choice === "hide") {
734
827
  const { clearScreen } = require("./src/cli/utils/display");
735
828
  clearScreen();
736
829
 
737
- // Enable auto startup on OS boot
738
- try {
739
- const { enableAutoStart } = require("./src/cli/tray/autostart");
740
- enableAutoStart(__filename);
741
- } catch (e) { }
742
-
743
830
  if (process.platform === "darwin") {
744
831
  // macOS: keep current process alive — spawning a detached child puts
745
832
  // it outside the login session so NSStatusItem silently fails.
@@ -750,6 +837,7 @@ function startServer(latestVersion) {
750
837
  console.log(`šŸ”” Switchboard is running in tray (PID: ${process.pid})`);
751
838
  console.log(` Server: http://${displayHost}:${port}`);
752
839
  console.log(`\nšŸ’” You can close this terminal. Right-click tray icon to quit.\n`);
840
+ console.log(` If the icon is unavailable: ${commandFor("stop")}\n`);
753
841
 
754
842
  // Tray already init'd at startup — just keep event loop alive.
755
843
  return;
@@ -758,7 +846,10 @@ function startServer(latestVersion) {
758
846
  // Windows/Linux: spawn detached bgProcess (systray works fine in child)
759
847
  console.log(`\nā³ Starting background process... (tray icon will appear in ~3s)`);
760
848
 
761
- const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
849
+ await cleanup();
850
+ const { getLaunchArgs } = require("./src/cli/tray/autostart");
851
+ const bgArgs = [__filename, "start", ...getLaunchArgs({ port, host })];
852
+ const bgProcess = spawn(process.execPath, bgArgs, {
762
853
  detached: true,
763
854
  stdio: "ignore",
764
855
  windowsHide: true,
@@ -770,61 +861,61 @@ function startServer(latestVersion) {
770
861
  console.log(` Server: http://${displayHost}:${port}`);
771
862
  console.log(`\nšŸ’” You can close this terminal. Right-click tray icon to quit.\n`);
772
863
 
773
- // cleanup() kills server so bgProcess can claim the port fresh
774
- cleanup();
775
864
  process.exit(0);
776
865
  } else if (choice === "exit") {
777
- isShuttingDown = true;
778
- console.log("\nExiting...");
779
- cleanup();
780
- setTimeout(() => process.exit(0), 100);
866
+ await shutdown(0, "\nStopping Switchboard gracefully...");
781
867
  }
782
868
  }
783
869
  } catch (err) {
784
870
  console.error("Error:", err.message);
785
- cleanup();
786
- process.exit(1);
871
+ await shutdown(1);
787
872
  }
788
- }, 3000);
873
+ })();
789
874
 
790
875
  function attachServerEvents() {
791
- server.on("error", (err) => {
792
- console.error("Failed to start server:", err.message);
793
- if (err?.code === "EADDRINUSE") stopForPortConflict();
794
- else if (!isShuttingDown) tryRestart();
795
- else { cleanup(); process.exit(1); }
796
- });
797
-
798
- server.on("close", (code) => {
799
- if (isShuttingDown || code === 0) {
800
- process.exit(code || 0);
876
+ const observedServer = server;
877
+ let terminalEventHandled = false;
878
+ const handleTerminalEvent = (code, signal, error) => {
879
+ if (terminalEventHandled) return;
880
+ terminalEventHandled = true;
881
+ if (isShuttingDown) return;
882
+ if (error) console.error("Failed to start server:", error.message);
883
+ if (error?.code === "EADDRINUSE" || isPortConflict()) {
884
+ void stopForPortConflict();
801
885
  return;
802
886
  }
803
- if (isPortConflict()) {
804
- stopForPortConflict();
887
+ if (code === 0 && !signal) {
888
+ console.log("\nServer stopped cleanly; shutting down the launcher.");
889
+ void shutdown(0);
805
890
  return;
806
891
  }
807
- tryRestart(code);
892
+ tryRestart(code, signal);
893
+ };
894
+
895
+ observedServer.on("error", (err) => {
896
+ handleTerminalEvent(null, null, err);
808
897
  });
809
- }
810
898
 
811
- let mitmResetDone = false;
899
+ observedServer.on("close", (code, signal) => {
900
+ handleTerminalEvent(code, signal, null);
901
+ });
902
+ }
812
903
 
813
904
  function isPortConflict() {
814
905
  return crashLog.some((line) => /EADDRINUSE|address already in use|listen .*already in use/i.test(line));
815
906
  }
816
907
 
817
- function stopForPortConflict() {
908
+ async function stopForPortConflict() {
818
909
  if (isShuttingDown) return;
819
- isShuttingDown = true;
820
- console.error(`\nāŒ Port ${port} is already in use. Stop the other listener or choose a different port.`);
821
- cleanup();
822
- process.exit(1);
910
+ const listeners = findListeningPids(port).filter((pid) => pid !== server?.pid);
911
+ console.error(`\nPort ${port} is already in use${listeners.length ? ` (PID: ${listeners.join(", ")})` : ""}.`);
912
+ console.error(`Run "${commandFor("status")}" or choose a different port.`);
913
+ await shutdown(1);
823
914
  }
824
915
 
825
- function tryRestart(code) {
916
+ function tryRestart(code, signal) {
826
917
  if (isPortConflict()) {
827
- stopForPortConflict();
918
+ void stopForPortConflict();
828
919
  return;
829
920
  }
830
921
  const aliveMs = Date.now() - serverStartTime;
@@ -832,41 +923,31 @@ function startServer(latestVersion) {
832
923
  if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
833
924
 
834
925
  if (restartCount >= MAX_RESTARTS) {
835
- // Already tried MITM reset and still crashing — exit non-zero
836
- if (mitmResetDone) {
837
- console.error(`\nāŒ Server still crashing after MITM reset. Giving up.`);
838
- console.error(` Data directory: ${getAppDataDir()}`);
839
- if (crashLog.length) {
840
- console.error("\n--- Server crash log ---");
841
- crashLog.forEach(l => console.error(l));
842
- console.error("--- End crash log ---\n");
843
- }
844
- cleanup();
845
- process.exit(1);
846
- }
847
- console.error(`\nāš ļø Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`);
848
- if (!disableMitm()) {
849
- console.error("āŒ Could not disable MIT in the database — refusing to restart into the same crash.");
850
- console.error(` Data directory: ${getAppDataDir()}`);
851
- process.exit(1);
926
+ console.error(`\nServer crashed ${MAX_RESTARTS} times. Giving up without changing your settings.`);
927
+ console.error(`Data directory: ${getAppDataDir()}`);
928
+ console.error(`Retry with server logs: ${commandFor()} --log`);
929
+ if (crashLog.length) {
930
+ console.error("\n--- Server crash log ---");
931
+ crashLog.forEach(l => console.error(l));
932
+ console.error("--- End crash log ---\n");
852
933
  }
853
- mitmResetDone = true;
854
- restartCount = 0;
855
- server = spawnServer();
856
- attachServerEvents();
934
+ void shutdown(1);
857
935
  return;
858
936
  }
859
937
 
860
938
  restartCount++;
861
939
  const delay = Math.min(1000 * restartCount, 10000);
862
- console.error(`\nāš ļø Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
940
+ const reason = signal ? `signal=${signal}` : `code=${code ?? "unknown"}`;
941
+ console.error(`\nServer exited (${reason}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
863
942
  if (crashLog.length) {
864
943
  console.error("\n--- Server crash log ---");
865
944
  crashLog.forEach(l => console.error(l));
866
945
  console.error("--- End crash log ---\n");
867
946
  }
868
947
 
869
- setTimeout(() => {
948
+ restartTimer = setTimeout(() => {
949
+ restartTimer = null;
950
+ if (isShuttingDown) return;
870
951
  server = spawnServer();
871
952
  attachServerEvents();
872
953
  }, delay);