tmux-ide 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,1048 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+
3
+ import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
4
+ import { log as logger } from "../log.ts";
5
+ import type { TunnelService, TunnelStatus } from "./types.ts";
6
+
7
+ let spawnImpl: typeof nodeSpawn = nodeSpawn;
8
+
9
+ /** @internal Restore previous `spawn` with the returned function. */
10
+ export function _setSpawnForTesting(fn: typeof nodeSpawn): () => void {
11
+ const prev = spawnImpl;
12
+ spawnImpl = fn;
13
+ return () => {
14
+ spawnImpl = prev;
15
+ };
16
+ }
17
+
18
+ /** @internal When true, skip scanning known filesystem paths (unit tests use `which` + mocked spawn). */
19
+ let testSkipTailscaleFsPaths = false;
20
+
21
+ /** @internal */
22
+ export function _setTestSkipTailscaleFsPaths(v: boolean): void {
23
+ testSkipTailscaleFsPaths = v;
24
+ }
25
+
26
+ export interface TailscaleServeService {
27
+ start(port: number, enableFunnel?: boolean): Promise<void>;
28
+ stop(): Promise<void>;
29
+ isRunning(): boolean;
30
+ isFunnelEnabled(): boolean;
31
+ getStatus(): Promise<TailscaleServeStatus>;
32
+ status(): Promise<TunnelStatus>;
33
+ }
34
+
35
+ export interface TailscaleServeStatus {
36
+ isRunning: boolean;
37
+ port?: number;
38
+ error?: string;
39
+ lastError?: string;
40
+ startTime?: Date;
41
+ isPermanentlyDisabled?: boolean;
42
+ funnelEnabled?: boolean;
43
+ funnelStartTime?: Date;
44
+ desiredMode?: "private" | "public"; // What the user requested
45
+ actualMode?: "private" | "public"; // What's actually running
46
+ funnelError?: string; // Specific Funnel error if it failed
47
+ }
48
+
49
+ /**
50
+ * Service to manage Tailscale Serve as a background process
51
+ */
52
+ export class TailscaleServeServiceImpl implements TailscaleServeService, TunnelService {
53
+ private serveProcess: ChildProcess | null = null;
54
+ private currentPort: number | null = null;
55
+ private isStarting = false;
56
+ private tailscaleExecutable = "tailscale"; // Default to PATH lookup
57
+ private lastError: string | undefined;
58
+ private startTime: Date | undefined;
59
+ private isPermanentlyDisabled = false;
60
+ private funnelEnabled = false;
61
+ private funnelStartTime: Date | undefined;
62
+ private desiredFunnel = false; // Track what the user requested
63
+ private funnelError: string | undefined; // Track specific Funnel errors
64
+
65
+ async start(port: number, enableFunnel = false): Promise<void> {
66
+ logger.info(
67
+ `🚀 Starting Tailscale Serve on port ${port} ${enableFunnel ? "with Funnel (Public Internet)" : "(Tailnet only - Private)"}`,
68
+ );
69
+
70
+ if (this.isPermanentlyDisabled) {
71
+ logger.warn(`❌ Cannot start - permanently disabled on this tailnet`);
72
+ throw new Error("Tailscale Serve is permanently disabled on this tailnet");
73
+ }
74
+
75
+ if (this.isStarting) {
76
+ logger.warn(`⚠️ Already starting, rejecting duplicate request`);
77
+ throw new Error("Tailscale Serve is already starting");
78
+ }
79
+
80
+ if (this.serveProcess) {
81
+ logger.info("🔄 Serve already running, stopping first...");
82
+ await this.stop();
83
+ }
84
+
85
+ this.isStarting = true;
86
+ this.lastError = undefined; // Clear previous errors
87
+ this.funnelError = undefined; // Clear previous Funnel errors
88
+ this.currentPort = port; // Set the port even if start fails
89
+
90
+ // Store what the user requested
91
+ this.desiredFunnel = enableFunnel;
92
+ logger.info(
93
+ `🌍 ${enableFunnel ? "PUBLIC Internet access (Funnel)" : "PRIVATE Tailnet-only access (no Funnel)"} requested`,
94
+ );
95
+ this.funnelEnabled = false; // Reset initially, will enable after Serve starts
96
+
97
+ try {
98
+ // Check if tailscale command is available
99
+ await this.checkTailscaleAvailable();
100
+
101
+ // First, reset any existing serve configuration
102
+ try {
103
+ logger.debug("Resetting Tailscale Serve configuration...");
104
+ const resetProcess = spawnImpl(this.tailscaleExecutable, ["serve", "reset"], {
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ });
107
+
108
+ await new Promise<void>((resolve) => {
109
+ resetProcess.on("exit", () => resolve());
110
+ resetProcess.on("error", () => resolve()); // Continue even if reset fails
111
+ setTimeout(resolve, 1000); // Timeout after 1 second
112
+ });
113
+ } catch {
114
+ logger.debug("Failed to reset serve config (this is normal if none exists)");
115
+ }
116
+
117
+ // Set up HTTPS proxy to local port using new CLI syntax
118
+ // Format: tailscale serve --bg http://localhost:4020
119
+ const args = ["serve", "--bg", `http://localhost:${port}`];
120
+ logger.info(`🚀 Starting Tailscale Serve - HTTPS proxy to localhost:${port}`);
121
+ logger.debug(`🔧 Command: ${this.tailscaleExecutable} ${args.join(" ")}`);
122
+ this.currentPort = port;
123
+
124
+ // Start the serve process
125
+ this.serveProcess = spawnImpl(this.tailscaleExecutable, args, {
126
+ stdio: ["ignore", "pipe", "pipe"],
127
+ detached: false, // Keep it attached to our process
128
+ });
129
+
130
+ // Handle process events
131
+ this.serveProcess.on("error", (error) => {
132
+ logger.info(`Tailscale Serve process error: ${error.message}`);
133
+ this.lastError = error.message;
134
+ this.cleanup();
135
+ });
136
+
137
+ this.serveProcess.on("exit", (code, signal) => {
138
+ logger.info(`Tailscale Serve process exited with code ${code}, signal ${signal}`);
139
+ if (code !== 0) {
140
+ // Check if this is the common "Serve not enabled" error
141
+ if (this.lastError?.includes("Serve is not enabled on your tailnet")) {
142
+ // Keep the more user-friendly error message we set in stderr handler
143
+ logger.info("Tailscale Serve failed due to tailnet permissions");
144
+ } else {
145
+ this.lastError = `Process exited with code ${code}`;
146
+ }
147
+ }
148
+ this.cleanup();
149
+ });
150
+
151
+ // Log stdout/stderr
152
+ if (this.serveProcess.stdout) {
153
+ this.serveProcess.stdout.on("data", (data) => {
154
+ logger.debug(`Tailscale Serve stdout: ${data.toString().trim()}`);
155
+ });
156
+ }
157
+
158
+ if (this.serveProcess.stderr) {
159
+ this.serveProcess.stderr.on("data", (data) => {
160
+ const stderr = data.toString().trim();
161
+ logger.debug(`Tailscale Serve stderr: ${stderr}`);
162
+
163
+ // Handle specific "Serve not enabled on tailnet" error
164
+ if (stderr.includes("Serve is not enabled on your tailnet")) {
165
+ logger.warn(
166
+ "Tailscale Serve is not enabled on this tailnet - marking as permanently disabled",
167
+ );
168
+ this.lastError = "Tailscale Serve feature not enabled on your tailnet";
169
+ this.isPermanentlyDisabled = true;
170
+ return;
171
+ }
172
+
173
+ // Capture other common error patterns
174
+ if (stderr.includes("error") || stderr.includes("failed")) {
175
+ this.lastError = stderr;
176
+ }
177
+ });
178
+ }
179
+
180
+ // Wait a moment to see if it starts successfully
181
+ await new Promise<void>((resolve, reject) => {
182
+ let settled = false;
183
+
184
+ const settlePromise = (isSuccess: boolean, error?: Error | string) => {
185
+ if (settled) return;
186
+ settled = true;
187
+ clearTimeout(timeout);
188
+
189
+ if (isSuccess) {
190
+ logger.info("Tailscale Serve started successfully");
191
+ this.startTime = new Date();
192
+ resolve();
193
+ } else {
194
+ const errorMessage =
195
+ error instanceof Error ? error.message : error || "Tailscale Serve failed to start";
196
+ this.lastError = errorMessage;
197
+ reject(new Error(errorMessage));
198
+ }
199
+ };
200
+
201
+ const timeout = setTimeout(() => {
202
+ if (this.serveProcess && !this.serveProcess.killed) {
203
+ settlePromise(true);
204
+ } else {
205
+ settlePromise(false, this.lastError);
206
+ }
207
+ }, 3000); // Wait 3 seconds
208
+
209
+ if (this.serveProcess) {
210
+ this.serveProcess.once("error", (error) => {
211
+ settlePromise(false, error);
212
+ });
213
+
214
+ this.serveProcess.once("exit", (code) => {
215
+ // With --bg flag, the command exits immediately after configuration
216
+ // Exit code 0 means successful configuration
217
+ if (code === 0) {
218
+ logger.info("Tailscale Serve configured successfully (exit code 0)");
219
+ // The serve process is now running in background
220
+ this.serveProcess = null; // Clear reference as process has exited
221
+ // Give the configuration a moment to take effect
222
+ setTimeout(() => {
223
+ settlePromise(true); // SUCCESS - proxy is configured
224
+ }, 500);
225
+ } else {
226
+ settlePromise(false, `Tailscale Serve failed with exit code ${code}`);
227
+ }
228
+ });
229
+ }
230
+ });
231
+
232
+ // If Funnel is requested (Public Internet access), start Funnel after Serve
233
+ if (enableFunnel) {
234
+ logger.info(
235
+ `🌍 Starting Funnel for PUBLIC internet access on HTTPS port 443 (Serve to localhost:${port} completed successfully)`,
236
+ );
237
+ try {
238
+ await this.startFunnel(port);
239
+ logger.info(
240
+ `✅ Funnel started successfully - VibeTunnel now accessible via public internet`,
241
+ );
242
+ } catch (funnelError) {
243
+ const errorMsg = funnelError instanceof Error ? funnelError.message : String(funnelError);
244
+ this.funnelError = errorMsg;
245
+ logger.info(`❌ Failed to start Funnel: ${errorMsg}`);
246
+ logger.warn(
247
+ `Funnel failed to start, continuing with PRIVATE Tailnet-only access: ${errorMsg}`,
248
+ );
249
+ // Don't throw - Serve is still working for Tailnet access
250
+ }
251
+ } else {
252
+ logger.info(
253
+ `🔒 Running in PRIVATE mode - accessible only within your Tailnet, not from public internet`,
254
+ );
255
+ }
256
+ } catch (error) {
257
+ this.lastError = error instanceof Error ? error.message : String(error);
258
+ this.cleanup();
259
+ throw error;
260
+ } finally {
261
+ this.isStarting = false;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Start Tailscale Funnel for public internet access
267
+ */
268
+ private async startFunnel(port: number): Promise<void> {
269
+ // Funnel operates on the HTTPS port that Tailscale Serve uses (443), not the local port
270
+ const httpsPort = 443;
271
+ logger.info(
272
+ `🌍 Starting Tailscale Funnel on HTTPS port ${httpsPort} for public internet access (proxying to local port ${port})`,
273
+ );
274
+
275
+ // First, reset any existing Funnel configuration to avoid "foreground already exists" error
276
+ try {
277
+ logger.debug("Resetting Funnel configuration before starting...");
278
+ const resetProcess = spawnImpl(this.tailscaleExecutable, ["funnel", "reset"], {
279
+ stdio: ["ignore", "pipe", "pipe"],
280
+ });
281
+
282
+ await new Promise<void>((resolve) => {
283
+ resetProcess.on("exit", () => resolve());
284
+ resetProcess.on("error", () => resolve()); // Continue even if reset fails
285
+ setTimeout(resolve, 2000); // Timeout after 2 seconds
286
+ });
287
+ logger.debug("Funnel configuration reset completed");
288
+ } catch {
289
+ logger.debug("Failed to reset Funnel config (this is normal if none exists)");
290
+ }
291
+
292
+ logger.debug(
293
+ `🔧 Command: ${this.tailscaleExecutable} funnel --bg --https=${httpsPort} http://localhost:${port}`,
294
+ );
295
+
296
+ try {
297
+ // Enable Funnel with the correct proxy target
298
+ // Must specify the full target URL to avoid overriding the proxy destination
299
+ const funnelProcess = spawnImpl(
300
+ this.tailscaleExecutable,
301
+ ["funnel", "--bg", `--https=${httpsPort}`, `http://localhost:${port}`],
302
+ {
303
+ stdio: ["ignore", "pipe", "pipe"],
304
+ },
305
+ );
306
+
307
+ logger.debug(`📝 Process spawned with PID: ${funnelProcess.pid}`);
308
+
309
+ let stdout = "";
310
+ let stderr = "";
311
+
312
+ if (funnelProcess.stdout) {
313
+ funnelProcess.stdout.on("data", (data) => {
314
+ stdout += data.toString();
315
+ logger.debug(`📤 Funnel stdout: ${data.toString().trim()}`);
316
+ });
317
+ }
318
+
319
+ if (funnelProcess.stderr) {
320
+ funnelProcess.stderr.on("data", (data) => {
321
+ stderr += data.toString();
322
+ logger.debug(`📥 Funnel stderr: ${data.toString().trim()}`);
323
+ });
324
+ }
325
+
326
+ // Wait for funnel to start
327
+ await new Promise<void>((resolve, reject) => {
328
+ const timeout = setTimeout(() => {
329
+ reject(new Error("Funnel start timeout"));
330
+ }, 10000); // 10 second timeout
331
+
332
+ funnelProcess.on("exit", (code) => {
333
+ clearTimeout(timeout);
334
+ logger.debug(`🔚 Funnel process exited with code: ${code}`);
335
+ if (stderr.trim()) {
336
+ logger.debug(`📥 Funnel stderr: ${stderr.trim()}`);
337
+ }
338
+ if (stdout.trim()) {
339
+ logger.debug(`📤 Funnel stdout: ${stdout.trim()}`);
340
+ }
341
+
342
+ if (code === 0) {
343
+ logger.info("✅ Tailscale Funnel started successfully");
344
+ this.funnelEnabled = true;
345
+ this.funnelError = undefined; // Clear any previous Funnel errors
346
+ this.funnelStartTime = new Date();
347
+ resolve();
348
+ } else {
349
+ logger.info(`❌ Funnel failed with exit code ${code}: ${stderr || "No error message"}`);
350
+ reject(new Error(`Funnel failed with exit code ${code}: ${stderr}`));
351
+ }
352
+ });
353
+
354
+ funnelProcess.on("error", (error) => {
355
+ clearTimeout(timeout);
356
+ reject(error);
357
+ });
358
+ });
359
+ } catch (error) {
360
+ const errorMsg = error instanceof Error ? error.message : String(error);
361
+ logger.info(`Failed to start Tailscale Funnel: ${errorMsg}`);
362
+ throw new Error(`Funnel start failed: ${errorMsg}`, { cause: error });
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Stop Tailscale Funnel
368
+ */
369
+ private async stopFunnel(): Promise<void> {
370
+ if (!this.funnelEnabled) {
371
+ return;
372
+ }
373
+
374
+ try {
375
+ logger.info("Stopping Tailscale Funnel...");
376
+
377
+ // Reset funnel configuration
378
+ const resetProcess = spawnImpl(this.tailscaleExecutable, ["funnel", "reset"], {
379
+ stdio: ["ignore", "pipe", "pipe"],
380
+ });
381
+
382
+ await new Promise<void>((resolve) => {
383
+ resetProcess.on("exit", (code) => {
384
+ if (code === 0) {
385
+ logger.info("✅ Tailscale Funnel stopped successfully");
386
+ } else {
387
+ logger.warn(`Funnel reset exited with code ${code}`);
388
+ }
389
+ resolve();
390
+ });
391
+ resetProcess.on("error", () => resolve());
392
+ setTimeout(resolve, 3000); // Timeout after 3 seconds
393
+ });
394
+
395
+ this.funnelEnabled = false;
396
+ this.funnelStartTime = undefined;
397
+ } catch (error) {
398
+ logger.info(`Failed to stop Funnel: ${error}`);
399
+ throw error;
400
+ }
401
+ }
402
+
403
+ async stop(): Promise<void> {
404
+ // First, stop Funnel if it's enabled
405
+ if (this.funnelEnabled) {
406
+ try {
407
+ logger.info("🌍 Stopping Tailscale Funnel...");
408
+ await this.stopFunnel();
409
+ } catch (error) {
410
+ logger.warn(`Failed to stop Funnel: ${error}`);
411
+ }
412
+ }
413
+
414
+ // Reset Serve configuration (since we're using --bg, there's no process to kill)
415
+ try {
416
+ logger.debug("Removing Tailscale Serve configuration...");
417
+
418
+ // Use 'reset' to completely clear all serve configuration
419
+ const resetProcess = spawnImpl(this.tailscaleExecutable, ["serve", "reset"], {
420
+ stdio: ["ignore", "pipe", "pipe"],
421
+ });
422
+
423
+ await new Promise<void>((resolve) => {
424
+ resetProcess.on("exit", (code) => {
425
+ if (code === 0) {
426
+ logger.debug("Tailscale Serve configuration reset successfully");
427
+ }
428
+ resolve();
429
+ });
430
+ resetProcess.on("error", () => resolve());
431
+ setTimeout(resolve, 2000); // Timeout after 2 seconds
432
+ });
433
+ } catch {
434
+ logger.debug("Failed to reset serve config during stop");
435
+ }
436
+
437
+ if (!this.serveProcess) {
438
+ logger.debug("No Tailscale Serve process to stop");
439
+ return;
440
+ }
441
+
442
+ logger.info("Stopping Tailscale Serve process...");
443
+
444
+ return new Promise<void>((resolve) => {
445
+ if (!this.serveProcess) {
446
+ resolve();
447
+ return;
448
+ }
449
+
450
+ const cleanup = () => {
451
+ this.cleanup();
452
+ resolve();
453
+ };
454
+
455
+ // Set a timeout to force kill if graceful shutdown fails
456
+ const forceKillTimeout = setTimeout(() => {
457
+ if (this.serveProcess && !this.serveProcess.killed) {
458
+ logger.warn("Force killing Tailscale Serve process");
459
+ this.serveProcess.kill("SIGKILL");
460
+ }
461
+ cleanup();
462
+ }, 5000);
463
+
464
+ this.serveProcess.once("exit", () => {
465
+ clearTimeout(forceKillTimeout);
466
+ cleanup();
467
+ });
468
+
469
+ // Try graceful shutdown first
470
+ this.serveProcess.kill("SIGTERM");
471
+ });
472
+ }
473
+
474
+ isRunning(): boolean {
475
+ // With --bg flag, the serve process exits immediately after configuration
476
+ // We track running state based on whether we've started and not stopped
477
+ return this.currentPort !== null && !this.isStarting;
478
+ }
479
+
480
+ isFunnelEnabled(): boolean {
481
+ return this.funnelEnabled;
482
+ }
483
+
484
+ async getStatus(): Promise<TailscaleServeStatus> {
485
+ logger.debug("[TAILSCALE STATUS] Getting status", {
486
+ isPermanentlyDisabled: this.isPermanentlyDisabled,
487
+ lastError: this.lastError,
488
+ processActive: !!this.serveProcess,
489
+ currentPort: this.currentPort,
490
+ });
491
+
492
+ // Debug mode: simulate errors based on environment variable
493
+ if (process.env.VIBETUNNEL_TAILSCALE_ERROR) {
494
+ return {
495
+ isRunning: false,
496
+ lastError: process.env.VIBETUNNEL_TAILSCALE_ERROR,
497
+ };
498
+ }
499
+
500
+ // IMPROVED CHECK: First verify if Tailscale Serve is actually configured and working
501
+ // Only mark as permanently disabled if we can't detect any working configuration
502
+ if (!this.isPermanentlyDisabled) {
503
+ logger.debug("[TAILSCALE STATUS] Checking actual Tailscale Serve configuration");
504
+
505
+ const portToCheck = this.currentPort || 4020;
506
+
507
+ try {
508
+ // First, check if Serve is actually configured for our port (even if started manually)
509
+ const isConfigured = await this.verifyServeConfiguration(portToCheck);
510
+ logger.debug(
511
+ `[TAILSCALE STATUS] Serve configured for port ${portToCheck}: ${isConfigured}`,
512
+ );
513
+
514
+ if (isConfigured) {
515
+ // Serve is working! Don't mark as permanently disabled
516
+ logger.info(
517
+ `✅ [TAILSCALE STATUS] Tailscale Serve is configured for port ${portToCheck} - not permanently disabled`,
518
+ );
519
+ // Continue with normal status logic below
520
+ } else {
521
+ // No configuration found, check availability to determine if it's a permanent issue
522
+ const checkResult = await this.checkServeAvailability();
523
+ logger.debug(`[TAILSCALE STATUS] Serve availability check result: ${checkResult}`);
524
+
525
+ if (
526
+ checkResult.includes("Serve is not enabled") ||
527
+ checkResult.includes("not available") ||
528
+ checkResult.includes("requires admin") ||
529
+ checkResult.includes("unauthorized")
530
+ ) {
531
+ logger.debug(
532
+ "[TAILSCALE STATUS] Tailscale Serve not available on tailnet - marking as permanently disabled",
533
+ );
534
+ this.isPermanentlyDisabled = true;
535
+ this.lastError = "Serve is not enabled on your tailnet";
536
+
537
+ // Return success (fallback mode)
538
+ return {
539
+ isRunning: false,
540
+ port: undefined,
541
+ lastError: undefined, // No error in fallback mode
542
+ startTime: this.startTime,
543
+ isPermanentlyDisabled: true,
544
+ funnelEnabled: false,
545
+ funnelStartTime: undefined,
546
+ };
547
+ }
548
+ }
549
+ } catch (error) {
550
+ logger.debug(`[TAILSCALE STATUS] Failed to check configuration: ${error}`);
551
+ }
552
+ }
553
+
554
+ // If we're permanently disabled, return that status without error
555
+ // This is the expected fallback mode when admin permissions aren't available
556
+ if (this.isPermanentlyDisabled) {
557
+ logger.info("[TAILSCALE STATUS] Returning permanently disabled status (no error)");
558
+ return {
559
+ isRunning: false,
560
+ port: undefined,
561
+ // Don't report an error - fallback mode is working fine
562
+ lastError: undefined,
563
+ startTime: this.startTime,
564
+ isPermanentlyDisabled: true,
565
+ funnelEnabled: false,
566
+ funnelStartTime: undefined,
567
+ };
568
+ }
569
+
570
+ // Check if the serve process is running
571
+ const processRunning = this.isRunning();
572
+ logger.info(`[TAILSCALE STATUS] Process running: ${processRunning}`);
573
+
574
+ // If not running and we have a permanent error, we're in fallback mode
575
+ if (!processRunning && this.lastError?.includes("Serve is not enabled on your tailnet")) {
576
+ logger.info("[TAILSCALE STATUS] Detected permanent failure, switching to fallback mode");
577
+ // Mark as permanently disabled and return without error
578
+ this.isPermanentlyDisabled = true;
579
+ return {
580
+ isRunning: false,
581
+ port: undefined,
582
+ lastError: undefined, // Don't show error in fallback mode
583
+ startTime: this.startTime,
584
+ isPermanentlyDisabled: true,
585
+ funnelEnabled: false,
586
+ funnelStartTime: undefined,
587
+ };
588
+ }
589
+
590
+ // Always verify if Tailscale Serve is available, even if process isn't running
591
+ // This helps detect permanent failures when the process never starts
592
+ let actuallyRunning = processRunning;
593
+ let verificationError: string | undefined;
594
+ const portToCheck = this.currentPort || 4020; // Use default port if not set
595
+
596
+ if (!processRunning && !this.isPermanentlyDisabled) {
597
+ // Process isn't running - check if Tailscale Serve is configured (manual start)
598
+ logger.info(
599
+ `[TAILSCALE STATUS] Process not running, checking for manually configured Tailscale Serve`,
600
+ );
601
+ try {
602
+ const isConfigured = await this.verifyServeConfiguration(portToCheck);
603
+ logger.info(`[TAILSCALE STATUS] Manual configuration check: ${isConfigured}`);
604
+
605
+ if (isConfigured) {
606
+ // Great! Serve is configured manually, mark as running
607
+ actuallyRunning = true;
608
+ logger.info(
609
+ `✅ [TAILSCALE STATUS] Found manually configured Tailscale Serve for port ${portToCheck}`,
610
+ );
611
+ } else {
612
+ // No configuration found - this is a normal case, not an error
613
+ verificationError = "Tailscale Serve is starting up or needs reconfiguration";
614
+ logger.info(
615
+ `[TAILSCALE STATUS] No Serve configuration found for port ${portToCheck} - may need restart`,
616
+ );
617
+ }
618
+ } catch (error) {
619
+ logger.debug(`Failed to check Tailscale Serve configuration: ${error}`);
620
+ }
621
+ } else if (processRunning && this.currentPort) {
622
+ logger.info(`[TAILSCALE STATUS] Verifying configuration for port ${this.currentPort}`);
623
+ try {
624
+ const isConfigured = await this.verifyServeConfiguration(this.currentPort);
625
+ logger.info(`[TAILSCALE STATUS] Configuration verified: ${isConfigured}`);
626
+ if (!isConfigured) {
627
+ actuallyRunning = false;
628
+ // Process is running but not configured properly - this is a normal configuration error, not permanent
629
+ verificationError = "Tailscale Serve proxy not configured for this port";
630
+ logger.info(
631
+ "[TAILSCALE STATUS] Process running but not configured - normal configuration issue",
632
+ );
633
+ }
634
+ } catch (error) {
635
+ logger.debug(`Failed to verify Tailscale Serve configuration: ${error}`);
636
+ // Don't report verification errors as user-facing errors
637
+ actuallyRunning = false;
638
+ }
639
+ }
640
+
641
+ // Check if modes match - if they do, consider it running even if process check fails
642
+ // This handles the case where --bg makes the process exit immediately
643
+ const desiredMode = this.desiredFunnel ? "public" : "private";
644
+ const actualMode = this.funnelEnabled ? "public" : "private";
645
+ const modesMatch = desiredMode === actualMode;
646
+
647
+ // If desired and actual modes match, and we have a port configured, consider it running
648
+ // This is important because with --bg flag, the process exits immediately after configuration
649
+ // Also, if we're in public mode and Funnel has been enabled, trust that it's working
650
+ const effectivelyRunning =
651
+ actuallyRunning ||
652
+ (modesMatch && this.currentPort !== null && !this.isStarting) ||
653
+ (this.funnelEnabled && actualMode === "public");
654
+
655
+ const result: TailscaleServeStatus = {
656
+ isRunning: effectivelyRunning,
657
+ port: effectivelyRunning ? (this.currentPort ?? undefined) : undefined,
658
+ // Don't show error if modes match and we're configured
659
+ lastError: effectivelyRunning || modesMatch ? undefined : verificationError || this.lastError,
660
+ startTime: this.startTime,
661
+ isPermanentlyDisabled: this.isPermanentlyDisabled,
662
+ funnelEnabled: this.funnelEnabled,
663
+ funnelStartTime: this.funnelStartTime,
664
+ desiredMode: desiredMode,
665
+ actualMode: actualMode,
666
+ funnelError: this.funnelError,
667
+ };
668
+
669
+ logger.info("[TAILSCALE STATUS] Returning status:");
670
+ logger.info(` - isRunning: ${result.isRunning}`);
671
+ logger.info(` - lastError: ${result.lastError}`);
672
+ logger.info(` - isPermanentlyDisabled: ${result.isPermanentlyDisabled}`);
673
+ logger.info(` - desiredMode: ${result.desiredMode}`);
674
+ logger.info(` - actualMode: ${result.actualMode}`);
675
+ logger.info(` - funnelError: ${result.funnelError}`);
676
+
677
+ return result;
678
+ }
679
+
680
+ async status(): Promise<TunnelStatus> {
681
+ const s = await this.getStatus();
682
+ return {
683
+ running: s.isRunning,
684
+ port: s.port,
685
+ lastError: s.lastError,
686
+ meta: {
687
+ isPermanentlyDisabled: s.isPermanentlyDisabled,
688
+ funnelEnabled: s.funnelEnabled,
689
+ funnelStartTime: s.funnelStartTime,
690
+ desiredMode: s.desiredMode,
691
+ actualMode: s.actualMode,
692
+ funnelError: s.funnelError,
693
+ },
694
+ };
695
+ }
696
+
697
+ /**
698
+ * Check if Tailscale Serve is available on this tailnet
699
+ */
700
+ private async checkServeAvailability(): Promise<string> {
701
+ return new Promise<string>((resolve) => {
702
+ // Try JSON first, fallback to regular status if needed
703
+ const statusProcess = spawnImpl(this.tailscaleExecutable, ["serve", "status", "--json"], {
704
+ stdio: ["ignore", "pipe", "pipe"],
705
+ });
706
+
707
+ let stdout = "";
708
+ let stderr = "";
709
+
710
+ if (statusProcess.stdout) {
711
+ statusProcess.stdout.on("data", (data) => {
712
+ stdout += data.toString();
713
+ });
714
+ }
715
+
716
+ if (statusProcess.stderr) {
717
+ statusProcess.stderr.on("data", (data) => {
718
+ stderr += data.toString();
719
+ });
720
+ }
721
+
722
+ statusProcess.on("exit", (code) => {
723
+ // Return stderr if it contains error messages
724
+ if (stderr) {
725
+ resolve(stderr);
726
+ } else if (code === 0) {
727
+ // Success - check if we have valid JSON or just return stdout
728
+ try {
729
+ JSON.parse(stdout);
730
+ // If we have valid JSON, we can serve
731
+ resolve(stdout);
732
+ } catch {
733
+ // Not valid JSON, return as-is
734
+ resolve(stdout);
735
+ }
736
+ } else {
737
+ resolve(stdout || "Tailscale Serve status check failed");
738
+ }
739
+ });
740
+
741
+ statusProcess.on("error", (error) => {
742
+ resolve(error.message);
743
+ });
744
+
745
+ // Timeout after 3 seconds
746
+ setTimeout(() => {
747
+ if (!statusProcess.killed) {
748
+ statusProcess.kill("SIGTERM");
749
+ resolve("Timeout checking Tailscale Serve availability");
750
+ }
751
+ }, 3000);
752
+ });
753
+ }
754
+
755
+ /**
756
+ * Verify that Tailscale Serve is actually configured for the given port
757
+ */
758
+ private async verifyServeConfiguration(port: number): Promise<boolean> {
759
+ return new Promise<boolean>((resolve) => {
760
+ // Use --json flag for reliable parsing
761
+ const statusProcess = spawnImpl(this.tailscaleExecutable, ["serve", "status", "--json"], {
762
+ stdio: ["ignore", "pipe", "pipe"],
763
+ });
764
+
765
+ let stdout = "";
766
+ let stderr = "";
767
+
768
+ if (statusProcess.stdout) {
769
+ statusProcess.stdout.on("data", (data) => {
770
+ stdout += data.toString();
771
+ });
772
+ }
773
+
774
+ if (statusProcess.stderr) {
775
+ statusProcess.stderr.on("data", (data) => {
776
+ stderr += data.toString();
777
+ });
778
+ }
779
+
780
+ statusProcess.on("exit", (code) => {
781
+ if (code === 0) {
782
+ // First try JSON parsing, fallback to text parsing if needed
783
+ try {
784
+ const status = JSON.parse(stdout);
785
+ const isConfigured = this.parseServeStatusJson(status, port);
786
+ logger.debug(
787
+ `Tailscale Serve JSON status check: port ${port} configured = ${isConfigured}`,
788
+ );
789
+ resolve(isConfigured);
790
+ } catch {
791
+ logger.debug("JSON parsing failed, trying text parsing as fallback");
792
+ // Fallback to text parsing if JSON fails
793
+ const isConfigured = this.parseServeStatus(stdout, port);
794
+ logger.debug(
795
+ `Tailscale Serve text status check: port ${port} configured = ${isConfigured}`,
796
+ );
797
+ resolve(isConfigured);
798
+ }
799
+ } else {
800
+ logger.debug(`Tailscale serve status failed with code ${code}: ${stderr}`);
801
+ resolve(false);
802
+ }
803
+ });
804
+
805
+ statusProcess.on("error", (error) => {
806
+ logger.debug(`Failed to run tailscale serve status: ${error.message}`);
807
+ resolve(false);
808
+ });
809
+
810
+ // Timeout after 3 seconds
811
+ setTimeout(() => {
812
+ if (!statusProcess.killed) {
813
+ statusProcess.kill("SIGTERM");
814
+ resolve(false);
815
+ }
816
+ }, 3000);
817
+ });
818
+ }
819
+
820
+ /**
821
+ * Parse JSON output from 'tailscale serve status --json' to check if our port is configured
822
+ */
823
+ private parseServeStatusJson(status: unknown, port: number): boolean {
824
+ const statusData = status as {
825
+ Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }>;
826
+ Foreground?: Record<
827
+ string,
828
+ {
829
+ Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }>;
830
+ AllowFunnel?: Record<string, boolean>;
831
+ }
832
+ >;
833
+ AllowFunnel?: Record<string, boolean>;
834
+ };
835
+
836
+ try {
837
+ logger.debug(`Parsing Tailscale serve JSON status for port ${port}:`);
838
+ logger.debug(`JSON status: ${JSON.stringify(status, null, 2)}`);
839
+
840
+ // Check both direct Web config and Foreground config
841
+ const webConfigs = [];
842
+
843
+ // Direct Web config (older format)
844
+ if (statusData.Web) {
845
+ webConfigs.push(statusData.Web);
846
+ }
847
+
848
+ // Foreground config (newer format)
849
+ if (statusData.Foreground) {
850
+ for (const nodeId in statusData.Foreground) {
851
+ const nodeConfig = statusData.Foreground[nodeId];
852
+ if (nodeConfig?.Web) {
853
+ webConfigs.push(nodeConfig.Web);
854
+ }
855
+ }
856
+ }
857
+
858
+ // Check all Web configurations found
859
+ for (const webConfig of webConfigs) {
860
+ for (const host in webConfig) {
861
+ const handlers = webConfig[host]?.Handlers;
862
+ if (handlers) {
863
+ logger.debug(`Checking handlers for host: ${host}`);
864
+ for (const path in handlers) {
865
+ const proxy = handlers[path]?.Proxy;
866
+ if (proxy) {
867
+ logger.debug(`Found proxy config: ${path} -> ${proxy}`);
868
+
869
+ // Check if this proxy points to our port
870
+ if (
871
+ proxy.includes(`:${port}`) ||
872
+ proxy.includes(`127.0.0.1:${port}`) ||
873
+ proxy.includes(`localhost:${port}`)
874
+ ) {
875
+ logger.info(`✅ Found Tailscale Serve config for port ${port}: ${proxy}`);
876
+
877
+ // Check for Funnel status in multiple possible locations
878
+ let funnelEnabled = false;
879
+ if (statusData.AllowFunnel?.[host]) {
880
+ funnelEnabled = true;
881
+ logger.info(`🌍 Funnel is enabled for ${host}`);
882
+ } else if (statusData.Foreground) {
883
+ // Check if any foreground config allows Funnel
884
+ for (const nodeId in statusData.Foreground) {
885
+ const nodeConfig = statusData.Foreground[nodeId];
886
+ if (nodeConfig?.AllowFunnel?.[host]) {
887
+ funnelEnabled = true;
888
+ logger.info(`🌍 Funnel is enabled for ${host} (node ${nodeId})`);
889
+ break;
890
+ }
891
+ }
892
+ }
893
+
894
+ // Update our internal Funnel status based on what we found
895
+ this.funnelEnabled = funnelEnabled;
896
+ if (funnelEnabled && !this.funnelStartTime) {
897
+ this.funnelStartTime = new Date();
898
+ }
899
+
900
+ return true;
901
+ }
902
+ }
903
+ }
904
+ }
905
+ }
906
+ }
907
+
908
+ logger.warn(`❌ No proxy configuration found for port ${port} in JSON status`);
909
+ logger.debug(`Available proxy configurations:`);
910
+ for (const webConfig of webConfigs) {
911
+ for (const host in webConfig) {
912
+ const handlers = webConfig[host]?.Handlers;
913
+ if (handlers) {
914
+ for (const path in handlers) {
915
+ const proxy = handlers[path]?.Proxy;
916
+ if (proxy) {
917
+ logger.debug(` - ${host}${path} -> ${proxy}`);
918
+ }
919
+ }
920
+ }
921
+ }
922
+ }
923
+
924
+ return false;
925
+ } catch (error) {
926
+ logger.info("Failed to parse JSON status:", error);
927
+ return false;
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Parse the output of 'tailscale serve status' to check if our port is configured
933
+ */
934
+ private parseServeStatus(output: string, port: number): boolean {
935
+ logger.debug(`Parsing Tailscale serve status output for port ${port}:`);
936
+ logger.debug(`Raw output: ${JSON.stringify(output)}`);
937
+
938
+ // Look for lines containing our port number
939
+ const lines = output.split("\n");
940
+ logger.debug(`Split into ${lines.length} lines`);
941
+
942
+ for (const line of lines) {
943
+ const trimmedLine = line.trim();
944
+ if (trimmedLine) {
945
+ logger.debug(`Checking line: "${trimmedLine}"`);
946
+ }
947
+
948
+ // Common patterns in Tailscale serve output:
949
+ // "https://hostname:443 proxy http://127.0.0.1:4020"
950
+ // "http://hostname:80 proxy http://127.0.0.1:4020"
951
+ if (line.includes(`127.0.0.1:${port}`) || line.includes(`localhost:${port}`)) {
952
+ logger.info(`Found proxy configuration for port ${port} in line: "${line.trim()}"`);
953
+ return true;
954
+ }
955
+ }
956
+
957
+ logger.warn(`No proxy configuration found for port ${port} in Tailscale serve status`);
958
+ return false;
959
+ }
960
+
961
+ private cleanup(): void {
962
+ // Kill the process if it's still running
963
+ if (this.serveProcess && !this.serveProcess.killed) {
964
+ logger.debug("Terminating orphaned Tailscale Serve process");
965
+ try {
966
+ this.serveProcess.kill("SIGTERM");
967
+ // Give it a moment to terminate gracefully
968
+ setTimeout(() => {
969
+ if (this.serveProcess && !this.serveProcess.killed) {
970
+ logger.warn("Force killing Tailscale Serve process");
971
+ this.serveProcess.kill("SIGKILL");
972
+ }
973
+ }, 1000);
974
+ } catch (error) {
975
+ logger.info("Failed to kill Tailscale Serve process:", error);
976
+ }
977
+ }
978
+
979
+ this.serveProcess = null;
980
+ this.currentPort = null;
981
+ this.isStarting = false;
982
+ this.startTime = undefined;
983
+ this.funnelEnabled = false;
984
+ this.funnelStartTime = undefined;
985
+ // Keep lastError for debugging
986
+ }
987
+
988
+ private async checkTailscaleAvailable(): Promise<void> {
989
+ const fs = await import("node:fs/promises");
990
+
991
+ // Platform-specific paths to check
992
+ let tailscalePaths: string[] = [];
993
+
994
+ if (process.platform === "darwin") {
995
+ // macOS paths
996
+ tailscalePaths = [
997
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
998
+ "/usr/local/bin/tailscale",
999
+ "/opt/homebrew/bin/tailscale",
1000
+ ];
1001
+ } else if (process.platform === "linux") {
1002
+ // Linux paths
1003
+ tailscalePaths = [
1004
+ "/usr/bin/tailscale",
1005
+ "/usr/local/bin/tailscale",
1006
+ "/opt/tailscale/bin/tailscale",
1007
+ "/snap/bin/tailscale",
1008
+ ];
1009
+ }
1010
+
1011
+ // Check platform-specific paths first
1012
+ if (!testSkipTailscaleFsPaths) {
1013
+ for (const path of tailscalePaths) {
1014
+ try {
1015
+ await fs.access(path, fs.constants.X_OK);
1016
+ this.tailscaleExecutable = path;
1017
+ logger.debug(`Found Tailscale at: ${path}`);
1018
+ return;
1019
+ } catch {
1020
+ // Continue checking other paths
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ // Fallback to checking PATH
1026
+ return new Promise<void>((resolve, reject) => {
1027
+ const checkProcess = spawnImpl("which", ["tailscale"], {
1028
+ stdio: ["ignore", "pipe", "pipe"],
1029
+ });
1030
+
1031
+ checkProcess.on("exit", (code) => {
1032
+ if (code === 0) {
1033
+ // Keep default 'tailscale' which will use PATH
1034
+ resolve();
1035
+ } else {
1036
+ reject(new Error("Tailscale command not found. Please install Tailscale first."));
1037
+ }
1038
+ });
1039
+
1040
+ checkProcess.on("error", (error) => {
1041
+ reject(new Error(`Failed to check Tailscale availability: ${error.message}`));
1042
+ });
1043
+ });
1044
+ }
1045
+ }
1046
+
1047
+ // Singleton instance
1048
+ export const tailscaleServeService = new TailscaleServeServiceImpl();