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.
Files changed (144) 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 +11 -11
  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 +3 -3
  43. package/app/.next-cli-build/server/app/_not-found.segments/_full.segment.rsc +3 -3
  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 +1 -1
  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 +3 -3
  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 +3 -3
  74. package/app/.next-cli-build/server/app/callback.segments/_full.segment.rsc +3 -3
  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 +3 -3
  83. package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_full.segment.rsc +3 -3
  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/index.html +1 -1
  92. package/app/.next-cli-build/server/app/index.rsc +3 -3
  93. package/app/.next-cli-build/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  94. package/app/.next-cli-build/server/app/index.segments/_full.segment.rsc +3 -3
  95. package/app/.next-cli-build/server/app/index.segments/_head.segment.rsc +1 -1
  96. package/app/.next-cli-build/server/app/index.segments/_index.segment.rsc +3 -3
  97. package/app/.next-cli-build/server/app/index.segments/_tree.segment.rsc +1 -1
  98. package/app/.next-cli-build/server/app/landing/page_client-reference-manifest.js +1 -1
  99. package/app/.next-cli-build/server/app/landing.html +1 -1
  100. package/app/.next-cli-build/server/app/landing.rsc +3 -3
  101. package/app/.next-cli-build/server/app/landing.segments/_full.segment.rsc +3 -3
  102. package/app/.next-cli-build/server/app/landing.segments/_head.segment.rsc +1 -1
  103. package/app/.next-cli-build/server/app/landing.segments/_index.segment.rsc +3 -3
  104. package/app/.next-cli-build/server/app/landing.segments/_tree.segment.rsc +1 -1
  105. package/app/.next-cli-build/server/app/landing.segments/landing/__PAGE__.segment.rsc +1 -1
  106. package/app/.next-cli-build/server/app/landing.segments/landing.segment.rsc +1 -1
  107. package/app/.next-cli-build/server/app/login/page_client-reference-manifest.js +1 -1
  108. package/app/.next-cli-build/server/app/login.html +1 -1
  109. package/app/.next-cli-build/server/app/login.rsc +3 -3
  110. package/app/.next-cli-build/server/app/login.segments/_full.segment.rsc +3 -3
  111. package/app/.next-cli-build/server/app/login.segments/_head.segment.rsc +1 -1
  112. package/app/.next-cli-build/server/app/login.segments/_index.segment.rsc +3 -3
  113. package/app/.next-cli-build/server/app/login.segments/_tree.segment.rsc +1 -1
  114. package/app/.next-cli-build/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  115. package/app/.next-cli-build/server/app/login.segments/login.segment.rsc +1 -1
  116. package/app/.next-cli-build/server/app/page_client-reference-manifest.js +1 -1
  117. package/app/.next-cli-build/server/app-paths-manifest.json +11 -11
  118. package/app/.next-cli-build/server/chunks/194.js +1 -1
  119. package/app/.next-cli-build/server/chunks/4126.js +1 -1
  120. package/app/.next-cli-build/server/chunks/4746.js +1 -1
  121. package/app/.next-cli-build/server/chunks/869.js +1 -1
  122. package/app/.next-cli-build/server/chunks/8998.js +1 -1
  123. package/app/.next-cli-build/server/middleware-build-manifest.js +1 -1
  124. package/app/.next-cli-build/server/middleware.js +3 -3
  125. package/app/.next-cli-build/server/pages/404.html +1 -1
  126. package/app/.next-cli-build/server/pages/500.html +1 -1
  127. package/app/.next-cli-build/server/server-reference-manifest.js +1 -1
  128. package/app/.next-cli-build/server/server-reference-manifest.json +1 -1
  129. package/app/.next-cli-build/static/chunks/{1321-c8ffcb14dcf776aa.js → 1321-5429c7d81eb38c6f.js} +1 -1
  130. package/app/package.json +1 -1
  131. package/app/src/lib/updater/updater.js +41 -12
  132. package/cli.js +401 -320
  133. package/package.json +1 -1
  134. package/src/cli/cliOptions.js +139 -0
  135. package/src/cli/menus/settings.js +2 -2
  136. package/src/cli/processManager.js +186 -0
  137. package/src/cli/serverStatus.js +90 -0
  138. package/src/cli/terminalUI.js +12 -11
  139. package/src/cli/tray/autostart.js +35 -14
  140. package/src/cli/tray/tray.js +34 -32
  141. package/src/cli/tray/trayWin.js +23 -9
  142. package/src/cli/utils/input.js +10 -1
  143. /package/app/.next-cli-build/static/{emM_vF1dDbzWCO6J4_WjU → 88rQB4DdrBDjq9aWvSEH4}/_buildManifest.js +0 -0
  144. /package/app/.next-cli-build/static/{emM_vF1dDbzWCO6J4_WjU → 88rQB4DdrBDjq9aWvSEH4}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard-router",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Switchboard CLI — local AI routing gateway (install: npm i -g switchboard-router; run: switchboard)",
