@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/client.d.ts +15 -0
- package/dist/client.js +45 -0
- package/dist/error.d.ts +8 -0
- package/dist/error.js +12 -0
- package/dist/exec.d.ts +47 -0
- package/dist/exec.js +107 -0
- package/dist/files.d.ts +124 -0
- package/dist/files.js +436 -0
- package/dist/fingerprint.d.ts +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/pool.d.ts +143 -0
- package/dist/pool.js +554 -0
- package/dist/pty.d.ts +59 -0
- package/dist/scp.d.ts +30 -0
- package/dist/scp.js +74 -0
- package/dist/sessions.d.ts +98 -0
- package/dist/tmux-exec.d.ts +50 -0
- package/dist/tmux.d.ts +213 -0
- package/dist/tmux.js +528 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +5 -0
- package/ebowwa-terminal-0.2.0.tgz +0 -0
- package/mcp/README.md +181 -0
- package/mcp/package.json +34 -0
- package/mcp/test-fix.sh +273 -0
- package/package.json +118 -0
- package/src/api.ts +752 -0
- package/src/client.ts +55 -0
- package/src/config.ts +489 -0
- package/src/error.ts +13 -0
- package/src/exec.ts +128 -0
- package/src/files.ts +636 -0
- package/src/fingerprint.ts +263 -0
- package/src/index.ts +144 -0
- package/src/manager.ts +319 -0
- package/src/mcp/index.ts +467 -0
- package/src/mcp/stdio.ts +708 -0
- package/src/network-error-detector.ts +121 -0
- package/src/pool.ts +662 -0
- package/src/pty.ts +285 -0
- package/src/scp.ts +109 -0
- package/src/sessions.ts +861 -0
- package/src/tmux-exec.ts +96 -0
- package/src/tmux-local.ts +839 -0
- package/src/tmux-manager.ts +962 -0
- package/src/tmux.ts +711 -0
- package/src/types.ts +19 -0
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
|