tmux-ide 1.3.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 (236) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +27 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/attach.ts +20 -0
  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.ts +248 -0
  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.ts +212 -0
  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.ts +76 -0
  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.ts +100 -0
  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.ts +36 -0
  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.ts +27 -0
  103. package/src/ls.test.ts +76 -0
  104. package/src/ls.ts +40 -0
  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/dist/attach.d.ts +0 -3
  194. package/dist/attach.js +0 -14
  195. package/dist/config.d.ts +0 -5
  196. package/dist/config.js +0 -269
  197. package/dist/detect.d.ts +0 -15
  198. package/dist/detect.js +0 -228
  199. package/dist/doctor.d.ts +0 -3
  200. package/dist/doctor.js +0 -66
  201. package/dist/init.d.ts +0 -4
  202. package/dist/init.js +0 -67
  203. package/dist/inspect.d.ts +0 -54
  204. package/dist/inspect.js +0 -118
  205. package/dist/launch.d.ts +0 -22
  206. package/dist/launch.js +0 -174
  207. package/dist/lib/dot-path.d.ts +0 -2
  208. package/dist/lib/dot-path.js +0 -17
  209. package/dist/lib/errors.d.ts +0 -29
  210. package/dist/lib/errors.js +0 -37
  211. package/dist/lib/launch-plan.d.ts +0 -6
  212. package/dist/lib/launch-plan.js +0 -40
  213. package/dist/lib/output.d.ts +0 -9
  214. package/dist/lib/output.js +0 -65
  215. package/dist/lib/session-monitor.d.ts +0 -12
  216. package/dist/lib/session-monitor.js +0 -160
  217. package/dist/lib/session-options.d.ts +0 -13
  218. package/dist/lib/session-options.js +0 -85
  219. package/dist/lib/sizes.d.ts +0 -16
  220. package/dist/lib/sizes.js +0 -36
  221. package/dist/lib/tmux.d.ts +0 -42
  222. package/dist/lib/tmux.js +0 -225
  223. package/dist/lib/yaml-io.d.ts +0 -10
  224. package/dist/lib/yaml-io.js +0 -24
  225. package/dist/ls.d.ts +0 -3
  226. package/dist/ls.js +0 -35
  227. package/dist/restart.d.ts +0 -4
  228. package/dist/restart.js +0 -13
  229. package/dist/status.d.ts +0 -3
  230. package/dist/status.js +0 -29
  231. package/dist/stop.d.ts +0 -3
  232. package/dist/stop.js +0 -21
  233. package/dist/types.d.ts +0 -41
  234. package/dist/types.js +0 -1
  235. package/dist/validate.d.ts +0 -4
  236. package/dist/validate.js +0 -172
