webmux-agent 0.0.1
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/dist/cli.js +567 -0
- package/package.json +28 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import os2 from "os";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/credentials.ts
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import * as os from "os";
|
|
11
|
+
function credentialsDir() {
|
|
12
|
+
return path.join(os.homedir(), ".webmux");
|
|
13
|
+
}
|
|
14
|
+
function credentialsPath() {
|
|
15
|
+
return path.join(credentialsDir(), "credentials.json");
|
|
16
|
+
}
|
|
17
|
+
function loadCredentials() {
|
|
18
|
+
const filePath = credentialsPath();
|
|
19
|
+
if (!fs.existsSync(filePath)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
24
|
+
const data = JSON.parse(raw);
|
|
25
|
+
if (!data.serverUrl || !data.agentId || !data.agentSecret || !data.name) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return data;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function saveCredentials(creds) {
|
|
34
|
+
const dir = credentialsDir();
|
|
35
|
+
if (!fs.existsSync(dir)) {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
37
|
+
}
|
|
38
|
+
fs.writeFileSync(credentialsPath(), JSON.stringify(creds, null, 2) + "\n", {
|
|
39
|
+
mode: 384
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/tmux.ts
|
|
44
|
+
import { execFile } from "child_process";
|
|
45
|
+
import { promisify } from "util";
|
|
46
|
+
var execFileAsync = promisify(execFile);
|
|
47
|
+
var FIELD_SEPARATOR = "";
|
|
48
|
+
var SESSION_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/;
|
|
49
|
+
var TMUX_EMPTY_STATE_MARKERS = [
|
|
50
|
+
"error connecting to",
|
|
51
|
+
"failed to connect to server",
|
|
52
|
+
"no server running",
|
|
53
|
+
"no sessions"
|
|
54
|
+
];
|
|
55
|
+
var TmuxClient = class {
|
|
56
|
+
socketName;
|
|
57
|
+
workspaceRoot;
|
|
58
|
+
constructor(options) {
|
|
59
|
+
this.socketName = options.socketName;
|
|
60
|
+
this.workspaceRoot = options.workspaceRoot;
|
|
61
|
+
}
|
|
62
|
+
async listSessions() {
|
|
63
|
+
const stdout = await this.run(
|
|
64
|
+
[
|
|
65
|
+
"list-sessions",
|
|
66
|
+
"-F",
|
|
67
|
+
[
|
|
68
|
+
"#{session_name}",
|
|
69
|
+
"#{session_windows}",
|
|
70
|
+
"#{session_attached}",
|
|
71
|
+
"#{session_created}",
|
|
72
|
+
"#{session_activity}",
|
|
73
|
+
"#{session_path}",
|
|
74
|
+
"#{pane_current_command}"
|
|
75
|
+
].join(FIELD_SEPARATOR)
|
|
76
|
+
],
|
|
77
|
+
{ allowEmptyState: true }
|
|
78
|
+
);
|
|
79
|
+
const sessions = parseSessionList(stdout);
|
|
80
|
+
const enriched = await Promise.all(
|
|
81
|
+
sessions.map(async (session) => ({
|
|
82
|
+
...session,
|
|
83
|
+
preview: await this.getPreview(session.name)
|
|
84
|
+
}))
|
|
85
|
+
);
|
|
86
|
+
return enriched.sort((left, right) => {
|
|
87
|
+
if (left.lastActivityAt !== right.lastActivityAt) {
|
|
88
|
+
return right.lastActivityAt - left.lastActivityAt;
|
|
89
|
+
}
|
|
90
|
+
return left.name.localeCompare(right.name);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async createSession(name) {
|
|
94
|
+
assertValidSessionName(name);
|
|
95
|
+
if (await this.hasSession(name)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await this.run(["new-session", "-d", "-s", name, "-c", this.workspaceRoot]);
|
|
99
|
+
}
|
|
100
|
+
async killSession(name) {
|
|
101
|
+
assertValidSessionName(name);
|
|
102
|
+
await this.run(["kill-session", "-t", name]);
|
|
103
|
+
}
|
|
104
|
+
async readSession(name) {
|
|
105
|
+
const sessions = await this.listSessions();
|
|
106
|
+
return sessions.find((session) => session.name === name) ?? null;
|
|
107
|
+
}
|
|
108
|
+
async hasSession(name) {
|
|
109
|
+
try {
|
|
110
|
+
await this.run(["has-session", "-t", name]);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const message = String(
|
|
114
|
+
error.stderr ?? error.message
|
|
115
|
+
);
|
|
116
|
+
if (isTmuxEmptyStateMessage(message)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async getPreview(name) {
|
|
123
|
+
try {
|
|
124
|
+
const stdout = await this.run(
|
|
125
|
+
["capture-pane", "-p", "-J", "-S", "-18", "-E", "-", "-t", `${name}:`],
|
|
126
|
+
{ allowEmptyState: true }
|
|
127
|
+
);
|
|
128
|
+
return formatPreview(stdout);
|
|
129
|
+
} catch {
|
|
130
|
+
return ["Session available. Tap to attach."];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async run(args, options = {}) {
|
|
134
|
+
try {
|
|
135
|
+
const { stdout } = await execFileAsync(
|
|
136
|
+
"tmux",
|
|
137
|
+
["-L", this.socketName, ...args],
|
|
138
|
+
{
|
|
139
|
+
cwd: this.workspaceRoot,
|
|
140
|
+
env: {
|
|
141
|
+
...process.env,
|
|
142
|
+
TERM: "xterm-256color"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
return stdout;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const message = String(
|
|
149
|
+
error.stderr ?? error.message
|
|
150
|
+
);
|
|
151
|
+
if (options.allowEmptyState && TMUX_EMPTY_STATE_MARKERS.some((marker) => message.includes(marker))) {
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
function assertValidSessionName(name) {
|
|
159
|
+
if (!SESSION_NAME_PATTERN.test(name)) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
"Invalid session name. Use up to 32 letters, numbers, dot, dash, or underscore."
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function parseSessionList(stdout) {
|
|
166
|
+
return stdout.split("\n").map((line) => line.trim()).filter(Boolean).flatMap((line) => {
|
|
167
|
+
const parts = line.split(FIELD_SEPARATOR);
|
|
168
|
+
const [name, windows, attachedClients, createdAt, lastActivityAt, path2] = parts;
|
|
169
|
+
const currentCommand = parts[6] ?? "";
|
|
170
|
+
if (!name || !windows || !attachedClients || !createdAt || !lastActivityAt || !path2) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
return [
|
|
174
|
+
{
|
|
175
|
+
name,
|
|
176
|
+
windows: Number(windows),
|
|
177
|
+
attachedClients: Number(attachedClients),
|
|
178
|
+
createdAt: Number(createdAt),
|
|
179
|
+
lastActivityAt: Number(lastActivityAt),
|
|
180
|
+
path: path2,
|
|
181
|
+
currentCommand
|
|
182
|
+
}
|
|
183
|
+
];
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function formatPreview(stdout) {
|
|
187
|
+
const lines = stdout.replaceAll("\r", "").split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 0).slice(-3);
|
|
188
|
+
if (lines.length > 0) {
|
|
189
|
+
return lines;
|
|
190
|
+
}
|
|
191
|
+
return ["Fresh session. Nothing has run yet."];
|
|
192
|
+
}
|
|
193
|
+
function isTmuxEmptyStateMessage(message) {
|
|
194
|
+
return TMUX_EMPTY_STATE_MARKERS.some((marker) => message.includes(marker));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/connection.ts
|
|
198
|
+
import WebSocket from "ws";
|
|
199
|
+
|
|
200
|
+
// src/terminal.ts
|
|
201
|
+
import { spawn } from "node-pty";
|
|
202
|
+
|
|
203
|
+
// ../shared/src/contracts.ts
|
|
204
|
+
var DEFAULT_TERMINAL_SIZE = {
|
|
205
|
+
cols: 120,
|
|
206
|
+
rows: 36
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// src/terminal.ts
|
|
210
|
+
async function createTerminalBridge(options) {
|
|
211
|
+
const {
|
|
212
|
+
tmux,
|
|
213
|
+
sessionName,
|
|
214
|
+
cols = DEFAULT_TERMINAL_SIZE.cols,
|
|
215
|
+
rows = DEFAULT_TERMINAL_SIZE.rows,
|
|
216
|
+
onData,
|
|
217
|
+
onExit
|
|
218
|
+
} = options;
|
|
219
|
+
assertValidSessionName(sessionName);
|
|
220
|
+
await tmux.createSession(sessionName);
|
|
221
|
+
const ptyProcess = spawn(
|
|
222
|
+
"tmux",
|
|
223
|
+
["-L", tmux.socketName, "attach-session", "-t", sessionName],
|
|
224
|
+
{
|
|
225
|
+
cols,
|
|
226
|
+
rows,
|
|
227
|
+
cwd: tmux.workspaceRoot,
|
|
228
|
+
env: {
|
|
229
|
+
...process.env,
|
|
230
|
+
TERM: "xterm-256color"
|
|
231
|
+
},
|
|
232
|
+
name: "xterm-256color"
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
ptyProcess.onData(onData);
|
|
236
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
237
|
+
onExit(exitCode);
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
write(data) {
|
|
241
|
+
ptyProcess.write(data);
|
|
242
|
+
},
|
|
243
|
+
resize(nextCols, nextRows) {
|
|
244
|
+
ptyProcess.resize(nextCols, nextRows);
|
|
245
|
+
},
|
|
246
|
+
dispose() {
|
|
247
|
+
ptyProcess.kill();
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/connection.ts
|
|
253
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
254
|
+
var INITIAL_RECONNECT_DELAY_MS = 1e3;
|
|
255
|
+
var MAX_RECONNECT_DELAY_MS = 3e4;
|
|
256
|
+
var AgentConnection = class {
|
|
257
|
+
serverUrl;
|
|
258
|
+
agentId;
|
|
259
|
+
agentSecret;
|
|
260
|
+
tmux;
|
|
261
|
+
ws = null;
|
|
262
|
+
heartbeatTimer = null;
|
|
263
|
+
reconnectTimer = null;
|
|
264
|
+
reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
265
|
+
bridges = /* @__PURE__ */ new Map();
|
|
266
|
+
stopped = false;
|
|
267
|
+
constructor(serverUrl, agentId, agentSecret, tmux) {
|
|
268
|
+
this.serverUrl = serverUrl;
|
|
269
|
+
this.agentId = agentId;
|
|
270
|
+
this.agentSecret = agentSecret;
|
|
271
|
+
this.tmux = tmux;
|
|
272
|
+
}
|
|
273
|
+
start() {
|
|
274
|
+
this.stopped = false;
|
|
275
|
+
this.connect();
|
|
276
|
+
}
|
|
277
|
+
stop() {
|
|
278
|
+
this.stopped = true;
|
|
279
|
+
if (this.reconnectTimer) {
|
|
280
|
+
clearTimeout(this.reconnectTimer);
|
|
281
|
+
this.reconnectTimer = null;
|
|
282
|
+
}
|
|
283
|
+
this.stopHeartbeat();
|
|
284
|
+
this.disposeAllBridges();
|
|
285
|
+
if (this.ws) {
|
|
286
|
+
this.ws.close(1e3, "agent shutting down");
|
|
287
|
+
this.ws = null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
connect() {
|
|
291
|
+
const wsUrl = buildWsUrl(this.serverUrl);
|
|
292
|
+
console.log(`[agent] Connecting to ${wsUrl}`);
|
|
293
|
+
const ws = new WebSocket(wsUrl);
|
|
294
|
+
this.ws = ws;
|
|
295
|
+
ws.on("open", () => {
|
|
296
|
+
console.log("[agent] WebSocket connected, authenticating...");
|
|
297
|
+
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
298
|
+
this.sendMessage({ type: "auth", agentId: this.agentId, agentSecret: this.agentSecret });
|
|
299
|
+
});
|
|
300
|
+
ws.on("message", (raw) => {
|
|
301
|
+
let msg;
|
|
302
|
+
try {
|
|
303
|
+
msg = JSON.parse(raw.toString());
|
|
304
|
+
} catch {
|
|
305
|
+
console.error("[agent] Failed to parse server message:", raw.toString());
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this.handleMessage(msg);
|
|
309
|
+
});
|
|
310
|
+
ws.on("close", (code, reason) => {
|
|
311
|
+
console.log(`[agent] WebSocket closed: code=${code} reason=${reason.toString()}`);
|
|
312
|
+
this.onDisconnect();
|
|
313
|
+
});
|
|
314
|
+
ws.on("error", (err) => {
|
|
315
|
+
console.error("[agent] WebSocket error:", err.message);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
handleMessage(msg) {
|
|
319
|
+
switch (msg.type) {
|
|
320
|
+
case "auth-ok":
|
|
321
|
+
console.log("[agent] Authenticated successfully");
|
|
322
|
+
this.startHeartbeat();
|
|
323
|
+
this.syncSessions();
|
|
324
|
+
break;
|
|
325
|
+
case "auth-fail":
|
|
326
|
+
console.error(`[agent] Authentication failed: ${msg.message}`);
|
|
327
|
+
this.stopped = true;
|
|
328
|
+
if (this.ws) {
|
|
329
|
+
this.ws.close();
|
|
330
|
+
this.ws = null;
|
|
331
|
+
}
|
|
332
|
+
process.exit(1);
|
|
333
|
+
break;
|
|
334
|
+
case "sessions-list":
|
|
335
|
+
this.syncSessions();
|
|
336
|
+
break;
|
|
337
|
+
case "terminal-attach":
|
|
338
|
+
this.handleTerminalAttach(msg.browserId, msg.sessionName, msg.cols, msg.rows);
|
|
339
|
+
break;
|
|
340
|
+
case "terminal-detach":
|
|
341
|
+
this.handleTerminalDetach(msg.browserId);
|
|
342
|
+
break;
|
|
343
|
+
case "terminal-input":
|
|
344
|
+
this.handleTerminalInput(msg.browserId, msg.data);
|
|
345
|
+
break;
|
|
346
|
+
case "terminal-resize":
|
|
347
|
+
this.handleTerminalResize(msg.browserId, msg.cols, msg.rows);
|
|
348
|
+
break;
|
|
349
|
+
case "session-create":
|
|
350
|
+
this.handleSessionCreate(msg.name);
|
|
351
|
+
break;
|
|
352
|
+
case "session-kill":
|
|
353
|
+
this.handleSessionKill(msg.name);
|
|
354
|
+
break;
|
|
355
|
+
default:
|
|
356
|
+
console.warn("[agent] Unknown message type:", msg.type);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async syncSessions() {
|
|
360
|
+
try {
|
|
361
|
+
const sessions = await this.tmux.listSessions();
|
|
362
|
+
this.sendMessage({ type: "sessions-sync", sessions });
|
|
363
|
+
} catch (err) {
|
|
364
|
+
console.error("[agent] Failed to list sessions:", err);
|
|
365
|
+
this.sendMessage({ type: "error", message: "Failed to list sessions" });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async handleTerminalAttach(browserId, sessionName, cols, rows) {
|
|
369
|
+
const existing = this.bridges.get(browserId);
|
|
370
|
+
if (existing) {
|
|
371
|
+
existing.dispose();
|
|
372
|
+
this.bridges.delete(browserId);
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const bridge = await createTerminalBridge({
|
|
376
|
+
tmux: this.tmux,
|
|
377
|
+
sessionName,
|
|
378
|
+
cols,
|
|
379
|
+
rows,
|
|
380
|
+
onData: (data) => {
|
|
381
|
+
this.sendMessage({ type: "terminal-output", browserId, data });
|
|
382
|
+
},
|
|
383
|
+
onExit: (exitCode) => {
|
|
384
|
+
this.bridges.delete(browserId);
|
|
385
|
+
this.sendMessage({ type: "terminal-exit", browserId, exitCode });
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
this.bridges.set(browserId, bridge);
|
|
389
|
+
this.sendMessage({ type: "terminal-ready", browserId, sessionName });
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
392
|
+
console.error(`[agent] Failed to attach terminal for browser ${browserId}:`, message);
|
|
393
|
+
this.sendMessage({ type: "error", browserId, message: `Failed to attach: ${message}` });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
handleTerminalDetach(browserId) {
|
|
397
|
+
const bridge = this.bridges.get(browserId);
|
|
398
|
+
if (bridge) {
|
|
399
|
+
bridge.dispose();
|
|
400
|
+
this.bridges.delete(browserId);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
handleTerminalInput(browserId, data) {
|
|
404
|
+
const bridge = this.bridges.get(browserId);
|
|
405
|
+
if (bridge) {
|
|
406
|
+
bridge.write(data);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
handleTerminalResize(browserId, cols, rows) {
|
|
410
|
+
const bridge = this.bridges.get(browserId);
|
|
411
|
+
if (bridge) {
|
|
412
|
+
bridge.resize(cols, rows);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async handleSessionCreate(name) {
|
|
416
|
+
try {
|
|
417
|
+
await this.tmux.createSession(name);
|
|
418
|
+
await this.syncSessions();
|
|
419
|
+
} catch (err) {
|
|
420
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
421
|
+
console.error(`[agent] Failed to create session "${name}":`, message);
|
|
422
|
+
this.sendMessage({ type: "error", message: `Failed to create session: ${message}` });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async handleSessionKill(name) {
|
|
426
|
+
try {
|
|
427
|
+
await this.tmux.killSession(name);
|
|
428
|
+
await this.syncSessions();
|
|
429
|
+
} catch (err) {
|
|
430
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
431
|
+
console.error(`[agent] Failed to kill session "${name}":`, message);
|
|
432
|
+
this.sendMessage({ type: "error", message: `Failed to kill session: ${message}` });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
sendMessage(msg) {
|
|
436
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
437
|
+
this.ws.send(JSON.stringify(msg));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
startHeartbeat() {
|
|
441
|
+
this.stopHeartbeat();
|
|
442
|
+
this.heartbeatTimer = setInterval(() => {
|
|
443
|
+
this.sendMessage({ type: "heartbeat" });
|
|
444
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
445
|
+
}
|
|
446
|
+
stopHeartbeat() {
|
|
447
|
+
if (this.heartbeatTimer) {
|
|
448
|
+
clearInterval(this.heartbeatTimer);
|
|
449
|
+
this.heartbeatTimer = null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
disposeAllBridges() {
|
|
453
|
+
for (const [browserId, bridge] of this.bridges) {
|
|
454
|
+
bridge.dispose();
|
|
455
|
+
this.bridges.delete(browserId);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
onDisconnect() {
|
|
459
|
+
this.stopHeartbeat();
|
|
460
|
+
this.disposeAllBridges();
|
|
461
|
+
this.ws = null;
|
|
462
|
+
if (this.stopped) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
console.log(`[agent] Reconnecting in ${this.reconnectDelay}ms...`);
|
|
466
|
+
this.reconnectTimer = setTimeout(() => {
|
|
467
|
+
this.reconnectTimer = null;
|
|
468
|
+
this.connect();
|
|
469
|
+
}, this.reconnectDelay);
|
|
470
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
function buildWsUrl(serverUrl) {
|
|
474
|
+
const url = new URL("/ws/agent", serverUrl);
|
|
475
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
476
|
+
return url.toString();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/cli.ts
|
|
480
|
+
var program = new Command();
|
|
481
|
+
program.name("webmux-agent").description("Webmux agent \u2014 connects your machine to the webmux server").version("0.0.0");
|
|
482
|
+
program.command("register").description("Register this agent with a webmux server").requiredOption("--server <url>", "Server URL (e.g. https://webmux.example.com)").requiredOption("--token <token>", "One-time registration token from the server").option("--name <name>", "Display name for this agent (defaults to hostname)").action(async (opts) => {
|
|
483
|
+
const serverUrl = opts.server.replace(/\/+$/, "");
|
|
484
|
+
const agentName = opts.name ?? os2.hostname();
|
|
485
|
+
console.log(`[agent] Registering with server ${serverUrl}...`);
|
|
486
|
+
console.log(`[agent] Agent name: ${agentName}`);
|
|
487
|
+
const body = {
|
|
488
|
+
token: opts.token,
|
|
489
|
+
name: agentName
|
|
490
|
+
};
|
|
491
|
+
let response;
|
|
492
|
+
try {
|
|
493
|
+
response = await fetch(`${serverUrl}/api/agents/register`, {
|
|
494
|
+
method: "POST",
|
|
495
|
+
headers: { "Content-Type": "application/json" },
|
|
496
|
+
body: JSON.stringify(body)
|
|
497
|
+
});
|
|
498
|
+
} catch (err) {
|
|
499
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
500
|
+
console.error(`[agent] Failed to connect to server: ${message}`);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
if (!response.ok) {
|
|
504
|
+
let errorMessage;
|
|
505
|
+
try {
|
|
506
|
+
const errorBody = await response.json();
|
|
507
|
+
errorMessage = errorBody.error ?? response.statusText;
|
|
508
|
+
} catch {
|
|
509
|
+
errorMessage = response.statusText;
|
|
510
|
+
}
|
|
511
|
+
console.error(`[agent] Registration failed: ${errorMessage}`);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
const result = await response.json();
|
|
515
|
+
saveCredentials({
|
|
516
|
+
serverUrl,
|
|
517
|
+
agentId: result.agentId,
|
|
518
|
+
agentSecret: result.agentSecret,
|
|
519
|
+
name: agentName
|
|
520
|
+
});
|
|
521
|
+
console.log(`[agent] Registration successful!`);
|
|
522
|
+
console.log(`[agent] Agent ID: ${result.agentId}`);
|
|
523
|
+
console.log(`[agent] Credentials saved to ${credentialsPath()}`);
|
|
524
|
+
console.log(`[agent] Run "webmux-agent start" to connect.`);
|
|
525
|
+
});
|
|
526
|
+
program.command("start").description("Start the agent and connect to the server").action(() => {
|
|
527
|
+
const creds = loadCredentials();
|
|
528
|
+
if (!creds) {
|
|
529
|
+
console.error(
|
|
530
|
+
`[agent] No credentials found at ${credentialsPath()}. Run "webmux-agent register" first.`
|
|
531
|
+
);
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
console.log(`[agent] Starting agent "${creds.name}"...`);
|
|
535
|
+
console.log(`[agent] Server: ${creds.serverUrl}`);
|
|
536
|
+
console.log(`[agent] Agent ID: ${creds.agentId}`);
|
|
537
|
+
const tmux = new TmuxClient({
|
|
538
|
+
socketName: "webmux",
|
|
539
|
+
workspaceRoot: process.cwd()
|
|
540
|
+
});
|
|
541
|
+
const connection = new AgentConnection(
|
|
542
|
+
creds.serverUrl,
|
|
543
|
+
creds.agentId,
|
|
544
|
+
creds.agentSecret,
|
|
545
|
+
tmux
|
|
546
|
+
);
|
|
547
|
+
const shutdown = () => {
|
|
548
|
+
console.log("\n[agent] Shutting down...");
|
|
549
|
+
connection.stop();
|
|
550
|
+
process.exit(0);
|
|
551
|
+
};
|
|
552
|
+
process.on("SIGINT", shutdown);
|
|
553
|
+
process.on("SIGTERM", shutdown);
|
|
554
|
+
connection.start();
|
|
555
|
+
});
|
|
556
|
+
program.command("status").description("Show agent status and credentials info").action(() => {
|
|
557
|
+
const creds = loadCredentials();
|
|
558
|
+
if (!creds) {
|
|
559
|
+
console.log(`[agent] Not registered. No credentials found at ${credentialsPath()}.`);
|
|
560
|
+
process.exit(0);
|
|
561
|
+
}
|
|
562
|
+
console.log(`Agent Name: ${creds.name}`);
|
|
563
|
+
console.log(`Server URL: ${creds.serverUrl}`);
|
|
564
|
+
console.log(`Agent ID: ${creds.agentId}`);
|
|
565
|
+
console.log(`Credentials File: ${credentialsPath()}`);
|
|
566
|
+
});
|
|
567
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webmux-agent",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"webmux-agent": "./dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"commander": "^14.0.0",
|
|
13
|
+
"node-pty": "^1.1.0",
|
|
14
|
+
"ws": "^8.19.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/ws": "^8.18.1",
|
|
18
|
+
"tsup": "^8.5.1",
|
|
19
|
+
"tsx": "^4.21.0",
|
|
20
|
+
"vitest": "^4.1.0",
|
|
21
|
+
"@webmux/shared": "0.0.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"dev": "tsx src/cli.ts",
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
}
|
|
28
|
+
}
|