switchboard-router 0.6.3 ā 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.
- package/README.md +40 -0
- package/app/.next-cli-build/BUILD_ID +1 -1
- package/app/.next-cli-build/app-path-routes-manifest.json +11 -11
- package/app/.next-cli-build/build-manifest.json +2 -2
- package/app/.next-cli-build/prerender-manifest.json +3 -3
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/agent-library/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/basic-chat/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/[toolId]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/combos/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/combos/routing/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/console-log/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/endpoint/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/combo/[id]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/web/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/mitm/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/profile/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/[id]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/new/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/quota/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/[id]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/token-saver/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/translator/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/usage/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/_global-error.html +1 -1
- package/app/.next-cli-build/server/app/_global-error.rsc +1 -1
- package/app/.next-cli-build/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/_not-found.html +1 -1
- package/app/.next-cli-build/server/app/_not-found.rsc +3 -3
- package/app/.next-cli-build/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_not-found.segments/_index.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/aider-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/all-statuses/route.js +4 -4
- package/app/.next-cli-build/server/app/api/cli-tools/antigravity-mitm/alias/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/claude-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/cline-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/codex-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-registry/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-tools/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/cowork-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/deepseek-tui-settings/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/droid-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/gemini-cli-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/grok-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/hermes-settings/route.js +3 -3
- package/app/.next-cli-build/server/app/api/cli-tools/jcode-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/kilo-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/openclaw-settings/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/opencode-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/pi-settings/route.js +1 -1
- package/app/.next-cli-build/server/app/api/version/route.js +1 -1
- package/app/.next-cli-build/server/app/api/version/shutdown/route.js +1 -1
- package/app/.next-cli-build/server/app/api/version/update/route.js +1 -1
- package/app/.next-cli-build/server/app/callback/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/callback.html +1 -1
- package/app/.next-cli-build/server/app/callback.rsc +3 -3
- package/app/.next-cli-build/server/app/callback.segments/_full.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/callback.segments/_head.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/callback.segments/_index.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/callback.segments/_tree.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/callback.segments/callback/__PAGE__.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/callback.segments/callback.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/pricing/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.rsc +3 -3
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_full.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_head.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_index.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_tree.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/dashboard/settings/pricing/__PAGE__.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/dashboard/settings/pricing.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/dashboard/settings.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/dashboard.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/index.html +1 -1
- package/app/.next-cli-build/server/app/index.rsc +3 -3
- package/app/.next-cli-build/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/index.segments/_full.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/index.segments/_head.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/index.segments/_index.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/index.segments/_tree.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/landing/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/landing.html +1 -1
- package/app/.next-cli-build/server/app/landing.rsc +3 -3
- package/app/.next-cli-build/server/app/landing.segments/_full.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/landing.segments/_head.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/landing.segments/_index.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/landing.segments/_tree.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/landing.segments/landing/__PAGE__.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/landing.segments/landing.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/login/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/login.html +1 -1
- package/app/.next-cli-build/server/app/login.rsc +3 -3
- package/app/.next-cli-build/server/app/login.segments/_full.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/login.segments/_head.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/login.segments/_index.segment.rsc +3 -3
- package/app/.next-cli-build/server/app/login.segments/_tree.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/login.segments/login.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app-paths-manifest.json +11 -11
- package/app/.next-cli-build/server/chunks/194.js +1 -1
- package/app/.next-cli-build/server/chunks/4126.js +1 -1
- package/app/.next-cli-build/server/chunks/4746.js +1 -1
- package/app/.next-cli-build/server/chunks/869.js +1 -1
- package/app/.next-cli-build/server/chunks/8998.js +1 -1
- package/app/.next-cli-build/server/middleware-build-manifest.js +1 -1
- package/app/.next-cli-build/server/middleware.js +3 -3
- package/app/.next-cli-build/server/pages/404.html +1 -1
- package/app/.next-cli-build/server/pages/500.html +1 -1
- package/app/.next-cli-build/server/server-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/server-reference-manifest.json +1 -1
- package/app/.next-cli-build/static/chunks/{1321-c8ffcb14dcf776aa.js ā 1321-5429c7d81eb38c6f.js} +1 -1
- package/app/package.json +1 -1
- package/app/src/lib/updater/updater.js +41 -12
- package/cli.js +401 -320
- package/package.json +1 -1
- package/src/cli/cliOptions.js +139 -0
- package/src/cli/menus/settings.js +2 -2
- package/src/cli/processManager.js +186 -0
- package/src/cli/serverStatus.js +90 -0
- package/src/cli/terminalUI.js +12 -11
- package/src/cli/tray/autostart.js +35 -14
- package/src/cli/tray/tray.js +34 -32
- package/src/cli/tray/trayWin.js +23 -9
- package/src/cli/utils/input.js +10 -1
- /package/app/.next-cli-build/static/{emM_vF1dDbzWCO6J4_WjU ā 88rQB4DdrBDjq9aWvSEH4}/_buildManifest.js +0 -0
- /package/app/.next-cli-build/static/{emM_vF1dDbzWCO6J4_WjU ā 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
|
-
|
|
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
|
-
|
|
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 (
|
|
137
|
+
if ([DEFAULT_HOST, "localhost", "::1", ALL_INTERFACES, "::"].includes(host)) return "localhost";
|
|
91
138
|
return host;
|
|
92
139
|
}
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
let
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
|
196
|
-
if (!expectedPath) return false;
|
|
214
|
+
function clearRecordedProcessState(state) {
|
|
197
215
|
try {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
571
|
-
if (
|
|
572
|
-
const
|
|
573
|
-
if (
|
|
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:
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
//
|
|
680
|
+
// One bounded, idempotent shutdown path owns every exit trigger.
|
|
610
681
|
let isCleaningUp = false;
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
|
|
645
|
-
process.on("
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
|
|
729
|
+
host,
|
|
730
|
+
onQuit: async () => {
|
|
673
731
|
console.log("\nš Shutting down from tray...");
|
|
674
|
-
|
|
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
|
-
|
|
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(`
|
|
757
|
+
console.log(`Starting server at http://${displayHost}:${port}...`);
|
|
692
758
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
786
|
-
process.exit(1);
|
|
871
|
+
await shutdown(1);
|
|
787
872
|
}
|
|
788
|
-
}
|
|
873
|
+
})();
|
|
789
874
|
|
|
790
875
|
function attachServerEvents() {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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 (
|
|
804
|
-
|
|
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
|
-
|
|
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
|
-
|
|
820
|
-
console.error(`\
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|