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.
- package/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- 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.js → detect.ts} +31 -15
- 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.js → inspect.ts} +95 -10
- 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.js → output.ts} +18 -10
- 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.js → session-options.ts} +11 -6
- 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.js → sizes.ts} +4 -4
- 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.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- 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/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
3
|
+
import { HQClient } from "./client.ts";
|
|
4
|
+
|
|
5
|
+
// Capture fetch calls
|
|
6
|
+
let fetchCalls: { url: string; init: RequestInit }[] = [];
|
|
7
|
+
let fetchResponses: Array<{ ok: boolean; status: number; text: string }> = [];
|
|
8
|
+
|
|
9
|
+
const originalFetch = globalThis.fetch;
|
|
10
|
+
|
|
11
|
+
function mockFetch(url: string | URL | Request, init?: RequestInit): Promise<Response> {
|
|
12
|
+
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
|
|
13
|
+
fetchCalls.push({ url: urlStr, init: init ?? {} });
|
|
14
|
+
const next = fetchResponses.shift() ?? { ok: true, status: 200, text: "{}" };
|
|
15
|
+
return Promise.resolve(
|
|
16
|
+
new Response(next.text, {
|
|
17
|
+
status: next.status,
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("HQClient", () => {
|
|
24
|
+
let client: HQClient;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
fetchCalls = [];
|
|
28
|
+
fetchResponses = [];
|
|
29
|
+
globalThis.fetch = mockFetch as typeof fetch;
|
|
30
|
+
|
|
31
|
+
client = new HQClient({
|
|
32
|
+
hqUrl: "https://hq.example.com",
|
|
33
|
+
secret: "mysecret",
|
|
34
|
+
machineName: "dev-box",
|
|
35
|
+
remoteUrl: "https://remote.example.com",
|
|
36
|
+
bearerToken: "bearer-token-123",
|
|
37
|
+
heartbeatInterval: 100, // fast for tests
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await client.destroy();
|
|
43
|
+
globalThis.fetch = originalFetch;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("registration", () => {
|
|
47
|
+
it("sends POST to /api/hq/register with Basic Auth", async () => {
|
|
48
|
+
fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
|
|
49
|
+
await client.register();
|
|
50
|
+
|
|
51
|
+
expect(fetchCalls.length).toBeGreaterThanOrEqual(1);
|
|
52
|
+
const reg = fetchCalls[0]!;
|
|
53
|
+
expect(reg.url).toBe("https://hq.example.com/api/hq/register");
|
|
54
|
+
expect(reg.init.method).toBe("POST");
|
|
55
|
+
|
|
56
|
+
const authHeader = (reg.init.headers as Record<string, string>)["Authorization"];
|
|
57
|
+
expect(authHeader).toStartWith("Basic ");
|
|
58
|
+
const decoded = Buffer.from(authHeader!.slice(6), "base64").toString();
|
|
59
|
+
expect(decoded).toBe("mysecret");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("sends correct payload with id, name, url, token", async () => {
|
|
63
|
+
fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
|
|
64
|
+
await client.register();
|
|
65
|
+
|
|
66
|
+
const body = JSON.parse(fetchCalls[0]!.init.body as string);
|
|
67
|
+
expect(body.name).toBe("dev-box");
|
|
68
|
+
expect(body.url).toBe("https://remote.example.com");
|
|
69
|
+
expect(body.token).toBe("bearer-token-123");
|
|
70
|
+
expect(body.id).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("throws on registration failure", async () => {
|
|
74
|
+
fetchResponses.push({ ok: false, status: 401, text: "Unauthorized" });
|
|
75
|
+
await expect(client.register()).rejects.toThrow("Registration failed (401)");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("heartbeat", () => {
|
|
80
|
+
it("fires heartbeat after registration", async () => {
|
|
81
|
+
// Registration + at least 1 heartbeat
|
|
82
|
+
fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
|
|
83
|
+
fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
|
|
84
|
+
fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
|
|
85
|
+
|
|
86
|
+
await client.register();
|
|
87
|
+
|
|
88
|
+
// Wait for heartbeat interval to fire
|
|
89
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
90
|
+
|
|
91
|
+
// At least 1 registration + 1 heartbeat
|
|
92
|
+
expect(fetchCalls.length).toBeGreaterThanOrEqual(2);
|
|
93
|
+
|
|
94
|
+
// All calls go to /api/hq/register
|
|
95
|
+
for (const call of fetchCalls) {
|
|
96
|
+
expect(call.url).toBe("https://hq.example.com/api/hq/register");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("backoff on failure", () => {
|
|
102
|
+
it("increases backoff when heartbeat fails", async () => {
|
|
103
|
+
fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' }); // register OK
|
|
104
|
+
fetchResponses.push({ ok: false, status: 500, text: "Server Error" }); // heartbeat fail
|
|
105
|
+
fetchResponses.push({ ok: false, status: 500, text: "Server Error" }); // heartbeat fail
|
|
106
|
+
|
|
107
|
+
await client.register();
|
|
108
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
109
|
+
|
|
110
|
+
// Client should still be alive (backoff doesn't crash)
|
|
111
|
+
expect(client.getRemoteId()).toBeTruthy();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("deregister on shutdown", () => {
|
|
116
|
+
it("sends DELETE on destroy", async () => {
|
|
117
|
+
fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' }); // register
|
|
118
|
+
fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' }); // deregister DELETE
|
|
119
|
+
|
|
120
|
+
await client.register();
|
|
121
|
+
await client.destroy();
|
|
122
|
+
|
|
123
|
+
const deleteCalls = fetchCalls.filter((c) => c.init.method === "DELETE");
|
|
124
|
+
expect(deleteCalls.length).toBe(1);
|
|
125
|
+
expect(deleteCalls[0]!.url).toContain("/api/hq/machines/");
|
|
126
|
+
expect(deleteCalls[0]!.url).toContain(client.getRemoteId());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("does not throw if deregister fails", async () => {
|
|
130
|
+
fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' }); // register
|
|
131
|
+
// No response for DELETE — will fail
|
|
132
|
+
|
|
133
|
+
await client.register();
|
|
134
|
+
// Should not throw
|
|
135
|
+
await client.destroy();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("accessors", () => {
|
|
140
|
+
it("returns remote ID", () => {
|
|
141
|
+
expect(client.getRemoteId()).toMatch(
|
|
142
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns bearer token", () => {
|
|
147
|
+
expect(client.getToken()).toBe("bearer-token-123");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns machine name", () => {
|
|
151
|
+
expect(client.getName()).toBe("dev-box");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import { logger } from "../log.ts";
|
|
4
|
+
import type { RegistrationPayload } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
const LOG = "hq-client";
|
|
7
|
+
|
|
8
|
+
export interface HQClientConfig {
|
|
9
|
+
hqUrl: string;
|
|
10
|
+
secret: string;
|
|
11
|
+
machineName: string;
|
|
12
|
+
remoteUrl: string;
|
|
13
|
+
bearerToken?: string;
|
|
14
|
+
heartbeatInterval?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class HQClient {
|
|
18
|
+
private readonly hqUrl: string;
|
|
19
|
+
private readonly remoteId: string;
|
|
20
|
+
private readonly machineName: string;
|
|
21
|
+
private readonly secret: string;
|
|
22
|
+
private readonly remoteUrl: string;
|
|
23
|
+
private readonly bearerToken: string;
|
|
24
|
+
private readonly heartbeatInterval: number;
|
|
25
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
26
|
+
private backoffMs = 1000;
|
|
27
|
+
private destroyed = false;
|
|
28
|
+
|
|
29
|
+
constructor(config: HQClientConfig) {
|
|
30
|
+
this.hqUrl = config.hqUrl;
|
|
31
|
+
this.remoteId = crypto.randomUUID();
|
|
32
|
+
this.machineName = config.machineName;
|
|
33
|
+
this.secret = config.secret;
|
|
34
|
+
this.remoteUrl = config.remoteUrl;
|
|
35
|
+
this.bearerToken = config.bearerToken ?? crypto.randomBytes(32).toString("hex");
|
|
36
|
+
this.heartbeatInterval = config.heartbeatInterval ?? 15_000;
|
|
37
|
+
|
|
38
|
+
logger.debug(LOG, "HQ client initialized", {
|
|
39
|
+
hqUrl: this.hqUrl,
|
|
40
|
+
machineName: this.machineName,
|
|
41
|
+
remoteId: this.remoteId,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private basicAuth(): string {
|
|
46
|
+
return `Basic ${Buffer.from(this.secret).toString("base64")}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async register(): Promise<void> {
|
|
50
|
+
logger.info(LOG, `Registering with HQ at ${this.hqUrl}`);
|
|
51
|
+
|
|
52
|
+
const payload: RegistrationPayload = {
|
|
53
|
+
id: this.remoteId,
|
|
54
|
+
name: this.machineName,
|
|
55
|
+
url: this.remoteUrl,
|
|
56
|
+
token: this.bearerToken,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const response = await fetch(`${this.hqUrl}/api/hq/register`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
Authorization: this.basicAuth(),
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify(payload),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const text = await response.text();
|
|
70
|
+
throw new Error(`Registration failed (${response.status}): ${text}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
logger.info(LOG, `Registered: ${this.machineName} (${this.remoteId})`);
|
|
74
|
+
this.backoffMs = 1000;
|
|
75
|
+
this.startHeartbeat();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private startHeartbeat(): void {
|
|
79
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
80
|
+
this.heartbeatTimer = setInterval(() => void this.sendHeartbeat(), this.heartbeatInterval);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async sendHeartbeat(): Promise<void> {
|
|
84
|
+
if (this.destroyed) return;
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`${this.hqUrl}/api/hq/register`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: {
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
Authorization: this.basicAuth(),
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
id: this.remoteId,
|
|
94
|
+
name: this.machineName,
|
|
95
|
+
url: this.remoteUrl,
|
|
96
|
+
token: this.bearerToken,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
if (res.ok) {
|
|
100
|
+
this.backoffMs = 1000;
|
|
101
|
+
logger.debug(LOG, "Heartbeat OK");
|
|
102
|
+
} else {
|
|
103
|
+
throw new Error(`HTTP ${res.status}`);
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
logger.warn(LOG, `Heartbeat failed, backoff ${this.backoffMs}ms`, {
|
|
107
|
+
error: String(err),
|
|
108
|
+
});
|
|
109
|
+
this.backoffMs = Math.min(this.backoffMs * 2, 60_000);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async destroy(): Promise<void> {
|
|
114
|
+
this.destroyed = true;
|
|
115
|
+
if (this.heartbeatTimer) {
|
|
116
|
+
clearInterval(this.heartbeatTimer);
|
|
117
|
+
this.heartbeatTimer = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
logger.info(LOG, `Deregistering from HQ: ${this.machineName} (${this.remoteId})`);
|
|
121
|
+
try {
|
|
122
|
+
await fetch(`${this.hqUrl}/api/hq/machines/${this.remoteId}`, {
|
|
123
|
+
method: "DELETE",
|
|
124
|
+
headers: { Authorization: this.basicAuth() },
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
// Best-effort during shutdown
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getRemoteId(): string {
|
|
132
|
+
return this.remoteId;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getToken(): string {
|
|
136
|
+
return this.bearerToken;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getName(): string {
|
|
140
|
+
return this.machineName;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "bun:test";
|
|
2
|
+
import { MDNSService } from "./mdns.ts";
|
|
3
|
+
|
|
4
|
+
describe("MDNSService", () => {
|
|
5
|
+
let svc: MDNSService;
|
|
6
|
+
|
|
7
|
+
afterEach(async () => {
|
|
8
|
+
if (svc) await svc.stopAdvertising();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("starts inactive", () => {
|
|
12
|
+
svc = new MDNSService();
|
|
13
|
+
expect(svc.isActive()).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("stopAdvertising is a no-op when not active", async () => {
|
|
17
|
+
svc = new MDNSService();
|
|
18
|
+
await svc.stopAdvertising(); // should not throw
|
|
19
|
+
expect(svc.isActive()).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (process.platform === "darwin") {
|
|
23
|
+
it("advertises via dns-sd on macOS", async () => {
|
|
24
|
+
svc = new MDNSService();
|
|
25
|
+
await svc.startAdvertising(19876, "test-tmux-ide");
|
|
26
|
+
expect(svc.isActive()).toBe(true);
|
|
27
|
+
|
|
28
|
+
await svc.stopAdvertising();
|
|
29
|
+
expect(svc.isActive()).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("skips duplicate startAdvertising", async () => {
|
|
33
|
+
svc = new MDNSService();
|
|
34
|
+
await svc.startAdvertising(19877, "dup-test");
|
|
35
|
+
await svc.startAdvertising(19877, "dup-test"); // should warn but not throw
|
|
36
|
+
expect(svc.isActive()).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("advertises _tmux-ide._tcp service type", async () => {
|
|
40
|
+
// The service type is hardcoded in the module. We just verify the
|
|
41
|
+
// service starts successfully (dns-sd logs the type on stdout).
|
|
42
|
+
svc = new MDNSService();
|
|
43
|
+
await svc.startAdvertising(19878);
|
|
44
|
+
expect(svc.isActive()).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
it("skips advertisement on non-macOS", async () => {
|
|
48
|
+
svc = new MDNSService();
|
|
49
|
+
await svc.startAdvertising(19876, "test");
|
|
50
|
+
// On non-macOS the service doesn't actually start dns-sd
|
|
51
|
+
expect(svc.isActive()).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { logger } from "../log.ts";
|
|
5
|
+
|
|
6
|
+
const LOG = "mdns";
|
|
7
|
+
const SERVICE_TYPE = "_tmux-ide._tcp";
|
|
8
|
+
|
|
9
|
+
export class MDNSService {
|
|
10
|
+
private dnsSdProcess: ChildProcess | null = null;
|
|
11
|
+
private advertising = false;
|
|
12
|
+
|
|
13
|
+
async startAdvertising(port: number, instanceName?: string): Promise<void> {
|
|
14
|
+
if (this.advertising) {
|
|
15
|
+
logger.warn(LOG, "mDNS already advertising");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const name = instanceName ?? hostname() ?? "tmux-ide";
|
|
20
|
+
|
|
21
|
+
if (process.platform === "darwin") {
|
|
22
|
+
await this.startDnsSd(name, port);
|
|
23
|
+
} else {
|
|
24
|
+
logger.info(LOG, "mDNS advertisement skipped (not macOS)");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async stopAdvertising(): Promise<void> {
|
|
29
|
+
if (!this.advertising) return;
|
|
30
|
+
|
|
31
|
+
if (this.dnsSdProcess) {
|
|
32
|
+
await new Promise<void>((resolve) => {
|
|
33
|
+
const proc = this.dnsSdProcess!;
|
|
34
|
+
this.dnsSdProcess = null;
|
|
35
|
+
proc.once("exit", () => resolve());
|
|
36
|
+
proc.kill();
|
|
37
|
+
setTimeout(resolve, 1000);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.advertising = false;
|
|
42
|
+
logger.info(LOG, "Stopped mDNS advertisement");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
isActive(): boolean {
|
|
46
|
+
return this.advertising;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async startDnsSd(name: string, port: number): Promise<void> {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
try {
|
|
52
|
+
const proc = spawn("dns-sd", ["-R", name, SERVICE_TYPE, "local.", String(port)], {
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
proc.on("error", (err) => {
|
|
57
|
+
logger.warn(LOG, `dns-sd error: ${err.message}`);
|
|
58
|
+
if (!this.advertising) reject(err);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
proc.stdout?.on("data", (data: Buffer) => {
|
|
62
|
+
const msg = data.toString().trim();
|
|
63
|
+
if (msg) logger.debug(LOG, `dns-sd: ${msg}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
proc.stderr?.on("data", (data: Buffer) => {
|
|
67
|
+
const msg = data.toString().trim();
|
|
68
|
+
if (msg) logger.warn(LOG, `dns-sd stderr: ${msg}`);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
proc.on("close", () => {
|
|
72
|
+
this.advertising = false;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.dnsSdProcess = proc;
|
|
76
|
+
this.advertising = true;
|
|
77
|
+
logger.info(LOG, `mDNS advertising: ${name} on port ${port} (${SERVICE_TYPE})`);
|
|
78
|
+
|
|
79
|
+
// dns-sd stays running, resolve immediately
|
|
80
|
+
resolve();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
reject(err);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const mdnsService = new MDNSService();
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
3
|
+
import { RemoteRegistry } from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
// Mock fetch for health checks (always succeed in tests to avoid pruning)
|
|
6
|
+
const originalFetch = globalThis.fetch;
|
|
7
|
+
|
|
8
|
+
describe("RemoteRegistry", () => {
|
|
9
|
+
let registry: RemoteRegistry;
|
|
10
|
+
let shuttingDown = false;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
shuttingDown = false;
|
|
14
|
+
// Mock fetch so health checks succeed
|
|
15
|
+
globalThis.fetch = (() =>
|
|
16
|
+
Promise.resolve(new Response('{"ok":true}', { status: 200 }))) as typeof fetch;
|
|
17
|
+
|
|
18
|
+
registry = new RemoteRegistry({
|
|
19
|
+
healthInterval: 60_000, // long interval so health checks don't interfere
|
|
20
|
+
healthTimeout: 1000,
|
|
21
|
+
isShuttingDown: () => shuttingDown,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
registry.destroy();
|
|
27
|
+
globalThis.fetch = originalFetch;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("register", () => {
|
|
31
|
+
it("registers a machine and it appears in list", () => {
|
|
32
|
+
registry.register({
|
|
33
|
+
id: "m1",
|
|
34
|
+
name: "dev-box",
|
|
35
|
+
url: "https://dev.example.com",
|
|
36
|
+
token: "tok-1",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const machines = registry.getMachines();
|
|
40
|
+
expect(machines.length).toBe(1);
|
|
41
|
+
expect(machines[0]!.name).toBe("dev-box");
|
|
42
|
+
expect(machines[0]!.url).toBe("https://dev.example.com");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("sets registeredAt and lastHeartbeat to now", () => {
|
|
46
|
+
const before = Date.now();
|
|
47
|
+
const m = registry.register({
|
|
48
|
+
id: "m2",
|
|
49
|
+
name: "prod-1",
|
|
50
|
+
url: "https://prod.example.com",
|
|
51
|
+
token: "tok-2",
|
|
52
|
+
});
|
|
53
|
+
const after = Date.now();
|
|
54
|
+
|
|
55
|
+
expect(m.registeredAt.getTime()).toBeGreaterThanOrEqual(before);
|
|
56
|
+
expect(m.registeredAt.getTime()).toBeLessThanOrEqual(after);
|
|
57
|
+
expect(m.lastHeartbeat.getTime()).toBeGreaterThanOrEqual(before);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("initializes sessionIds as empty set", () => {
|
|
61
|
+
const m = registry.register({
|
|
62
|
+
id: "m3",
|
|
63
|
+
name: "staging",
|
|
64
|
+
url: "https://staging.example.com",
|
|
65
|
+
token: "tok-3",
|
|
66
|
+
});
|
|
67
|
+
expect(m.sessionIds.size).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("duplicate registration updates", () => {
|
|
72
|
+
it("re-registration with same ID updates fields", () => {
|
|
73
|
+
registry.register({
|
|
74
|
+
id: "m1",
|
|
75
|
+
name: "dev-box",
|
|
76
|
+
url: "https://old.example.com",
|
|
77
|
+
token: "tok-old",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const updated = registry.register({
|
|
81
|
+
id: "m1",
|
|
82
|
+
name: "dev-box",
|
|
83
|
+
url: "https://new.example.com",
|
|
84
|
+
token: "tok-new",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(registry.getMachines().length).toBe(1);
|
|
88
|
+
expect(updated.url).toBe("https://new.example.com");
|
|
89
|
+
expect(updated.token).toBe("tok-new");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rejects different ID with same name", () => {
|
|
93
|
+
registry.register({
|
|
94
|
+
id: "m1",
|
|
95
|
+
name: "dev-box",
|
|
96
|
+
url: "https://one.example.com",
|
|
97
|
+
token: "tok-1",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(() =>
|
|
101
|
+
registry.register({
|
|
102
|
+
id: "m2",
|
|
103
|
+
name: "dev-box",
|
|
104
|
+
url: "https://two.example.com",
|
|
105
|
+
token: "tok-2",
|
|
106
|
+
}),
|
|
107
|
+
).toThrow("already registered");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("unregister", () => {
|
|
112
|
+
it("removes machine from list", () => {
|
|
113
|
+
registry.register({
|
|
114
|
+
id: "m1",
|
|
115
|
+
name: "dev-box",
|
|
116
|
+
url: "https://dev.example.com",
|
|
117
|
+
token: "tok-1",
|
|
118
|
+
});
|
|
119
|
+
expect(registry.getMachines().length).toBe(1);
|
|
120
|
+
|
|
121
|
+
const result = registry.unregister("m1");
|
|
122
|
+
expect(result).toBe(true);
|
|
123
|
+
expect(registry.getMachines().length).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns false for unknown ID", () => {
|
|
127
|
+
expect(registry.unregister("nonexistent")).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("session-to-remote mapping", () => {
|
|
132
|
+
it("maps sessions to machines", () => {
|
|
133
|
+
registry.register({
|
|
134
|
+
id: "m1",
|
|
135
|
+
name: "dev-box",
|
|
136
|
+
url: "https://dev.example.com",
|
|
137
|
+
token: "tok-1",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
registry.updateSessions("m1", ["sess-a", "sess-b"]);
|
|
141
|
+
|
|
142
|
+
const found = registry.getMachineBySession("sess-a");
|
|
143
|
+
expect(found).toBeDefined();
|
|
144
|
+
expect(found!.id).toBe("m1");
|
|
145
|
+
|
|
146
|
+
const foundB = registry.getMachineBySession("sess-b");
|
|
147
|
+
expect(foundB).toBeDefined();
|
|
148
|
+
expect(foundB!.id).toBe("m1");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns undefined for unmapped session", () => {
|
|
152
|
+
expect(registry.getMachineBySession("unknown")).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("cleans up session mappings on unregister", () => {
|
|
156
|
+
registry.register({
|
|
157
|
+
id: "m1",
|
|
158
|
+
name: "dev-box",
|
|
159
|
+
url: "https://dev.example.com",
|
|
160
|
+
token: "tok-1",
|
|
161
|
+
});
|
|
162
|
+
registry.updateSessions("m1", ["sess-a"]);
|
|
163
|
+
expect(registry.getMachineBySession("sess-a")).toBeDefined();
|
|
164
|
+
|
|
165
|
+
registry.unregister("m1");
|
|
166
|
+
expect(registry.getMachineBySession("sess-a")).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("health check failure prunes", () => {
|
|
171
|
+
it("removes machine when health check fails", async () => {
|
|
172
|
+
// Make health check fail
|
|
173
|
+
globalThis.fetch = (() =>
|
|
174
|
+
Promise.resolve(new Response("Server Error", { status: 500 }))) as typeof fetch;
|
|
175
|
+
|
|
176
|
+
// Use short health interval
|
|
177
|
+
registry.destroy();
|
|
178
|
+
registry = new RemoteRegistry({
|
|
179
|
+
healthInterval: 50,
|
|
180
|
+
healthTimeout: 50,
|
|
181
|
+
isShuttingDown: () => shuttingDown,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
registry.register({
|
|
185
|
+
id: "m1",
|
|
186
|
+
name: "unhealthy",
|
|
187
|
+
url: "https://dead.example.com",
|
|
188
|
+
token: "tok-1",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Wait for health check to fire and prune
|
|
192
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
193
|
+
|
|
194
|
+
expect(registry.getMachines().length).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does not prune during shutdown", async () => {
|
|
198
|
+
globalThis.fetch = (() =>
|
|
199
|
+
Promise.resolve(new Response("Server Error", { status: 500 }))) as typeof fetch;
|
|
200
|
+
|
|
201
|
+
registry.destroy();
|
|
202
|
+
registry = new RemoteRegistry({
|
|
203
|
+
healthInterval: 50,
|
|
204
|
+
healthTimeout: 50,
|
|
205
|
+
isShuttingDown: () => shuttingDown,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
registry.register({
|
|
209
|
+
id: "m1",
|
|
210
|
+
name: "box",
|
|
211
|
+
url: "https://box.example.com",
|
|
212
|
+
token: "tok-1",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
shuttingDown = true;
|
|
216
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
217
|
+
|
|
218
|
+
// Should still be registered because shutdown flag prevents pruning
|
|
219
|
+
expect(registry.getMachines().length).toBe(1);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("getMachine", () => {
|
|
224
|
+
it("returns machine by ID", () => {
|
|
225
|
+
registry.register({
|
|
226
|
+
id: "m1",
|
|
227
|
+
name: "dev",
|
|
228
|
+
url: "https://dev.example.com",
|
|
229
|
+
token: "tok",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(registry.getMachine("m1")).toBeDefined();
|
|
233
|
+
expect(registry.getMachine("m1")!.name).toBe("dev");
|
|
234
|
+
expect(registry.getMachine("unknown")).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|