@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/pool.js ADDED
@@ -0,0 +1,554 @@
1
+ /**
2
+ * SSH Connection Pool Manager
3
+ * Maintains persistent SSH connections for reuse across commands
4
+ */
5
+ import { NodeSSH } from 'node-ssh';
6
+ import path from 'node:path';
7
+ const DEFAULT_CONFIG = {
8
+ maxConnections: 50,
9
+ maxConnectionsPerHost: 5, // Allow up to 5 connections per host for parallel ops
10
+ idleTimeout: 5 * 60 * 1000, // 5 minutes
11
+ connectionTimeout: 10 * 1000, // 10 seconds
12
+ keepAliveInterval: 30 * 1000, // 30 seconds
13
+ };
14
+ /**
15
+ * SSH Connection Pool Class
16
+ */
17
+ export class SSHConnectionPool {
18
+ connections = new Map();
19
+ config;
20
+ cleanupInterval = null;
21
+ nextId = 0; // Counter for generating unique connection IDs
22
+ constructor(config = {}) {
23
+ this.config = { ...DEFAULT_CONFIG, ...config };
24
+ this.startCleanup();
25
+ }
26
+ /**
27
+ * Generate a unique key for the connection (host-based)
28
+ */
29
+ getKey(host, port, user) {
30
+ return `${user}@${host}:${port}`;
31
+ }
32
+ /**
33
+ * Get all connections for a given host
34
+ */
35
+ getConnectionsList(key) {
36
+ let list = this.connections.get(key);
37
+ if (!list) {
38
+ list = [];
39
+ this.connections.set(key, list);
40
+ }
41
+ return list;
42
+ }
43
+ /**
44
+ * Get or create a connection (returns least recently used connection)
45
+ */
46
+ async getConnection(options) {
47
+ const connections = await this.getConnections(options, 1);
48
+ return connections[0];
49
+ }
50
+ /**
51
+ * Get or create a connection using password authentication
52
+ */
53
+ async getConnectionWithPassword(host, user, password, port = 22) {
54
+ return this.getConnection({ host, user, password, port });
55
+ }
56
+ /**
57
+ * Get or create multiple connections for parallel execution
58
+ * @param options - SSH connection options
59
+ * @param count - Number of connections to retrieve
60
+ * @returns Array of SSH connections
61
+ */
62
+ async getConnections(options, count) {
63
+ const { host, user = 'root', port = 22, keyPath, password } = options;
64
+ const key = this.getKey(host, port, user);
65
+ const list = this.getConnectionsList(key);
66
+ const now = Date.now();
67
+ const result = [];
68
+ // Try to reuse existing connections that are alive and recently used
69
+ for (let i = list.length - 1; i >= 0 && result.length < count; i--) {
70
+ const existing = list[i];
71
+ // If used recently (within 30 seconds), reuse without verification
72
+ if (now - existing.lastUsed < 30000) {
73
+ existing.lastUsed = now;
74
+ result.push(existing.ssh);
75
+ list.splice(i, 1);
76
+ continue;
77
+ }
78
+ // Verify connection is still alive (for idle connections)
79
+ try {
80
+ await existing.ssh.exec('echo', ['ok']);
81
+ existing.lastUsed = now;
82
+ result.push(existing.ssh);
83
+ list.splice(i, 1);
84
+ }
85
+ catch {
86
+ // Connection is dead, remove and dispose
87
+ list.splice(i, 1);
88
+ try {
89
+ await existing.ssh.dispose();
90
+ }
91
+ catch {
92
+ // Ignore dispose errors
93
+ }
94
+ }
95
+ }
96
+ // Put back the connections we're reusing at the end (most recently used)
97
+ for (const conn of result) {
98
+ const pooled = list.find(c => c.ssh === conn);
99
+ if (pooled) {
100
+ // Move to end of list (most recently used)
101
+ list.splice(list.indexOf(pooled), 1);
102
+ list.push(pooled);
103
+ }
104
+ }
105
+ // Create new connections if needed
106
+ while (result.length < count) {
107
+ // Check pool size limit
108
+ if (this.getTotalConnectionCount() >= this.config.maxConnections) {
109
+ await this.evictOldest();
110
+ }
111
+ // Check per-host limit
112
+ const currentHostCount = list.length + result.length;
113
+ if (currentHostCount >= this.config.maxConnectionsPerHost) {
114
+ // Can't create more connections for this host
115
+ break;
116
+ }
117
+ // Create new connection
118
+ const ssh = await this.createConnection(host, port, user, keyPath, password);
119
+ // Add to result and pool
120
+ result.push(ssh);
121
+ list.push({
122
+ ssh,
123
+ lastUsed: now,
124
+ host,
125
+ port,
126
+ user,
127
+ id: `${key}-${this.nextId++}`,
128
+ });
129
+ }
130
+ return result;
131
+ }
132
+ /**
133
+ * Create a new SSH connection
134
+ * Tries key-based auth first, then password auth, then SSH agent
135
+ */
136
+ async createConnection(host, port, user, keyPath, password) {
137
+ const ssh = new NodeSSH();
138
+ const baseConfig = {
139
+ host,
140
+ port,
141
+ username: user,
142
+ readyTimeout: this.config.connectionTimeout,
143
+ keepaliveInterval: this.config.keepAliveInterval,
144
+ };
145
+ // Try authentication methods in order: key, password, agent
146
+ const authMethods = [];
147
+ // 1. Key-based authentication (if keyPath provided)
148
+ if (keyPath) {
149
+ // Resolve relative paths to absolute to ensure key is found from any working directory
150
+ let resolvedKeyPath = keyPath;
151
+ if (!path.isAbsolute(keyPath)) {
152
+ // Legacy relative paths like "../.ssh-keys/..." - resolve from project root
153
+ const projectRoot = path.resolve(import.meta.dir, '../../com.hetzner.codespaces');
154
+ if (keyPath.includes('.ssh-keys')) {
155
+ // Extract just the filename from the path and look in project .ssh-keys
156
+ const keyName = path.basename(keyPath);
157
+ resolvedKeyPath = path.join(projectRoot, '.ssh-keys', keyName);
158
+ }
159
+ else {
160
+ resolvedKeyPath = path.resolve(keyPath);
161
+ }
162
+ }
163
+ authMethods.push({
164
+ name: 'key',
165
+ config: { ...baseConfig, privateKeyPath: resolvedKeyPath },
166
+ });
167
+ }
168
+ // 2. Password authentication (if password provided)
169
+ if (password) {
170
+ authMethods.push({
171
+ name: 'password',
172
+ config: { ...baseConfig, password },
173
+ });
174
+ }
175
+ // 3. SSH agent (fallback if available)
176
+ if (process.env.SSH_AUTH_SOCK) {
177
+ authMethods.push({
178
+ name: 'agent',
179
+ config: { ...baseConfig, agent: process.env.SSH_AUTH_SOCK },
180
+ });
181
+ }
182
+ // If no auth methods available, try with empty config (will fail)
183
+ if (authMethods.length === 0) {
184
+ authMethods.push({
185
+ name: 'default',
186
+ config: baseConfig,
187
+ });
188
+ }
189
+ // Try each authentication method
190
+ const errors = [];
191
+ for (const method of authMethods) {
192
+ try {
193
+ await ssh.connect(method.config);
194
+ // Attach error handler to underlying ssh2 client to catch keepalive and other errors
195
+ // The NodeSSH instance exposes the underlying ssh2 Client via the 'ssh' property
196
+ if (ssh.ssh) {
197
+ const underlyingClient = ssh.ssh;
198
+ const connectionKey = this.getKey(host, port, user);
199
+ underlyingClient.on('error', (err) => {
200
+ // Handle keepalive timeouts gracefully - these are network issues, not fatal errors
201
+ const isKeepalive = err.message.includes('Keepalive') || err.level === 'client-timeout';
202
+ if (isKeepalive) {
203
+ console.warn(`[SSH Pool] Keepalive timeout for ${user}@${host}:${port} - removing stale connection`);
204
+ }
205
+ else {
206
+ console.error(`[SSH Pool] Connection error for ${user}@${host}:${port}:`, err.message);
207
+ }
208
+ // Remove failed connection from pool
209
+ const list = this.connections.get(connectionKey);
210
+ if (list) {
211
+ const index = list.findIndex(c => c.ssh === ssh);
212
+ if (index !== -1) {
213
+ const removed = list.splice(index, 1)[0];
214
+ // Dispose connection
215
+ try {
216
+ ssh.dispose();
217
+ }
218
+ catch {
219
+ // Ignore dispose errors
220
+ }
221
+ console.log(`[SSH Pool] Removed errored connection ${removed.id} from pool (will reconnect on next use)`);
222
+ // Clean up empty lists
223
+ if (list.length === 0) {
224
+ this.connections.delete(connectionKey);
225
+ }
226
+ }
227
+ }
228
+ });
229
+ // Also handle 'close' event for unexpected disconnections
230
+ underlyingClient.on('close', () => {
231
+ const list = this.connections.get(connectionKey);
232
+ if (list) {
233
+ const index = list.findIndex(c => c.ssh === ssh);
234
+ if (index !== -1) {
235
+ const removed = list.splice(index, 1)[0];
236
+ console.log(`[SSH Pool] Connection ${removed.id} closed unexpectedly (will reconnect on next use)`);
237
+ if (list.length === 0) {
238
+ this.connections.delete(connectionKey);
239
+ }
240
+ }
241
+ }
242
+ });
243
+ }
244
+ return ssh;
245
+ }
246
+ catch (error) {
247
+ const errMsg = error instanceof Error ? error.message : String(error);
248
+ errors.push(`${method.name}: ${errMsg}`);
249
+ // Try next method
250
+ }
251
+ }
252
+ // All methods failed
253
+ throw new Error(`SSH connection failed to ${host} (tried: ${authMethods.map(m => m.name).join(', ')}): ${errors.join('; ')}`);
254
+ }
255
+ /**
256
+ * Get total number of connections across all hosts
257
+ */
258
+ getTotalConnectionCount() {
259
+ let count = 0;
260
+ for (const list of this.connections.values()) {
261
+ count += list.length;
262
+ }
263
+ return count;
264
+ }
265
+ /**
266
+ * Execute a command using a pooled connection
267
+ *
268
+ * ERROR HANDLING BEHAVIOR:
269
+ * =========================
270
+ * If result.stderr exists AND result.stdout is empty, we throw an error.
271
+ * This is intentional - commands that fail should return fallback values
272
+ * via shell redirection (e.g., `|| echo "0"` or `2>/dev/null`).
273
+ *
274
+ * Example of proper fallback handling:
275
+ * `type nvidia-smi 2>/dev/null && nvidia-smi ... || echo NOGPU`
276
+ *
277
+ * This ensures commands don't silently fail - they must handle their own
278
+ * error cases and return sensible defaults.
279
+ */
280
+ async exec(command, options) {
281
+ const ssh = await this.getConnection(options);
282
+ try {
283
+ const result = await ssh.execCommand(command, {
284
+ execOptions: {
285
+ timeout: (options.timeout || 5) * 1000,
286
+ },
287
+ });
288
+ // If we have stderr but no stdout, the command failed
289
+ // Commands should handle their own fallbacks (|| echo fallback, 2>/dev/null, etc)
290
+ if (result.stderr && !result.stdout) {
291
+ // Include both stderr and stdout (if any) for better debugging
292
+ const output = result.stdout ? `${result.stderr}\n${result.stdout}` : result.stderr;
293
+ throw new Error(output);
294
+ }
295
+ return result.stdout.trim();
296
+ }
297
+ catch (error) {
298
+ // Remove failed connection from pool (find and remove specific connection)
299
+ const { host, user = 'root', port = 22 } = options;
300
+ const key = this.getKey(host, port, user);
301
+ const list = this.connections.get(key);
302
+ if (list) {
303
+ const index = list.findIndex(c => c.ssh === ssh);
304
+ if (index !== -1) {
305
+ const removed = list.splice(index, 1)[0];
306
+ try {
307
+ await removed.ssh.dispose();
308
+ }
309
+ catch {
310
+ // Ignore dispose errors
311
+ }
312
+ }
313
+ // Clean up empty lists
314
+ if (list.length === 0) {
315
+ this.connections.delete(key);
316
+ }
317
+ }
318
+ throw error;
319
+ }
320
+ }
321
+ /**
322
+ * Check if a connection exists and is alive for a given host
323
+ */
324
+ async hasConnection(options) {
325
+ const { host, user = 'root', port = 22 } = options;
326
+ const key = this.getKey(host, port, user);
327
+ const list = this.connections.get(key);
328
+ if (!list || list.length === 0) {
329
+ return false;
330
+ }
331
+ // Check if any connection is alive
332
+ for (let i = list.length - 1; i >= 0; i--) {
333
+ const conn = list[i];
334
+ try {
335
+ await conn.ssh.exec('echo', ['ok']);
336
+ return true;
337
+ }
338
+ catch {
339
+ // Remove dead connection
340
+ list.splice(i, 1);
341
+ try {
342
+ await conn.ssh.dispose();
343
+ }
344
+ catch {
345
+ // Ignore dispose errors
346
+ }
347
+ }
348
+ }
349
+ // Clean up empty list
350
+ if (list.length === 0) {
351
+ this.connections.delete(key);
352
+ }
353
+ return false;
354
+ }
355
+ /**
356
+ * Close a specific connection by SSH instance
357
+ */
358
+ async closeConnectionInstance(ssh) {
359
+ for (const [key, list] of this.connections.entries()) {
360
+ const index = list.findIndex(c => c.ssh === ssh);
361
+ if (index !== -1) {
362
+ const removed = list.splice(index, 1)[0];
363
+ try {
364
+ await removed.ssh.dispose();
365
+ }
366
+ catch {
367
+ // Ignore dispose errors
368
+ }
369
+ // Clean up empty lists
370
+ if (list.length === 0) {
371
+ this.connections.delete(key);
372
+ }
373
+ return;
374
+ }
375
+ }
376
+ }
377
+ /**
378
+ * Close all connections for a specific host
379
+ */
380
+ async closeConnection(options) {
381
+ const { host, user = 'root', port = 22 } = options;
382
+ const key = this.getKey(host, port, user);
383
+ const list = this.connections.get(key);
384
+ if (list) {
385
+ this.connections.delete(key);
386
+ for (const conn of list) {
387
+ try {
388
+ await conn.ssh.dispose();
389
+ }
390
+ catch {
391
+ // Ignore dispose errors
392
+ }
393
+ }
394
+ }
395
+ }
396
+ /**
397
+ * Evict the oldest connection from the pool
398
+ */
399
+ async evictOldest() {
400
+ let oldest = null;
401
+ for (const [key, list] of this.connections.entries()) {
402
+ for (let i = 0; i < list.length; i++) {
403
+ const conn = list[i];
404
+ if (!oldest || conn.lastUsed < oldest.conn.lastUsed) {
405
+ oldest = { key, conn, listIndex: i };
406
+ }
407
+ }
408
+ }
409
+ if (oldest) {
410
+ const list = this.connections.get(oldest.key);
411
+ if (list) {
412
+ list.splice(oldest.listIndex, 1);
413
+ // Clean up empty lists
414
+ if (list.length === 0) {
415
+ this.connections.delete(oldest.key);
416
+ }
417
+ }
418
+ try {
419
+ await oldest.conn.ssh.dispose();
420
+ }
421
+ catch {
422
+ // Ignore dispose errors
423
+ }
424
+ }
425
+ }
426
+ /**
427
+ * Clean up idle connections
428
+ */
429
+ async cleanupIdle() {
430
+ const now = Date.now();
431
+ for (const [key, list] of this.connections.entries()) {
432
+ const toRemove = [];
433
+ for (let i = 0; i < list.length; i++) {
434
+ if (now - list[i].lastUsed > this.config.idleTimeout) {
435
+ toRemove.push(i);
436
+ }
437
+ }
438
+ // Remove from end to preserve indices
439
+ for (const index of toRemove.reverse()) {
440
+ const conn = list.splice(index, 1)[0];
441
+ try {
442
+ await conn.ssh.dispose();
443
+ }
444
+ catch {
445
+ // Ignore dispose errors
446
+ }
447
+ }
448
+ // Clean up empty lists
449
+ if (list.length === 0) {
450
+ this.connections.delete(key);
451
+ }
452
+ }
453
+ }
454
+ /**
455
+ * Start periodic cleanup
456
+ */
457
+ startCleanup() {
458
+ // Run cleanup every minute
459
+ this.cleanupInterval = setInterval(() => {
460
+ this.cleanupIdle().catch(console.error);
461
+ }, 60 * 1000);
462
+ }
463
+ /**
464
+ * Stop cleanup interval
465
+ */
466
+ stopCleanup() {
467
+ if (this.cleanupInterval) {
468
+ clearInterval(this.cleanupInterval);
469
+ this.cleanupInterval = null;
470
+ }
471
+ }
472
+ /**
473
+ * Close all connections and stop cleanup
474
+ */
475
+ async closeAll() {
476
+ this.stopCleanup();
477
+ const closePromises = [];
478
+ for (const list of this.connections.values()) {
479
+ for (const conn of list) {
480
+ closePromises.push((async () => {
481
+ try {
482
+ await conn.ssh.dispose();
483
+ }
484
+ catch {
485
+ // Ignore dispose errors
486
+ }
487
+ })());
488
+ }
489
+ }
490
+ await Promise.all(closePromises);
491
+ this.connections.clear();
492
+ }
493
+ /**
494
+ * Get pool statistics
495
+ */
496
+ getStats() {
497
+ const now = Date.now();
498
+ const allConnections = [];
499
+ for (const list of this.connections.values()) {
500
+ for (const conn of list) {
501
+ allConnections.push({
502
+ host: conn.host,
503
+ port: conn.port,
504
+ user: conn.user,
505
+ lastUsed: new Date(conn.lastUsed),
506
+ idleMs: now - conn.lastUsed,
507
+ id: conn.id,
508
+ });
509
+ }
510
+ }
511
+ return {
512
+ totalConnections: allConnections.length,
513
+ connections: allConnections,
514
+ };
515
+ }
516
+ /**
517
+ * Check if a host has an active connection
518
+ */
519
+ isConnected(host, user = 'root', port = 22) {
520
+ const key = this.getKey(host, port, user);
521
+ const list = this.connections.get(key);
522
+ return list !== undefined && list.length > 0;
523
+ }
524
+ }
525
+ // Global singleton instance
526
+ let globalPool = null;
527
+ /**
528
+ * Get the global connection pool instance
529
+ */
530
+ export function getSSHPool(config) {
531
+ if (!globalPool) {
532
+ globalPool = new SSHConnectionPool(config);
533
+ }
534
+ return globalPool;
535
+ }
536
+ /**
537
+ * Close the global pool (for cleanup/shutdown)
538
+ */
539
+ export async function closeGlobalSSHPool() {
540
+ if (globalPool) {
541
+ await globalPool.closeAll();
542
+ globalPool = null;
543
+ }
544
+ }
545
+ /**
546
+ * Get active SSH connections (for monitoring)
547
+ */
548
+ export function getActiveSSHConnections() {
549
+ if (!globalPool) {
550
+ return { totalConnections: 0, connections: [] };
551
+ }
552
+ return globalPool.getStats();
553
+ }
554
+ //# sourceMappingURL=pool.js.map
package/dist/pty.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * SSH PTY session manager for interactive terminal sessions
3
+ * Handles bidirectional communication with remote shells
4
+ */
5
+ interface PTYSession {
6
+ id: string;
7
+ host: string;
8
+ user: string;
9
+ proc: any;
10
+ stdin: WritableStream<Uint8Array>;
11
+ stdout: ReadableStream<Uint8Array>;
12
+ stderr: ReadableStream<Uint8Array>;
13
+ rows: number;
14
+ cols: number;
15
+ createdAt: number;
16
+ }
17
+ /**
18
+ * Create a new SSH PTY session
19
+ * Uses script or expect to wrap SSH with PTY allocation
20
+ */
21
+ export declare function createPTYSession(host: string, user?: string, options?: {
22
+ rows?: number;
23
+ cols?: number;
24
+ port?: number;
25
+ keyPath?: string;
26
+ }): Promise<{
27
+ sessionId: string;
28
+ initialOutput: string;
29
+ }>;
30
+ /**
31
+ * Write data to PTY session stdin
32
+ */
33
+ export declare function writeToPTY(sessionId: string, data: string): Promise<boolean>;
34
+ /**
35
+ * Set PTY size (rows and columns)
36
+ */
37
+ export declare function setPTYSize(sessionId: string, rows: number, cols: number): Promise<boolean>;
38
+ /**
39
+ * Read from PTY session stdout (non-blocking)
40
+ */
41
+ export declare function readFromPTY(sessionId: string, timeout?: number): Promise<string | null>;
42
+ /**
43
+ * Close PTY session
44
+ */
45
+ export declare function closePTYSession(sessionId: string): Promise<boolean>;
46
+ /**
47
+ * Get session info
48
+ */
49
+ export declare function getPTYSession(sessionId: string): PTYSession | undefined;
50
+ /**
51
+ * Get all active sessions
52
+ */
53
+ export declare function getActivePTYSessions(): PTYSession[];
54
+ /**
55
+ * Clean up stale sessions (older than specified milliseconds)
56
+ */
57
+ export declare function cleanupStaleSessions(maxAge?: number): void;
58
+ export {};
59
+ //# sourceMappingURL=pty.d.ts.map
package/dist/scp.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * SCP/SFTP file transfer operations
3
+ * Uses SSH connection pool and SFTP for efficient transfers
4
+ */
5
+ import type { SCPOptions } from "./types.js";
6
+ /**
7
+ * Upload a file to remote server via SFTP
8
+ * @param options - SCP options including source and destination
9
+ * @returns True if successful
10
+ */
11
+ export declare function scpUpload(options: SCPOptions): Promise<boolean>;
12
+ /**
13
+ * Download a file from remote server via SFTP
14
+ * @param options - SCP options including source (remote) and destination (local)
15
+ * @returns True if successful
16
+ */
17
+ export declare function scpDownload(options: SCPOptions): Promise<boolean>;
18
+ /**
19
+ * Upload a directory to remote server via SFTP
20
+ * @param options - SCP options with source directory
21
+ * @returns True if successful
22
+ */
23
+ export declare function scpUploadDirectory(options: SCPOptions): Promise<boolean>;
24
+ /**
25
+ * Download a directory from remote server via SFTP
26
+ * @param options - SCP options with source directory
27
+ * @returns True if successful
28
+ */
29
+ export declare function scpDownloadDirectory(options: SCPOptions): Promise<boolean>;
30
+ //# sourceMappingURL=scp.d.ts.map