5
5
  "bin": {
6
6
  "switchboard": "./cli.js"
@@ -0,0 +1,139 @@
1
+ const DEFAULT_PORT = 20128;
2
+ const DEFAULT_HOST = "127.0.0.1";
3
+ const COMMANDS = new Set(["start", "status", "stop", "restart", "help", "version"]);
4
+
5
+ function isLoopbackHost(value) {
6
+ const normalized = String(value).toLowerCase();
7
+ return normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(normalized);
8
+ }
9
+
10
+ function requireValue(argv, index, option) {
11
+ const value = argv[index + 1];
12
+ if (!value || value.startsWith("-")) {
13
+ throw new Error(`${option} requires a value`);
14
+ }
15
+ return value;
16
+ }
17
+
18
+ function parsePort(value) {
19
+ if (!/^\d+$/.test(String(value))) {
20
+ throw new Error("--port must be between 1 and 65535");
21
+ }
22
+ const parsed = Number(value);
23
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
24
+ throw new Error("--port must be between 1 and 65535");
25
+ }
26
+ return parsed;
27
+ }
28
+
29
+ function parseHost(value) {
30
+ const host = String(value).trim();
31
+ if (!host || /\s|\/|:\/\//.test(host)) {
32
+ throw new Error("--host must be a hostname or IP address");
33
+ }
34
+ return host;
35
+ }
36
+
37
+ function parseCliArgs(argv, defaults = {}) {
38
+ const args = [...argv];
39
+ let command = "start";
40
+ if (args[0] && !args[0].startsWith("-")) {
41
+ command = args.shift();
42
+ if (!COMMANDS.has(command)) throw new Error(`Unknown command: ${command}`);
43
+ }
44
+
45
+ const parsed = {
46
+ command,
47
+ port: defaults.port || DEFAULT_PORT,
48
+ host: defaults.host || DEFAULT_HOST,
49
+ showLog: false,
50
+ trayMode: false,
51
+ skipUpdate: false,
52
+ noBrowser: false,
53
+ json: false,
54
+ help: command === "help",
55
+ version: command === "version",
56
+ portProvided: false,
57
+ hostProvided: false,
58
+ };
59
+
60
+ for (let i = 0; i < args.length; i++) {
61
+ const arg = args[i];
62
+ if (arg === "--port" || arg === "-p") {
63
+ parsed.port = parsePort(requireValue(args, i, arg));
64
+ parsed.portProvided = true;
65
+ i++;
66
+ } else if (arg === "--host" || arg === "-H") {
67
+ parsed.host = parseHost(requireValue(args, i, arg));
68
+ parsed.hostProvided = true;
69
+ i++;
70
+ } else if (arg === "--no-browser" || arg === "-n") {
71
+ parsed.noBrowser = true;
72
+ } else if (arg === "--log" || arg === "-l") {
73
+ parsed.showLog = true;
74
+ } else if (arg === "--tray" || arg === "-t") {
75
+ parsed.trayMode = true;
76
+ } else if (arg === "--skip-update") {
77
+ parsed.skipUpdate = true;
78
+ } else if (arg === "--json") {
79
+ parsed.json = true;
80
+ } else if (arg === "--help" || arg === "-h") {
81
+ parsed.help = true;
82
+ } else if (arg === "--version" || arg === "-v") {
83
+ parsed.version = true;
84
+ } else {
85
+ throw new Error(`Unknown option: ${arg}`);
86
+ }
87
+ }
88
+
89
+ if (parsed.json && command !== "status") {
90
+ throw new Error("--json is only valid with the status command");
91
+ }
92
+ if (["status", "stop"].includes(command)) {
93
+ const invalid = [parsed.showLog && "--log", parsed.trayMode && "--tray", parsed.skipUpdate && "--skip-update", parsed.noBrowser && "--no-browser"].filter(Boolean);
94
+ if (invalid.length) throw new Error(`${invalid.join(", ")} is not valid with the ${command} command`);
95
+ }
96
+ return parsed;
97
+ }
98
+
99
+ function formatHelp({ version = "unknown", defaultPort = DEFAULT_PORT, defaultHost = DEFAULT_HOST } = {}) {
100
+ return `Switchboard ${version}
101
+
102
+ Usage:
103
+ switchboard [start] [options] Start Switchboard (default)
104
+ switchboard status [options] Show server, version, PID, and ownership status
105
+ switchboard stop [options] Stop the owned Switchboard instance gracefully
106
+ switchboard restart [options] Gracefully stop, then start Switchboard
107
+
108
+ Lifecycle:
109
+ In the terminal, press Ctrl+C to stop gracefully.
110
+ In tray mode, choose Quit. You can always use "switchboard stop" if the icon is unavailable.
111
+ A second start never kills an unrelated listener; use "switchboard status" to diagnose conflicts.
112
+
113
+ Options:
114
+ -p, --port <port> Server port (default: ${defaultPort})
115
+ -H, --host <host> Bind host (default: ${defaultHost})
116
+ -l, --log Stream server logs while retaining crash diagnostics
117
+ -t, --tray Run with the system-tray control surface
118
+ -n, --no-browser Compatibility flag; browsers open only when explicitly selected
119
+ --skip-update Skip the startup update check
120
+ --json Machine-readable output for "switchboard status"
121
+ -h, --help Show this help
122
+ -v, --version Show the version
123
+
124
+ Examples:
125
+ switchboard
126
+ switchboard --tray
127
+ switchboard status
128
+ switchboard restart --port ${defaultPort}
129
+ switchboard stop
130
+ `;
131
+ }
132
+
133
+ module.exports = {
134
+ DEFAULT_HOST,
135
+ DEFAULT_PORT,
136
+ formatHelp,
137
+ isLoopbackHost,
138
+ parseCliArgs,
139
+ };
@@ -18,14 +18,14 @@ const COLORS = {
18
18
  * there is no dashboard login, password or OIDC to manage here.
19
19
  * @param {Array<string>} breadcrumb - Breadcrumb path
20
20
  */
21
- async function showSettingsMenu(breadcrumb = []) {
21
+ async function showSettingsMenu(breadcrumb = [], { port = 20128, networkExposed = false } = {}) {
22
22
  await showMenuWithBack({
23
23
  title: "⚙️ Settings",
24
24
  breadcrumb,
25
25
  headerContent: async (data) => {
26
26
  const lines = [];
27
27
 
28
- lines.push(` Endpoint: http://localhost:20128/v1 ${COLORS.dim}(local only)${COLORS.reset}`);
28
+ lines.push(` Endpoint: http://localhost:${port}/v1 ${COLORS.dim}(${networkExposed ? "network-exposed" : "local only"})${COLORS.reset}`);
29
29
 
30
30
  // RTK section
31
31
  const rtkOn = data?.settings?.rtkEnabled !== false;
@@ -0,0 +1,186 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { execFileSync, execSync } = require("child_process");
4
+
5
+ function normalizePath(value) {
6
+ try { return path.resolve(value); } catch { return ""; }
7
+ }
8
+
9
+ function tokenizeCommand(command) {
10
+ const tokens = [];
11
+ let token = "";
12
+ let quote = "";
13
+ for (const char of String(command)) {
14
+ if (quote) {
15
+ if (char === quote) quote = "";
16
+ else token += char;
17
+ } else if (char === "\"" || char === "'") {
18
+ quote = char;
19
+ } else if (/\s/.test(char)) {
20
+ if (token) {
21
+ tokens.push(token);
22
+ token = "";
23
+ }
24
+ } else {
25
+ token += char;
26
+ }
27
+ }
28
+ if (token) tokens.push(token);
29
+ return tokens;
30
+ }
31
+
32
+ function matchesRecordedProcess({ command, cwd, expectedPath }) {
33
+ if (!expectedPath || !command) return false;
34
+ const expected = normalizePath(expectedPath);
35
+ const tokens = tokenizeCommand(command);
36
+ const scriptIndex = tokens.findIndex((token) => {
37
+ if (!token) return false;
38
+ if (path.isAbsolute(token)) return normalizePath(token) === expected;
39
+ return !!cwd && normalizePath(path.join(cwd, token)) === expected;
40
+ });
41
+ const runtimeName = path.basename(tokens[0] || "").toLowerCase();
42
+ const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe" || runtimeName === path.basename(process.execPath).toLowerCase();
43
+ if (isNodeRuntime && scriptIndex > 0 && tokens.slice(1, scriptIndex).every((token) => token.startsWith("-"))) {
44
+ return true;
45
+ }
46
+
47
+ const expectedName = path.basename(expectedPath);
48
+ const isBundledServer = expectedName === "server.js" || expectedName === "custom-server.js";
49
+ if (!isBundledServer || !/^next-server(?:\s|\(|$)/.test(command.trim())) return false;
50
+
51
+ return !!cwd && normalizePath(cwd) === normalizePath(path.dirname(expectedPath));
52
+ }
53
+
54
+ function getProcessCommand(pid) {
55
+ try {
56
+ if (process.platform === "win32") {
57
+ return execFileSync("powershell", [
58
+ "-NonInteractive", "-NoProfile", "-Command",
59
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${Number(pid)}').CommandLine`,
60
+ ], { encoding: "utf8", windowsHide: true, timeout: 3000 }).trim();
61
+ }
62
+ return execFileSync("ps", ["-p", String(Number(pid)), "-o", "command="], {
63
+ encoding: "utf8",
64
+ timeout: 3000,
65
+ }).trim();
66
+ } catch { return ""; }
67
+ }
68
+
69
+ function getProcessCwd(pid) {
70
+ try {
71
+ if (process.platform === "linux") return fs.readlinkSync(`/proc/${Number(pid)}/cwd`);
72
+ if (process.platform === "win32") return "";
73
+ const output = execFileSync("lsof", ["-a", "-p", String(Number(pid)), "-d", "cwd", "-Fn"], {
74
+ encoding: "utf8",
75
+ stdio: ["ignore", "pipe", "ignore"],
76
+ timeout: 3000,
77
+ });
78
+ const cwdLine = output.split(/\r?\n/).find((line) => line.startsWith("n"));
79
+ return cwdLine ? cwdLine.slice(1) : "";
80
+ } catch { return ""; }
81
+ }
82
+
83
+ function processMatchesRecordedPath(pid, expectedPath) {
84
+ const command = getProcessCommand(pid);
85
+ if (matchesRecordedProcess({ command, cwd: "", expectedPath })) return true;
86
+ return matchesRecordedProcess({ command, cwd: getProcessCwd(pid), expectedPath });
87
+ }
88
+
89
+ function findListeningPids(port) {
90
+ const numericPort = Number(port);
91
+ if (!Number.isInteger(numericPort) || numericPort < 1 || numericPort > 65535) return [];
92
+ try {
93
+ if (process.platform === "win32") {
94
+ const output = execFileSync("powershell", [
95
+ "-NonInteractive", "-NoProfile", "-Command",
96
+ `Get-NetTCPConnection -LocalPort ${numericPort} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`,
97
+ ], { encoding: "utf8", windowsHide: true, timeout: 5000 });
98
+ return output.split(/\s+/).map(Number).filter((pid) => Number.isInteger(pid) && pid > 1);
99
+ }
100
+ const output = execFileSync("lsof", [`-tiTCP:${numericPort}`, "-sTCP:LISTEN"], {
101
+ encoding: "utf8",
102
+ stdio: ["ignore", "pipe", "ignore"],
103
+ timeout: 5000,
104
+ });
105
+ return output.split(/\s+/).map(Number).filter((pid) => Number.isInteger(pid) && pid > 1);
106
+ } catch { return []; }
107
+ }
108
+
109
+ function isPidAlive(pid) {
110
+ try { process.kill(Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; }
111
+ }
112
+
113
+ function sleep(ms) {
114
+ return new Promise((resolve) => setTimeout(resolve, ms));
115
+ }
116
+
117
+ async function acquireLifecycleLock(dataDir, { instanceId, timeoutMs = 10000 } = {}) {
118
+ const lockDir = path.join(dataDir, "runtime", "lifecycle.lock");
119
+ const ownerFile = path.join(lockDir, "owner.json");
120
+ const deadline = Date.now() + timeoutMs;
121
+ fs.mkdirSync(path.dirname(lockDir), { recursive: true });
122
+
123
+ while (Date.now() < deadline) {
124
+ try {
125
+ fs.mkdirSync(lockDir);
126
+ fs.writeFileSync(ownerFile, JSON.stringify({ pid: process.pid, instanceId, createdAt: Date.now() }), { mode: 0o600 });
127
+ return () => {
128
+ try {
129
+ const owner = JSON.parse(fs.readFileSync(ownerFile, "utf8"));
130
+ if (owner.instanceId === instanceId) fs.rmSync(lockDir, { recursive: true, force: true });
131
+ } catch { /* lock already released */ }
132
+ };
133
+ } catch (error) {
134
+ if (error?.code !== "EEXIST") throw error;
135
+ let owner = null;
136
+ try { owner = JSON.parse(fs.readFileSync(ownerFile, "utf8")); } catch { /* incomplete or stale lock */ }
137
+ let ageMs = 0;
138
+ try { ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; } catch { /* retry */ }
139
+ const stale = owner?.pid ? !isPidAlive(owner.pid) : ageMs > 1000;
140
+ if (stale) {
141
+ try { fs.rmSync(lockDir, { recursive: true, force: true }); } catch { /* another waiter won */ }
142
+ continue;
143
+ }
144
+ await sleep(100);
145
+ }
146
+ }
147
+ throw new Error("Another Switchboard lifecycle operation is still in progress; retry shortly");
148
+ }
149
+
150
+ async function terminatePid(pid, { timeoutMs = 2000, processGroup = false } = {}) {
151
+ const numericPid = Number(pid);
152
+ if (!Number.isInteger(numericPid) || numericPid <= 1 || numericPid === process.pid) return false;
153
+ const unixTarget = processGroup && process.platform !== "win32" ? -numericPid : numericPid;
154
+ const targetIsAlive = () => isPidAlive(unixTarget);
155
+ try {
156
+ if (process.platform === "win32") {
157
+ execSync(`taskkill /T /PID ${numericPid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
158
+ } else {
159
+ process.kill(unixTarget, "SIGTERM");
160
+ }
161
+ } catch { /* already stopped or inaccessible */ }
162
+
163
+ const deadline = Date.now() + timeoutMs;
164
+ while (targetIsAlive() && Date.now() < deadline) await sleep(100);
165
+ if (!targetIsAlive()) return true;
166
+
167
+ try {
168
+ if (process.platform === "win32") {
169
+ execSync(`taskkill /F /T /PID ${numericPid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
170
+ } else {
171
+ process.kill(unixTarget, "SIGKILL");
172
+ }
173
+ } catch { /* best effort */ }
174
+ return !targetIsAlive();
175
+ }
176
+
177
+ module.exports = {
178
+ acquireLifecycleLock,
179
+ findListeningPids,
180
+ getProcessCommand,
181
+ getProcessCwd,
182
+ isPidAlive,
183
+ matchesRecordedProcess,
184
+ processMatchesRecordedPath,
185
+ terminatePid,
186
+ };
@@ -0,0 +1,90 @@
1
+ const http = require("http");
2
+ const net = require("net");
3
+ const MAX_RESPONSE_BYTES = 64 * 1024;
4
+
5
+ function requestJson({ hostname = "127.0.0.1", port, path, timeoutMs = 1000 }) {
6
+ return new Promise((resolve) => {
7
+ let settled = false;
8
+ const finish = (value) => {
9
+ if (settled) return;
10
+ settled = true;
11
+ resolve(value);
12
+ };
13
+ const req = http.request({
14
+ hostname,
15
+ port,
16
+ path,
17
+ method: "GET",
18
+ headers: { Accept: "application/json" },
19
+ }, (res) => {
20
+ let body = "";
21
+ res.setEncoding("utf8");
22
+ res.on("data", (chunk) => {
23
+ body += chunk;
24
+ if (Buffer.byteLength(body) > MAX_RESPONSE_BYTES) {
25
+ res.destroy(new Error("response too large"));
26
+ finish(null);
27
+ }
28
+ });
29
+ res.on("aborted", () => finish(null));
30
+ res.on("error", () => finish(null));
31
+ res.on("end", () => {
32
+ try {
33
+ finish({ statusCode: res.statusCode, body: JSON.parse(body) });
34
+ } catch {
35
+ finish(null);
36
+ }
37
+ });
38
+ });
39
+ req.setTimeout(timeoutMs, () => {
40
+ req.destroy();
41
+ finish(null);
42
+ });
43
+ req.on("error", () => finish(null));
44
+ req.end();
45
+ });
46
+ }
47
+
48
+ function probeTcp(port, timeoutMs = 1000, hostname = "127.0.0.1") {
49
+ return new Promise((resolve) => {
50
+ let settled = false;
51
+ const socket = net.createConnection({ host: hostname, port });
52
+ const finish = (ready) => {
53
+ if (settled) return;
54
+ settled = true;
55
+ socket.destroy();
56
+ resolve(ready);
57
+ };
58
+ socket.setTimeout(timeoutMs, () => finish(false));
59
+ socket.once("connect", () => finish(true));
60
+ socket.once("error", () => finish(false));
61
+ });
62
+ }
63
+
64
+ async function probeSwitchboard(port, timeoutMs = 1000, hostname = "127.0.0.1") {
65
+ const response = await requestJson({ hostname, port, path: "/api/mgmt/v1/version", timeoutMs });
66
+ const data = response?.body?.data;
67
+ if (response?.statusCode === 200 && data?.name === "switchboard-app") return data;
68
+
69
+ // Compatibility with older bundles that predate the management version API.
70
+ const health = await requestJson({ hostname, port, path: "/api/health", timeoutMs });
71
+ if (health?.statusCode === 200 && health?.body?.ok === true) {
72
+ return { name: "switchboard-app", version: null, startedAt: null, legacyHealth: true };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ async function waitForSwitchboard(port, { timeoutMs = 20000, intervalMs = 250, hostname = "127.0.0.1", acceptTcpFallback = null } = {}) {
78
+ const deadline = Date.now() + timeoutMs;
79
+ while (Date.now() < deadline) {
80
+ const status = await probeSwitchboard(port, Math.min(1000, intervalMs * 2), hostname);
81
+ if (status) return status;
82
+ if (acceptTcpFallback && await probeTcp(port, Math.min(1000, intervalMs * 2), hostname) && await acceptTcpFallback()) {
83
+ return { name: "switchboard-app", version: null, startedAt: null, tcpOnly: true };
84
+ }
85
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
86
+ }
87
+ return null;
88
+ }
89
+
90
+ module.exports = { probeSwitchboard, probeTcp, waitForSwitchboard };
@@ -18,9 +18,10 @@ const COLORS = {
18
18
  let cachedHeader = "";
19
19
  let fetchingHeader = false;
20
20
 
21
- function renderHeader(port, keys) {
21
+ function renderHeader(port, keys, { networkExposed = false } = {}) {
22
22
  const lines = [];
23
- lines.push(`Endpoint: http://localhost:${port}/v1 ${COLORS.dim}(local only)${COLORS.reset}`);
23
+ const exposure = networkExposed ? "network-exposed" : "local only";
24
+ lines.push(`Endpoint: http://localhost:${port}/v1 ${COLORS.dim}(${exposure})${COLORS.reset}`);
24
25
  if (!keys || keys.length === 0) {
25
26
  lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`);
26
27
  } else {
@@ -30,21 +31,21 @@ function renderHeader(port, keys) {
30
31
  return lines.join("\n");
31
32
  }
32
33
 
33
- async function refreshHeaderBg(port) {
34
+ async function refreshHeaderBg(port, networkExposed) {
34
35
  if (fetchingHeader) return;
35
36
  fetchingHeader = true;
36
37
  try {
37
38
  const keysResult = await api.getApiKeys();
38
39
  const keys = keysResult.success ? (keysResult.data.keys || []) : [];
39
- cachedHeader = renderHeader(port, keys);
40
+ cachedHeader = renderHeader(port, keys, { networkExposed });
40
41
  } finally {
41
42
  fetchingHeader = false;
42
43
  }
43
44
  }
44
45
 
45
- function getHeader(port) {
46
+ function getHeader(port, networkExposed) {
46
47
  // Kick off background refresh; return cache (or placeholder on first call).
47
- refreshHeaderBg(port);
48
+ refreshHeaderBg(port, networkExposed);
48
49
  return cachedHeader || `Endpoint: http://localhost:${port}/v1\nKey: ${COLORS.dim}...${COLORS.reset}`;
49
50
  }
50
51
 
@@ -52,20 +53,20 @@ function getHeader(port) {
52
53
  * Start Terminal UI
53
54
  * @param {number} port - Server port number
54
55
  */
55
- async function startTerminalUI(port) {
56
+ async function startTerminalUI(port, { networkExposed = false } = {}) {
56
57
  // Configure API client
57
58
  api.configure({ port });
58
59
 
59
60
  const basePath = ["Switchboard"];
60
61
 
61
62
  // Prime header cache before first render
62
- await refreshHeaderBg(port);
63
+ await refreshHeaderBg(port, networkExposed);
63
64
 
64
65
  // Main menu
65
66
  await showMenuWithBack({
66
67
  title: "📡 Switchboard Terminal UI",
67
68
  breadcrumb: basePath,
68
- headerContent: () => getHeader(port),
69
+ headerContent: () => getHeader(port, networkExposed),
69
70
  items: [
70
71
  {
71
72
  label: "Providers",
@@ -98,7 +99,7 @@ async function startTerminalUI(port) {
98
99
  {
99
100
  label: "Settings",
100
101
  action: async () => {
101
- await showSettingsMenu([...basePath, "Settings"]);
102
+ await showSettingsMenu([...basePath, "Settings"], { port, networkExposed });
102
103
  return true;
103
104
  }
104
105
  }
@@ -107,4 +108,4 @@ async function startTerminalUI(port) {
107
108
  });
108
109
  }
109
110
 
110
- module.exports = { startTerminalUI };
111
+ module.exports = { renderHeader, startTerminalUI };
@@ -44,16 +44,16 @@ function getCliJsPath(cliPath) {
44
44
  * @param {string} cliPath - Optional path to cli.js (defaults to auto-detect)
45
45
  * @returns {boolean} success
46
46
  */
47
- function enableAutoStart(cliPath) {
47
+ function enableAutoStart(cliPath, launchOptions = {}) {
48
48
  const platform = process.platform;
49
49
 
50
50
  if (!["darwin", "win32", "linux"].includes(platform)) return false;
51
51
  if (platform === "linux" && !process.env.DISPLAY) return false;
52
52
 
53
53
  try {
54
- if (platform === "darwin") return enableMacOS(cliPath);
55
- if (platform === "win32") return enableWindows(cliPath);
56
- if (platform === "linux") return enableLinux(cliPath);
54
+ if (platform === "darwin") return enableMacOS(cliPath, launchOptions);
55
+ if (platform === "win32") return enableWindows(cliPath, launchOptions);
56
+ if (platform === "linux") return enableLinux(cliPath, launchOptions);
57
57
  } catch (err) {
58
58
  // Silent fail — autostart is optional
59
59
  }
@@ -138,7 +138,19 @@ function isAgentSelfMacOS() {
138
138
  }
139
139
  }
140
140
 
141
- function enableMacOS(cliPath) {
141
+ function getLaunchArgs({ port = 20128, host = "127.0.0.1" } = {}) {
142
+ return ["--tray", "--skip-update", "--port", String(port), "--host", String(host)];
143
+ }
144
+
145
+ function xmlEscape(value) {
146
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
147
+ }
148
+
149
+ function quoteDesktopArg(value) {
150
+ return `"${String(value).replace(/(["\\$])/g, "\\$1")}"`;
151
+ }
152
+
153
+ function enableMacOS(cliPath, launchOptions) {
142
154
  const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
143
155
  const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`);
144
156
 
@@ -158,6 +170,10 @@ function enableMacOS(cliPath) {
158
170
  // EnvironmentVariables.PATH explicitly includes node's bin dir so child
159
171
  // processes spawned by cli.js (npm install at runtime, etc.) resolve.
160
172
  const launchPath = `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin`;
173
+ const launchArgs = getLaunchArgs(launchOptions);
174
+ const plistArgs = [nodePath, routerScript, ...launchArgs]
175
+ .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
176
+ .join("\n");
161
177
 
162
178
  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
163
179
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -167,10 +183,7 @@ function enableMacOS(cliPath) {
167
183
  <string>${APP_LABEL}</string>
168
184
  <key>ProgramArguments</key>
169
185
  <array>
170
- <string>${nodePath}</string>
171
- <string>${routerScript}</string>
172
- <string>--tray</string>
173
- <string>--skip-update</string>
186
+ ${plistArgs}
174
187
  </array>
175
188
  <key>EnvironmentVariables</key>
176
189
  <dict>
@@ -236,7 +249,7 @@ function disableMacOS() {
236
249
 
237
250
  // ============ Windows ============
238
251
 
239
- function enableWindows(cliPath) {
252
+ function enableWindows(cliPath, launchOptions) {
240
253
  const startupDir = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
241
254
  const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`);
242
255
 
@@ -245,11 +258,16 @@ function enableWindows(cliPath) {
245
258
  const nodePath = process.execPath;
246
259
  const routerScript = getCliJsPath(cliPath);
247
260
  if (!routerScript) return false;
261
+ const launchArgs = getLaunchArgs(launchOptions);
262
+ const vbsCommand = [nodePath, routerScript, ...launchArgs]
263
+ .map((arg) => `"${String(arg).replace(/"/g, '""')}"`)
264
+ .join(" ")
265
+ .replace(/"/g, '""');
248
266
 
249
267
  // Run node + cli.js directly, hidden window. Avoids the fragile
250
268
  // `switchboard.cmd` lookup that depended on the npm prefix path.
251
269
  const vbsContent = `Set WshShell = CreateObject("WScript.Shell")
252
- WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False
270
+ WshShell.Run "${vbsCommand}", 0, False
253
271
  `;
254
272
  fs.writeFileSync(vbsPath, vbsContent);
255
273
  return true;
@@ -265,7 +283,7 @@ function disableWindows() {
265
283
 
266
284
  // ============ Linux ============
267
285
 
268
- function enableLinux(cliPath) {
286
+ function enableLinux(cliPath, launchOptions) {
269
287
  const autostartDir = path.join(os.homedir(), ".config", "autostart");
270
288
  const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`);
271
289
 
@@ -277,12 +295,14 @@ function enableLinux(cliPath) {
277
295
  const nodePath = process.execPath;
278
296
  const routerScript = getCliJsPath(cliPath);
279
297
  if (!routerScript) return false;
298
+ const launchArgs = getLaunchArgs(launchOptions);
299
+ const execCommand = [nodePath, routerScript, ...launchArgs].map(quoteDesktopArg).join(" ");
280
300
 
281
301
  const desktopContent = `[Desktop Entry]
282
302
  Type=Application
283
303
  Name=Switchboard
284
304
  Comment=Switchboard API Proxy
285
- Exec=${nodePath} ${routerScript} --tray --skip-update
305
+ Exec=${execCommand}
286
306
  Hidden=false
287
307
  NoDisplay=false
288
308
  X-GNOME-Autostart-enabled=true
@@ -302,5 +322,6 @@ function disableLinux() {
302
322
  module.exports = {
303
323
  enableAutoStart,
304
324
  disableAutoStart,
305
- isAutoStartEnabled
325
+ isAutoStartEnabled,
326
+ getLaunchArgs,
306
327
  };