@@ -0,0 +1,254 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+
3
+ import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { log as logger } from "../log.ts";
7
+ import type { TunnelService, TunnelStatus } from "./types.ts";
8
+
9
+ let spawnImpl: typeof nodeSpawn = nodeSpawn;
10
+
11
+ /** @internal Restore previous `spawn` with the returned function. */
12
+ export function _setSpawnForTesting(fn: typeof nodeSpawn): () => void {
13
+ const prev = spawnImpl;
14
+ spawnImpl = fn;
15
+ return () => {
16
+ spawnImpl = prev;
17
+ };
18
+ }
19
+
20
+ export interface CloudflareTunnel {
21
+ publicUrl: string;
22
+ proto: string;
23
+ name: string;
24
+ uri: string;
25
+ }
26
+
27
+ export class CloudflareService implements TunnelService {
28
+ private cloudflaredProcess: ChildProcess | null = null;
29
+ private currentTunnel: CloudflareTunnel | null = null;
30
+ private isRunning = false;
31
+ private cloudflaredPath: string | null = null;
32
+
33
+ constructor(
34
+ private port: number,
35
+ private options?: { startupTimeoutMs?: number },
36
+ ) {}
37
+
38
+ /**
39
+ * Standard paths to check for cloudflared binary
40
+ */
41
+ private static readonly cloudflaredPaths = [
42
+ "cloudflared", // Global PATH
43
+ "/usr/local/bin/cloudflared",
44
+ "/opt/homebrew/bin/cloudflared",
45
+ "/usr/bin/cloudflared",
46
+ path.join(os.homedir(), ".cloudflared", "cloudflared"),
47
+ // Windows paths
48
+ "C:\\Program Files\\Cloudflare\\cloudflared\\cloudflared.exe",
49
+ path.join(os.homedir(), "AppData", "Local", "cloudflared", "cloudflared.exe"),
50
+ ];
51
+
52
+ /**
53
+ * Check if cloudflared binary is available
54
+ */
55
+ private async checkCloudflaredBinary(): Promise<string | null> {
56
+ for (const cloudflaredPath of CloudflareService.cloudflaredPaths) {
57
+ try {
58
+ const result = await new Promise<boolean>((resolve) => {
59
+ const proc = spawnImpl(cloudflaredPath, ["--version"], { stdio: "ignore" });
60
+ proc.on("close", (code) => resolve(code === 0));
61
+ proc.on("error", () => resolve(false));
62
+ // Timeout after 2 seconds
63
+ setTimeout(() => {
64
+ proc.kill();
65
+ resolve(false);
66
+ }, 2000);
67
+ });
68
+ if (result) {
69
+ logger.debug(`Found cloudflared at: ${cloudflaredPath}`);
70
+ return cloudflaredPath;
71
+ }
72
+ } catch {
73
+ // Continue checking other paths
74
+ }
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Start Cloudflare tunnel (Quick Tunnel)
82
+ */
83
+ async start(): Promise<CloudflareTunnel> {
84
+ if (this.isRunning) {
85
+ logger.warn("Cloudflare tunnel is already running");
86
+ if (this.currentTunnel) {
87
+ return this.currentTunnel;
88
+ }
89
+ }
90
+
91
+ this.cloudflaredPath = await this.checkCloudflaredBinary();
92
+ if (!this.cloudflaredPath) {
93
+ throw new Error(
94
+ "cloudflared binary not found. Please install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/",
95
+ );
96
+ }
97
+
98
+ // Use Quick Tunnel (no auth required)
99
+ const args = ["tunnel", "--url", `http://localhost:${this.port}`];
100
+
101
+ logger.log(`Starting Cloudflare tunnel on port ${this.port}...`);
102
+
103
+ const cloudflaredPath = this.cloudflaredPath;
104
+ if (!cloudflaredPath) {
105
+ throw new Error(
106
+ "cloudflared binary not found. Please install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/",
107
+ );
108
+ }
109
+
110
+ return new Promise((resolve, reject) => {
111
+ this.cloudflaredProcess = spawnImpl(cloudflaredPath, args, {
112
+ stdio: ["ignore", "pipe", "pipe"],
113
+ });
114
+
115
+ let startupTimeout: NodeJS.Timeout;
116
+ let resolved = false;
117
+
118
+ const cleanup = () => {
119
+ if (startupTimeout) clearTimeout(startupTimeout);
120
+ };
121
+
122
+ const handleOutput = (data: Buffer) => {
123
+ const output = data.toString();
124
+ logger.debug("Cloudflare output:", output);
125
+
126
+ // Look for tunnel URL in output
127
+ // Cloudflare outputs something like: "https://random-words.trycloudflare.com"
128
+ const urlMatch = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
129
+ if (urlMatch && !resolved) {
130
+ resolved = true;
131
+ const publicUrl = urlMatch[0];
132
+ this.currentTunnel = {
133
+ publicUrl,
134
+ proto: "https",
135
+ name: "cloudflare-quick-tunnel",
136
+ uri: `http://localhost:${this.port}`,
137
+ };
138
+ this.isRunning = true;
139
+ cleanup();
140
+ logger.log(`Cloudflare tunnel started: ${publicUrl}`);
141
+ resolve(this.currentTunnel);
142
+ }
143
+
144
+ // Look for error messages
145
+ if (output.toLowerCase().includes("error") && !resolved) {
146
+ logger.error("Cloudflare error:", output);
147
+ }
148
+ };
149
+
150
+ this.cloudflaredProcess.stdout?.on("data", handleOutput);
151
+ this.cloudflaredProcess.stderr?.on("data", handleOutput);
152
+
153
+ this.cloudflaredProcess.on("error", (error) => {
154
+ if (!resolved) {
155
+ resolved = true;
156
+ this.isRunning = false;
157
+ cleanup();
158
+ reject(new Error(`Failed to start cloudflared: ${error.message}`));
159
+ }
160
+ });
161
+
162
+ this.cloudflaredProcess.on("close", (code) => {
163
+ this.isRunning = false;
164
+ this.currentTunnel = null;
165
+ if (code !== 0 && code !== null) {
166
+ logger.error(`Cloudflared process exited with code ${code}`);
167
+ }
168
+ });
169
+
170
+ // Timeout if tunnel doesn't start
171
+ startupTimeout = setTimeout(() => {
172
+ if (!resolved) {
173
+ resolved = true;
174
+ this.stop().catch(() => {});
175
+ reject(new Error("Cloudflare tunnel startup timeout - tunnel failed to start"));
176
+ }
177
+ }, this.options?.startupTimeoutMs ?? 30_000);
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Stop Cloudflare tunnel
183
+ */
184
+ async stop(): Promise<void> {
185
+ if (!this.cloudflaredProcess) {
186
+ return;
187
+ }
188
+
189
+ logger.log("Stopping Cloudflare tunnel...");
190
+
191
+ return new Promise((resolve) => {
192
+ if (!this.cloudflaredProcess) {
193
+ resolve();
194
+ return;
195
+ }
196
+
197
+ const killTimeout = setTimeout(() => {
198
+ if (this.cloudflaredProcess) {
199
+ logger.warn("Cloudflared process did not exit gracefully, forcing kill");
200
+ this.cloudflaredProcess.kill("SIGKILL");
201
+ }
202
+ resolve();
203
+ }, 5000);
204
+
205
+ this.cloudflaredProcess.on("close", () => {
206
+ clearTimeout(killTimeout);
207
+ this.cloudflaredProcess = null;
208
+ this.currentTunnel = null;
209
+ this.isRunning = false;
210
+ logger.log("Cloudflare tunnel stopped");
211
+ resolve();
212
+ });
213
+
214
+ this.cloudflaredProcess.kill("SIGTERM");
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Get current tunnel information
220
+ */
221
+ getTunnel(): CloudflareTunnel | null {
222
+ return this.currentTunnel;
223
+ }
224
+
225
+ /**
226
+ * Check if tunnel is running
227
+ */
228
+ isActive(): boolean {
229
+ return this.isRunning;
230
+ }
231
+
232
+ /**
233
+ * Get public URL if available
234
+ */
235
+ getPublicUrl(): string | null {
236
+ return this.currentTunnel?.publicUrl || null;
237
+ }
238
+
239
+ async status(): Promise<TunnelStatus> {
240
+ return {
241
+ running: this.isActive(),
242
+ publicUrl: this.getPublicUrl(),
243
+ port: this.port,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Check if cloudflared is installed
249
+ */
250
+ async checkInstallation(): Promise<boolean> {
251
+ this.cloudflaredPath = await this.checkCloudflaredBinary();
252
+ return this.cloudflaredPath !== null;
253
+ }
254
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { TunnelManager } from "./manager.ts";
3
+
4
+ describe("TunnelManager", () => {
5
+ let mgr: TunnelManager;
6
+
7
+ beforeEach(() => {
8
+ // Use a temp dir so event-log writes don't clutter
9
+ mgr = new TunnelManager({ dir: "/tmp/tunnel-test-" + Date.now() });
10
+ });
11
+
12
+ it("starts with status not running", async () => {
13
+ const status = await mgr.status();
14
+ expect(status.running).toBe(false);
15
+ expect(status.provider).toBeNull();
16
+ });
17
+
18
+ it("url() returns null when no tunnel running", async () => {
19
+ const url = await mgr.url();
20
+ expect(url).toBeNull();
21
+ });
22
+
23
+ it("stop() is a no-op when no tunnel running", async () => {
24
+ // Should not throw
25
+ await mgr.stop();
26
+ });
27
+
28
+ describe("provider resolution", () => {
29
+ it("constructs a TailscaleServeServiceImpl for tailscale provider", async () => {
30
+ // We can't actually start tailscale in tests, but we can verify the
31
+ // manager accepts the config and fails gracefully
32
+ try {
33
+ await mgr.start({ provider: "tailscale", port: 4000 });
34
+ } catch {
35
+ // Expected: tailscale binary not found in CI
36
+ }
37
+ });
38
+
39
+ it("constructs an NgrokService for ngrok provider", async () => {
40
+ try {
41
+ await mgr.start({
42
+ provider: "ngrok",
43
+ port: 4000,
44
+ startupTimeoutMs: 100, // fail fast
45
+ });
46
+ } catch {
47
+ // Expected: ngrok binary not found in CI
48
+ }
49
+ });
50
+
51
+ it("constructs a CloudflareService for cloudflare provider", async () => {
52
+ try {
53
+ await mgr.start({
54
+ provider: "cloudflare",
55
+ port: 4000,
56
+ startupTimeoutMs: 100,
57
+ });
58
+ } catch {
59
+ // Expected: cloudflared binary not found in CI
60
+ }
61
+ });
62
+ });
63
+ });
@@ -0,0 +1,132 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import { execFileSync } from "node:child_process";
3
+ import type { TunnelService, TunnelStatus, TunnelConfig } from "./types.ts";
4
+ import { TailscaleServeServiceImpl } from "./tailscale.ts";
5
+ import { NgrokService } from "./ngrok.ts";
6
+ import { CloudflareService } from "./cloudflare.ts";
7
+ import { appendEvent } from "../event-log.ts";
8
+
9
+ export class TunnelManager {
10
+ private service: TunnelService | null = null;
11
+ private provider: TunnelConfig["provider"] | null = null;
12
+ private session: string | null;
13
+ private dir: string;
14
+
15
+ constructor(opts: { session?: string; dir: string }) {
16
+ this.session = opts.session ?? null;
17
+ this.dir = opts.dir;
18
+ }
19
+
20
+ async start(config: TunnelConfig): Promise<TunnelStatus> {
21
+ if (this.service) {
22
+ const current = await this.service.status();
23
+ if (current.running) {
24
+ return current;
25
+ }
26
+ }
27
+
28
+ this.provider = config.provider;
29
+ this.service = this.createService(config);
30
+
31
+ try {
32
+ await this.service.start();
33
+ const status = await this.service.status();
34
+
35
+ if (status.publicUrl) {
36
+ this.setTmuxVar(status.publicUrl);
37
+ }
38
+
39
+ this.emitEvent("info", `Tunnel started via ${config.provider}`, status.publicUrl);
40
+ return status;
41
+ } catch (err) {
42
+ const msg = err instanceof Error ? err.message : String(err);
43
+ this.emitEvent("error", `Tunnel start failed: ${msg}`, null);
44
+ throw err;
45
+ }
46
+ }
47
+
48
+ async stop(): Promise<void> {
49
+ if (!this.service) return;
50
+
51
+ try {
52
+ await this.service.stop();
53
+ this.clearTmuxVar();
54
+ this.emitEvent("info", `Tunnel stopped (${this.provider})`, null);
55
+ } finally {
56
+ this.service = null;
57
+ this.provider = null;
58
+ }
59
+ }
60
+
61
+ async status(): Promise<TunnelStatus & { provider: string | null }> {
62
+ if (!this.service) {
63
+ return { running: false, provider: null };
64
+ }
65
+ const s = await this.service.status();
66
+ return { ...s, provider: this.provider };
67
+ }
68
+
69
+ async url(): Promise<string | null> {
70
+ if (!this.service) return null;
71
+ const s = await this.service.status();
72
+ return s.publicUrl ?? null;
73
+ }
74
+
75
+ private createService(config: TunnelConfig): TunnelService {
76
+ switch (config.provider) {
77
+ case "tailscale":
78
+ return new TailscaleServeServiceImpl();
79
+ case "ngrok":
80
+ return new NgrokService({
81
+ port: config.port,
82
+ authToken: config.authToken,
83
+ domain: config.domain,
84
+ region: config.region,
85
+ startupTimeoutMs: config.startupTimeoutMs,
86
+ });
87
+ case "cloudflare":
88
+ return new CloudflareService(config.port, {
89
+ startupTimeoutMs: config.startupTimeoutMs,
90
+ });
91
+ }
92
+ }
93
+
94
+ private setTmuxVar(url: string): void {
95
+ if (!this.session) return;
96
+ try {
97
+ execFileSync("tmux", ["set-option", "-t", this.session, "@tunnel_url", url], {
98
+ stdio: "ignore",
99
+ });
100
+ } catch {
101
+ // tmux not available or session gone
102
+ }
103
+ }
104
+
105
+ private clearTmuxVar(): void {
106
+ if (!this.session) return;
107
+ try {
108
+ execFileSync("tmux", ["set-option", "-t", this.session, "-u", "@tunnel_url"], {
109
+ stdio: "ignore",
110
+ });
111
+ } catch {
112
+ // tmux not available or session gone
113
+ }
114
+ }
115
+
116
+ private emitEvent(
117
+ level: "info" | "error",
118
+ message: string,
119
+ url: string | null | undefined,
120
+ ): void {
121
+ try {
122
+ appendEvent(this.dir, {
123
+ timestamp: new Date().toISOString(),
124
+ type: level === "error" ? "error" : ("notify" as const),
125
+ target: "tunnel",
126
+ message: url ? `${message} — ${url}` : message,
127
+ });
128
+ } catch {
129
+ // event log unavailable
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, afterEach } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+ import type { ChildProcess } from "node:child_process";
4
+ import { NgrokService, _setSpawnForTesting } from "./ngrok.ts";
5
+
6
+ function makeChild(hooks: {
7
+ onReady?: (stdout: EventEmitter, stderr: EventEmitter, proc: EventEmitter) => void;
8
+ }): ChildProcess {
9
+ const stdout = new EventEmitter();
10
+ const stderr = new EventEmitter();
11
+ const proc = new EventEmitter() as EventEmitter & Partial<ChildProcess>;
12
+ proc.stdout = stdout as NodeJS.ReadableStream;
13
+ proc.stderr = stderr as NodeJS.ReadableStream;
14
+ proc.pid = 9001;
15
+ proc.killed = false;
16
+ proc.kill = () => {
17
+ proc.killed = true;
18
+ queueMicrotask(() => proc.emit("close", 0));
19
+ return true;
20
+ };
21
+ queueMicrotask(() => hooks.onReady?.(stdout, stderr, proc));
22
+ return proc as ChildProcess;
23
+ }
24
+
25
+ describe("NgrokService", () => {
26
+ let restore: () => void = () => {};
27
+
28
+ afterEach(() => {
29
+ restore();
30
+ restore = () => {};
31
+ });
32
+
33
+ it("parses public URL from JSON log line on stdout", async () => {
34
+ const mockSpawn = (_cmd: string, args: string[]) => {
35
+ if (args[0] === "version") {
36
+ return makeChild({
37
+ onReady: (_out, _err, proc) => proc.emit("close", 0),
38
+ });
39
+ }
40
+ if (args[0] === "http") {
41
+ return makeChild({
42
+ onReady: (stdout) => {
43
+ const line = `${JSON.stringify({
44
+ msg: "started tunnel",
45
+ url: "https://abc-123.ngrok-free.app",
46
+ })}\n`;
47
+ stdout.emit("data", Buffer.from(line, "utf8"));
48
+ },
49
+ });
50
+ }
51
+ return makeChild({ onReady: (_o, _e, proc) => proc.emit("close", 0) });
52
+ };
53
+ restore = _setSpawnForTesting(mockSpawn as typeof import("node:child_process").spawn);
54
+
55
+ const svc = new NgrokService({ port: 5555, startupTimeoutMs: 5000 });
56
+ const tunnel = await svc.start();
57
+ expect(tunnel.publicUrl).toBe("https://abc-123.ngrok-free.app");
58
+ await svc.stop();
59
+ });
60
+
61
+ it("rejects when ngrok binary is not found", async () => {
62
+ const mockSpawn = (_cmd: string, args: string[]) => {
63
+ if (args[0] === "version") {
64
+ return makeChild({
65
+ onReady: (_o, _e, proc) => proc.emit("close", 1),
66
+ });
67
+ }
68
+ return makeChild({ onReady: (_o, _e, proc) => proc.emit("close", 0) });
69
+ };
70
+ restore = _setSpawnForTesting(mockSpawn as typeof import("node:child_process").spawn);
71
+
72
+ const svc = new NgrokService({ port: 5555 });
73
+ await expect(svc.start()).rejects.toThrow(/ngrok binary not found/);
74
+ });
75
+
76
+ it("rejects on startup timeout when no tunnel URL appears", async () => {
77
+ const mockSpawn = (cmd: string, args: string[]) => {
78
+ if (args[0] === "version") {
79
+ return makeChild({
80
+ onReady: (_o, _e, proc) => proc.emit("close", 0),
81
+ });
82
+ }
83
+ return makeChild({
84
+ onReady: () => {
85
+ /* no stdout */
86
+ },
87
+ });
88
+ };
89
+ restore = _setSpawnForTesting(mockSpawn as typeof import("node:child_process").spawn);
90
+
91
+ const svc = new NgrokService({ port: 5555, startupTimeoutMs: 40 });
92
+ await expect(svc.start()).rejects.toThrow(/Ngrok startup timeout/);
93
+ });
94
+ });