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.
- package/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +27 -14
- package/src/__tests__/support.ts +186 -0
- package/src/attach.ts +20 -0
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/detect.ts +248 -0
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/inspect.ts +212 -0
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/output.ts +76 -0
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/session-options.ts +100 -0
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/sizes.ts +36 -0
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/yaml-io.ts +27 -0
- package/src/ls.test.ts +76 -0
- package/src/ls.ts +40 -0
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/dist/attach.d.ts +0 -3
- package/dist/attach.js +0 -14
- package/dist/config.d.ts +0 -5
- package/dist/config.js +0 -269
- package/dist/detect.d.ts +0 -15
- package/dist/detect.js +0 -228
- package/dist/doctor.d.ts +0 -3
- package/dist/doctor.js +0 -66
- package/dist/init.d.ts +0 -4
- package/dist/init.js +0 -67
- package/dist/inspect.d.ts +0 -54
- package/dist/inspect.js +0 -118
- package/dist/launch.d.ts +0 -22
- package/dist/launch.js +0 -174
- package/dist/lib/dot-path.d.ts +0 -2
- package/dist/lib/dot-path.js +0 -17
- package/dist/lib/errors.d.ts +0 -29
- package/dist/lib/errors.js +0 -37
- package/dist/lib/launch-plan.d.ts +0 -6
- package/dist/lib/launch-plan.js +0 -40
- package/dist/lib/output.d.ts +0 -9
- package/dist/lib/output.js +0 -65
- package/dist/lib/session-monitor.d.ts +0 -12
- package/dist/lib/session-monitor.js +0 -160
- package/dist/lib/session-options.d.ts +0 -13
- package/dist/lib/session-options.js +0 -85
- package/dist/lib/sizes.d.ts +0 -16
- package/dist/lib/sizes.js +0 -36
- package/dist/lib/tmux.d.ts +0 -42
- package/dist/lib/tmux.js +0 -225
- package/dist/lib/yaml-io.d.ts +0 -10
- package/dist/lib/yaml-io.js +0 -24
- package/dist/ls.d.ts +0 -3
- package/dist/ls.js +0 -35
- package/dist/restart.d.ts +0 -4
- package/dist/restart.js +0 -13
- package/dist/status.d.ts +0 -3
- package/dist/status.js +0 -29
- package/dist/stop.d.ts +0 -3
- package/dist/stop.js +0 -21
- package/dist/types.d.ts +0 -41
- package/dist/types.js +0 -1
- package/dist/validate.d.ts +0 -4
- 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
|
+
});
|