@ebowwa/terminal 0.2.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/dist/tmux.js ADDED
@@ -0,0 +1,528 @@
1
+ /**
2
+ * tmux-based Terminal Sessions
3
+ * Provides persistent terminal sessions using tmux on remote servers
4
+ * Includes automatic tmux installation and session management
5
+ */
6
+ import { getSSHPool } from "./pool.js";
7
+ import { SSHFlags, SSHPresets, buildSSHArgs } from "@codespaces/ssh";
8
+ const DEFAULT_CONFIG = {
9
+ sessionPrefix: "codespaces",
10
+ defaultShell: "/bin/bash",
11
+ term: "xterm-256color",
12
+ timeout: 30,
13
+ historyLimit: 10000, // 10k lines ~ 1-2MB per session
14
+ sessionAgeLimit: 30 * 24 * 60 * 60 * 1000, // 30 days
15
+ };
16
+ /**
17
+ * Generate a tmux session name for a host
18
+ */
19
+ export function generateSessionName(host, user = "root") {
20
+ const sanitizedHost = host.replace(/[.]/g, "-");
21
+ return `${DEFAULT_CONFIG.sessionPrefix}-${sanitizedHost}`;
22
+ }
23
+ /**
24
+ * Check if tmux is installed on the remote server
25
+ */
26
+ export async function isTmuxInstalled(options) {
27
+ const pool = getSSHPool();
28
+ try {
29
+ const result = await pool.exec("type tmux", {
30
+ ...options,
31
+ timeout: 5,
32
+ });
33
+ return result !== "0";
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * Install tmux on the remote server
41
+ *
42
+ * FALLBACK MECHANISM: This should NOT be the primary installation method.
43
+ * tmux should be installed via cloud-init during initial node provisioning.
44
+ *
45
+ * This function exists for:
46
+ * - Legacy nodes provisioned before cloud-init included tmux
47
+ * - Manual node provisioning outside cheapspaces
48
+ * - Recovery scenarios where cloud-init failed
49
+ *
50
+ * @see workspace/docs/design/node-agent/TMUX-INSTALLATION.md
51
+ * @see workspace/src/lib/bootstrap/cloud-init.ts - where tmux should be added to packages
52
+ *
53
+ * Supports Debian/Ubuntu (apt) and CentOS/RHEL (yum/dnf)
54
+ */
55
+ export async function installTmux(options) {
56
+ const pool = getSSHPool();
57
+ try {
58
+ // Detect package manager and install tmux
59
+ const installCmd = `
60
+ if command -v apt-get >/dev/null 2>&1; then
61
+ # Debian/Ubuntu
62
+ export DEBIAN_FRONTEND=noninteractive
63
+ apt-get update -qq && apt-get install -y -qq tmux
64
+ elif command -v yum >/dev/null 2>&1; then
65
+ # CentOS/RHEL (older)
66
+ yum install -y -q tmux
67
+ elif command -v dnf >/dev/null 2>&1; then
68
+ # Fedora/RHEL (newer)
69
+ dnf install -y -q tmux
70
+ elif command -v apk >/dev/null 2>&1; then
71
+ # Alpine
72
+ apk add --no-cache tmux
73
+ else
74
+ echo "ERROR: No supported package manager found"
75
+ exit 1
76
+ fi
77
+ `;
78
+ await pool.exec(installCmd, {
79
+ ...options,
80
+ timeout: 120, // Installation can take time
81
+ });
82
+ // Verify installation
83
+ const installed = await isTmuxInstalled(options);
84
+ if (installed) {
85
+ return { success: true, message: "tmux installed successfully" };
86
+ }
87
+ else {
88
+ return { success: false, message: "tmux installation failed" };
89
+ }
90
+ }
91
+ catch (error) {
92
+ return {
93
+ success: false,
94
+ message: `tmux installation error: ${error instanceof Error ? error.message : String(error)}`,
95
+ };
96
+ }
97
+ }
98
+ /**
99
+ * Ensure tmux is available, installing if necessary
100
+ */
101
+ export async function ensureTmux(options) {
102
+ const installed = await isTmuxInstalled(options);
103
+ if (installed) {
104
+ return { success: true, message: "tmux already installed" };
105
+ }
106
+ console.log("[Tmux] Not installed, attempting installation...");
107
+ return await installTmux(options);
108
+ }
109
+ /**
110
+ * List existing tmux sessions on the remote server
111
+ */
112
+ export async function listTmuxSessions(options) {
113
+ const pool = getSSHPool();
114
+ try {
115
+ const result = await pool.exec("tmux list-sessions -F '#{session_name}' 2>/dev/null || echo ''", { ...options, timeout: 5 });
116
+ if (!result || result === "0" || result.trim() === "") {
117
+ return [];
118
+ }
119
+ return result.trim().split("\n");
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ }
125
+ /**
126
+ * Check if a specific tmux session exists
127
+ */
128
+ export async function hasTmuxSession(sessionName, options) {
129
+ const sessions = await listTmuxSessions(options);
130
+ return sessions.includes(sessionName);
131
+ }
132
+ /**
133
+ * Create or attach to a tmux session
134
+ * Returns the SSH command arguments to connect to the tmux session
135
+ */
136
+ export async function createOrAttachTmuxSession(host, user = "root", keyPath, config = {}) {
137
+ const fullConfig = { ...DEFAULT_CONFIG, ...config };
138
+ const sessionName = generateSessionName(host, user);
139
+ const pool = getSSHPool();
140
+ const sshOptions = {
141
+ host,
142
+ user,
143
+ port: 22,
144
+ keyPath,
145
+ timeout: fullConfig.timeout,
146
+ };
147
+ // Ensure tmux is installed
148
+ const tmuxCheck = await ensureTmux(sshOptions);
149
+ if (!tmuxCheck.success) {
150
+ throw new Error(`Failed to setup tmux: ${tmuxCheck.message}`);
151
+ }
152
+ // Check if session already exists
153
+ const sessionExists = await hasTmuxSession(sessionName, sshOptions);
154
+ // Build SSH command with typed flags
155
+ const flags = [
156
+ ...SSHPresets.default,
157
+ SSHFlags.port(22),
158
+ SSHFlags.forceTTY(2), // -tt for forced PTY
159
+ ];
160
+ if (keyPath) {
161
+ flags.push(SSHFlags.identity(String(keyPath)));
162
+ }
163
+ const sshArgs = buildSSHArgs(flags, host, user);
164
+ // Tmux commands to create or attach session
165
+ // -A: Attach to existing session or create new
166
+ // -s: Session name
167
+ // new-window: Ensure we have a window (for new sessions)
168
+ const tmuxCmd = [
169
+ "tmux",
170
+ "new-session",
171
+ "-A",
172
+ "-s", sessionName,
173
+ "-n", "codespaces",
174
+ ];
175
+ sshArgs.push(...tmuxCmd);
176
+ // If creating a new session, also configure it via separate command
177
+ if (!sessionExists) {
178
+ try {
179
+ await pool.exec(`tmux set-option -t "${sessionName}" history-limit ${fullConfig.historyLimit} 2>/dev/null`, {
180
+ ...sshOptions,
181
+ timeout: 5,
182
+ });
183
+ }
184
+ catch {
185
+ // Ignore config errors
186
+ }
187
+ }
188
+ return {
189
+ sshArgs,
190
+ sessionName,
191
+ newlyCreated: !sessionExists,
192
+ };
193
+ }
194
+ /**
195
+ * Kill a tmux session on the remote server
196
+ */
197
+ export async function killTmuxSession(sessionName, options) {
198
+ const pool = getSSHPool();
199
+ try {
200
+ await pool.exec(`tmux kill-session -t "${sessionName}" 2>/dev/null`, {
201
+ ...options,
202
+ timeout: 5,
203
+ });
204
+ return true;
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ }
210
+ /**
211
+ * Get tmux session information
212
+ */
213
+ export async function getTmuxSessionInfo(sessionName, options) {
214
+ const pool = getSSHPool();
215
+ try {
216
+ const result = await pool.exec(`tmux display-message -t "${sessionName}" -p '#{session_windows} #{window_panes}' 2>/dev/null || echo ""`, { ...options, timeout: 5 });
217
+ if (!result || result.trim() === "" || result === "0") {
218
+ return { exists: false };
219
+ }
220
+ const [windows, panes] = result.trim().split(" ").map(Number);
221
+ return { exists: true, windows, panes };
222
+ }
223
+ catch {
224
+ return { exists: false };
225
+ }
226
+ }
227
+ /**
228
+ * Cleanup old tmux sessions on a remote server
229
+ * Kills sessions with matching prefix that are older than specified age limit
230
+ * @param options SSH connection options
231
+ * @param config Optional configuration (uses default age limit if not provided)
232
+ * @returns Object with cleaned count and errors
233
+ */
234
+ export async function cleanupOldTmuxSessions(options, config = {}) {
235
+ const fullConfig = { ...DEFAULT_CONFIG, ...config };
236
+ const pool = getSSHPool();
237
+ const errors = [];
238
+ let cleaned = 0;
239
+ try {
240
+ // Get all tmux sessions
241
+ const sessions = await listTmuxSessions(options);
242
+ // Filter for codespaces sessions
243
+ const codespacesSessions = sessions.filter(s => s.startsWith(fullConfig.sessionPrefix));
244
+ // Check age of each session by looking at socket file modification time
245
+ for (const sessionName of codespacesSessions) {
246
+ try {
247
+ // Check session age by examining tmux socket file
248
+ // Tmux sockets are in /tmp/tmux-*/default or /tmp/tmux-*
249
+ const ageCheckCmd = `
250
+ find /tmp -type s -name "*${sessionName}*" 2>/dev/null | head -1 | while read socket; do
251
+ if [ -n "$socket" ]; then
252
+ # Get file modification time in seconds since epoch
253
+ mtime=$(stat -c %Y "$socket" 2>/dev/null || stat -f %m "$socket" 2>/dev/null)
254
+ if [ -n "$mtime" ]; then
255
+ now=$(date +%s)
256
+ age=$((now - mtime))
257
+ age_ms=$((age * 1000))
258
+ echo "$age_ms"
259
+ fi
260
+ fi
261
+ done
262
+ `;
263
+ const ageResult = await pool.exec(ageCheckCmd, { ...options, timeout: 10 });
264
+ const ageMs = parseInt(ageResult.trim());
265
+ if (!isNaN(ageMs) && ageMs > fullConfig.sessionAgeLimit) {
266
+ console.log(`[Tmux] Cleaning up old session "${sessionName}" (age: ${Math.round(ageMs / 86400000)} days)`);
267
+ await killTmuxSession(sessionName, options);
268
+ cleaned++;
269
+ }
270
+ }
271
+ catch (err) {
272
+ errors.push(`${sessionName}: ${err instanceof Error ? err.message : String(err)}`);
273
+ }
274
+ }
275
+ return { cleaned, errors };
276
+ }
277
+ catch (err) {
278
+ errors.push(`Cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
279
+ return { cleaned, errors };
280
+ }
281
+ }
282
+ /**
283
+ * Get resource usage information for tmux sessions on a remote server
284
+ * @param options SSH connection options
285
+ * @returns Resource usage summary
286
+ */
287
+ export async function getTmuxResourceUsage(options) {
288
+ const pool = getSSHPool();
289
+ try {
290
+ // Get count of tmux sessions
291
+ const sessions = await listTmuxSessions(options);
292
+ const codespacesSessions = sessions.filter(s => s.startsWith(DEFAULT_CONFIG.sessionPrefix));
293
+ // Estimate memory: each session ~10MB base + scrollback buffer
294
+ // Scrollback: 10000 lines × ~100 bytes/line = ~1MB per session
295
+ const estimatedMemoryMB = codespacesSessions.length * 11;
296
+ return {
297
+ totalSessions: sessions.length,
298
+ codespacesSessions: codespacesSessions.length,
299
+ estimatedMemoryMB,
300
+ };
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ }
306
+ /**
307
+ * Send a command to a specific pane in a tmux session
308
+ * @param sessionName Target tmux session name
309
+ * @param paneIndex Pane index (default: 0 for first pane)
310
+ * @param command Command to execute (sent as keystrokes)
311
+ * @param options SSH connection options
312
+ */
313
+ export async function sendCommandToPane(sessionName, command, paneIndex = "0", options) {
314
+ const pool = getSSHPool();
315
+ try {
316
+ // Use send-keys to send command as keystrokes, then Enter
317
+ const escapedCmd = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
318
+ await pool.exec(`tmux send-keys -t "${sessionName}:${paneIndex}" "${escapedCmd}" Enter`, { ...options, timeout: 5 });
319
+ return true;
320
+ }
321
+ catch (error) {
322
+ console.error(`[Tmux] Failed to send command to ${sessionName}:${paneIndex}:`, error);
323
+ return false;
324
+ }
325
+ }
326
+ /**
327
+ * Split a pane horizontally or vertically in a tmux session
328
+ * @param sessionName Target tmux session name
329
+ * @param windowIndex Window index (default: 0)
330
+ * @param direction Split direction: "h" (horizontal) or "v" (vertical)
331
+ * @param command Optional command to run in the new pane
332
+ * @param options SSH connection options
333
+ * @returns The new pane index
334
+ */
335
+ export async function splitPane(sessionName, direction = "v", command = null, options) {
336
+ const pool = getSSHPool();
337
+ try {
338
+ // Split the pane and capture the new pane ID
339
+ const splitCmd = command
340
+ ? `tmux split-window -${direction} -t "${sessionName}" -c "#{pane_current_path}" "${command}"`
341
+ : `tmux split-window -${direction} -t "${sessionName}"`;
342
+ const result = await pool.exec(splitCmd, { ...options, timeout: 10 });
343
+ // Return the result which contains the new pane ID
344
+ return result?.trim() || null;
345
+ }
346
+ catch (error) {
347
+ console.error(`[Tmux] Failed to split pane in ${sessionName}:`, error);
348
+ return null;
349
+ }
350
+ }
351
+ /**
352
+ * List all windows in a tmux session
353
+ * @param sessionName Target tmux session name
354
+ * @param options SSH connection options
355
+ */
356
+ export async function listSessionWindows(sessionName, options) {
357
+ const pool = getSSHPool();
358
+ try {
359
+ const result = await pool.exec(`tmux list-windows -t "${sessionName}" -F '#{window_index} #{window_name} #{window_active}' 2>/dev/null || echo ''`, { ...options, timeout: 5 });
360
+ if (!result || result.trim() === "" || result === "0") {
361
+ return [];
362
+ }
363
+ return result.trim().split("\n").map(line => {
364
+ const [index, name, active] = line.split(" ");
365
+ return { index, name, active: active === "1" };
366
+ });
367
+ }
368
+ catch {
369
+ return [];
370
+ }
371
+ }
372
+ /**
373
+ * List all panes in a tmux session window
374
+ * @param sessionName Target tmux session name
375
+ * @param windowIndex Window index (default: 0)
376
+ * @param options SSH connection options
377
+ */
378
+ export async function listWindowPanes(sessionName, windowIndex = "0", options) {
379
+ const pool = getSSHPool();
380
+ try {
381
+ const result = await pool.exec(`tmux list-panes -t "${sessionName}:${windowIndex}" -F '#{pane_index} #{pane_current_path} #{pane_pid} #{pane_active}' 2>/dev/null || echo ''`, { ...options, timeout: 5 });
382
+ if (!result || result.trim() === "" || result === "0") {
383
+ return [];
384
+ }
385
+ return result.trim().split("\n").map(line => {
386
+ const [index, currentPath, pid, active] = line.split(" ");
387
+ return { index, currentPath, pid, active: active === "1" };
388
+ });
389
+ }
390
+ catch {
391
+ return [];
392
+ }
393
+ }
394
+ /**
395
+ * Capture the current output of a pane
396
+ * @param sessionName Target tmux session name
397
+ * @param paneIndex Pane index (default: 0)
398
+ * @param options SSH connection options
399
+ */
400
+ export async function capturePane(sessionName, paneIndex = "0", options) {
401
+ const pool = getSSHPool();
402
+ try {
403
+ const result = await pool.exec(`tmux capture-pane -t "${sessionName}:${paneIndex}" -p`, { ...options, timeout: 5 });
404
+ return result || null;
405
+ }
406
+ catch (error) {
407
+ console.error(`[Tmux] Failed to capture pane ${sessionName}:${paneIndex}:`, error);
408
+ return null;
409
+ }
410
+ }
411
+ /**
412
+ * Get scrollback/history from a pane
413
+ * @param sessionName Target tmux session name
414
+ * @param paneIndex Pane index (default: 0)
415
+ * @param lines Number of lines to retrieve (default: all)
416
+ * @param options SSH connection options
417
+ */
418
+ export async function getPaneHistory(sessionName, paneIndex = "0", lines = -1, options) {
419
+ const pool = getSSHPool();
420
+ try {
421
+ const linesArg = lines > 0 ? `-S -${lines}` : "-S -";
422
+ const result = await pool.exec(`tmux capture-pane ${linesArg} -t "${sessionName}:${paneIndex}" -p`, { ...options, timeout: 10 });
423
+ return result || null;
424
+ }
425
+ catch (error) {
426
+ console.error(`[Tmux] Failed to get history for ${sessionName}:${paneIndex}:`, error);
427
+ return null;
428
+ }
429
+ }
430
+ /**
431
+ * Switch to a specific window in a tmux session
432
+ * @param sessionName Target tmux session name
433
+ * @param windowIndex Target window index
434
+ * @param options SSH connection options
435
+ */
436
+ export async function switchWindow(sessionName, windowIndex, options) {
437
+ const pool = getSSHPool();
438
+ try {
439
+ await pool.exec(`tmux select-window -t "${sessionName}:${windowIndex}"`, { ...options, timeout: 5 });
440
+ return true;
441
+ }
442
+ catch {
443
+ return false;
444
+ }
445
+ }
446
+ /**
447
+ * Switch to a specific pane in a tmux session window
448
+ * @param sessionName Target tmux session name
449
+ * @param paneIndex Target pane index (e.g., "0", "1", "0.1" for window.pane)
450
+ * @param options SSH connection options
451
+ */
452
+ export async function switchPane(sessionName, paneIndex, options) {
453
+ const pool = getSSHPool();
454
+ try {
455
+ await pool.exec(`tmux select-pane -t "${sessionName}:${paneIndex}"`, { ...options, timeout: 5 });
456
+ return true;
457
+ }
458
+ catch {
459
+ return false;
460
+ }
461
+ }
462
+ /**
463
+ * Rename a window in a tmux session
464
+ * @param sessionName Target tmux session name
465
+ * @param windowIndex Window index (default: 0)
466
+ * @param newName New window name
467
+ * @param options SSH connection options
468
+ */
469
+ export async function renameWindow(sessionName, windowIndex, newName, options) {
470
+ const pool = getSSHPool();
471
+ try {
472
+ await pool.exec(`tmux rename-window -t "${sessionName}:${windowIndex}" "${newName}"`, { ...options, timeout: 5 });
473
+ return true;
474
+ }
475
+ catch {
476
+ return false;
477
+ }
478
+ }
479
+ /**
480
+ * Kill a specific pane in a tmux session
481
+ * @param sessionName Target tmux session name
482
+ * @param paneIndex Pane index to kill
483
+ * @param options SSH connection options
484
+ */
485
+ export async function killPane(sessionName, paneIndex, options) {
486
+ const pool = getSSHPool();
487
+ try {
488
+ await pool.exec(`tmux kill-pane -t "${sessionName}:${paneIndex}"`, { ...options, timeout: 5 });
489
+ return true;
490
+ }
491
+ catch {
492
+ return false;
493
+ }
494
+ }
495
+ /**
496
+ * Get detailed information about all panes in a session
497
+ * @param sessionName Target tmux session name
498
+ * @param options SSH connection options
499
+ */
500
+ export async function getDetailedSessionInfo(sessionName, options) {
501
+ const pool = getSSHPool();
502
+ try {
503
+ // First check if session exists
504
+ const exists = await hasTmuxSession(sessionName, options);
505
+ if (!exists) {
506
+ return { exists: false, windows: [] };
507
+ }
508
+ // Get all windows
509
+ const windows = await listSessionWindows(sessionName, options);
510
+ // Get panes for each window
511
+ const windowsWithPanes = await Promise.all(windows.map(async (window) => {
512
+ const panes = await listWindowPanes(sessionName, window.index, options);
513
+ return {
514
+ ...window,
515
+ panes,
516
+ };
517
+ }));
518
+ return {
519
+ exists: true,
520
+ windows: windowsWithPanes,
521
+ };
522
+ }
523
+ catch (error) {
524
+ console.error(`[Tmux] Failed to get detailed info for ${sessionName}:`, error);
525
+ return null;
526
+ }
527
+ }
528
+ //# sourceMappingURL=tmux.js.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * SSH type definitions
3
+ */
4
+ export interface SSHOptions {
5
+ host: string;
6
+ user?: string;
7
+ timeout?: number;
8
+ port?: number;
9
+ keyPath?: string;
10
+ password?: string;
11
+ }
12
+ export interface SCPOptions extends SSHOptions {
13
+ source: string;
14
+ destination: string;
15
+ recursive?: boolean;
16
+ preserve?: boolean;
17
+ }
18
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * SSH type definitions
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=types.js.map
Binary file