@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/src/client.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Core SSH client for executing commands on remote servers
3
+ * Uses persistent connection pool for efficient reuse
4
+ * Uses base64 encoding to avoid shell escaping issues
5
+ */
6
+
7
+ import { z } from 'zod'
8
+ import type { SSHOptions } from "./types.js";
9
+ import { SSHError } from "./error.js";
10
+ import { SSHOptionsSchema, SSHCommandSchema } from '@ebowwa/codespaces-types/runtime/ssh'
11
+ import { getSSHPool } from './pool.js'
12
+
13
+ /**
14
+ * Execute a command on a remote server via SSH
15
+ * Uses persistent connection pool for better performance
16
+ * @param command - Shell command to execute
17
+ * @param options - SSH connection options
18
+ * @returns Command output as string
19
+ */
20
+ export async function execSSH(
21
+ command: string,
22
+ options: SSHOptions,
23
+ ): Promise<string> {
24
+ // Validate inputs with Zod
25
+ const validatedCommand = SSHCommandSchema.safeParse(command)
26
+ if (!validatedCommand.success) {
27
+ throw new Error(`Invalid SSH command: ${validatedCommand.error.issues.map(i => i.message).join(', ')}`)
28
+ }
29
+
30
+ const validatedOptions = SSHOptionsSchema.safeParse(options)
31
+ if (!validatedOptions.success) {
32
+ throw new Error(`Invalid SSH options: ${validatedOptions.error.issues.map(i => i.message).join(', ')}`)
33
+ }
34
+
35
+ const { host, user = "root", timeout = 5, port = 22, keyPath, password } = validatedOptions.data;
36
+
37
+ try {
38
+ // Get connection pool
39
+ const pool = getSSHPool()
40
+
41
+ // Execute command directly via SSH - the pool handles proper escaping
42
+ const output = await pool.exec(validatedCommand.data, {
43
+ host,
44
+ user,
45
+ timeout,
46
+ port,
47
+ keyPath,
48
+ password,
49
+ })
50
+
51
+ return output || "0";
52
+ } catch (error) {
53
+ throw new SSHError(`SSH command failed: ${validatedCommand.data}`, error);
54
+ }
55
+ }
package/src/config.ts ADDED
@@ -0,0 +1,489 @@
1
+ /**
2
+ * SSH Config Manager
3
+ *
4
+ * Manages ~/.ssh/config entries for easy node access.
5
+ * When a node is created, adds an alias so you can:
6
+ * ssh node-<id> or ssh <name>
7
+ * Instead of:
8
+ * ssh -i ~/.../key -o StrictHostKeyChecking=no root@167.235.236.8
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from "fs";
12
+ import { join, dirname, isAbsolute, resolve } from "path";
13
+ import { exec } from "child_process";
14
+ import { promisify } from "util";
15
+
16
+ const execAsync = promisify(exec);
17
+
18
+ const SSH_CONFIG_PATH = join(process.env.HOME || "~", ".ssh", "config");
19
+
20
+ // Marker comments to identify our managed entries
21
+ const BLOCK_START = "# >>> hetzner-codespaces managed";
22
+ const BLOCK_END = "# <<< hetzner-codespaces managed";
23
+
24
+ /**
25
+ * Resolve a path to absolute, handling relative paths
26
+ */
27
+ function resolveKeyPath(keyPath: string): string {
28
+ if (isAbsolute(keyPath)) {
29
+ return keyPath;
30
+ }
31
+
32
+ // Try to resolve relative to current working directory
33
+ const resolved = resolve(process.cwd(), keyPath);
34
+
35
+ // If file exists at resolved path, use it
36
+ if (existsSync(resolved)) {
37
+ try {
38
+ return realpathSync(resolved);
39
+ } catch {
40
+ return resolved;
41
+ }
42
+ }
43
+
44
+ // Return as-is if we can't resolve it
45
+ return keyPath;
46
+ }
47
+
48
+ export interface SSHConfigEntry {
49
+ id: string;
50
+ name: string;
51
+ host: string;
52
+ user?: string;
53
+ keyPath: string;
54
+ port?: number;
55
+ }
56
+
57
+ /**
58
+ * Read the current SSH config file
59
+ */
60
+ function readSSHConfig(): string {
61
+ if (!existsSync(SSH_CONFIG_PATH)) {
62
+ return "";
63
+ }
64
+ return readFileSync(SSH_CONFIG_PATH, "utf-8");
65
+ }
66
+
67
+ /**
68
+ * Write to SSH config file, ensuring proper permissions
69
+ */
70
+ function writeSSHConfig(content: string): void {
71
+ const sshDir = dirname(SSH_CONFIG_PATH);
72
+
73
+ // Ensure ~/.ssh exists with correct permissions
74
+ if (!existsSync(sshDir)) {
75
+ mkdirSync(sshDir, { mode: 0o700, recursive: true });
76
+ }
77
+
78
+ writeFileSync(SSH_CONFIG_PATH, content, { mode: 0o600 });
79
+ }
80
+
81
+ /**
82
+ * Extract our managed block from SSH config
83
+ */
84
+ function extractManagedBlock(config: string): { before: string; managed: string; after: string } {
85
+ const startIdx = config.indexOf(BLOCK_START);
86
+ const endIdx = config.indexOf(BLOCK_END);
87
+
88
+ if (startIdx === -1 || endIdx === -1) {
89
+ return { before: config, managed: "", after: "" };
90
+ }
91
+
92
+ return {
93
+ before: config.substring(0, startIdx),
94
+ managed: config.substring(startIdx, endIdx + BLOCK_END.length + 1),
95
+ after: config.substring(endIdx + BLOCK_END.length + 1),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Parse managed block into entries
101
+ */
102
+ function parseManagedEntries(managed: string): Map<string, SSHConfigEntry> {
103
+ const entries = new Map<string, SSHConfigEntry>();
104
+
105
+ // Match Host blocks within managed section
106
+ const hostRegex = /# node-id: (\S+)\nHost ([^\n]+)\n([\s\S]*?)(?=# node-id:|$)/g;
107
+ let match;
108
+
109
+ while ((match = hostRegex.exec(managed)) !== null) {
110
+ const id = match[1];
111
+ const hosts = match[2].trim();
112
+ const body = match[3];
113
+
114
+ // Parse body for HostName, User, IdentityFile, Port
115
+ const hostMatch = body.match(/HostName\s+(\S+)/);
116
+ const userMatch = body.match(/User\s+(\S+)/);
117
+ const keyMatch = body.match(/IdentityFile\s+(\S+)/);
118
+ const portMatch = body.match(/Port\s+(\d+)/);
119
+
120
+ if (hostMatch && keyMatch) {
121
+ entries.set(id, {
122
+ id,
123
+ name: hosts.split(/\s+/)[1] || hosts, // Second alias is usually the name
124
+ host: hostMatch[1],
125
+ user: userMatch?.[1] || "root",
126
+ keyPath: keyMatch[1],
127
+ port: portMatch ? parseInt(portMatch[1]) : 22,
128
+ });
129
+ }
130
+ }
131
+
132
+ return entries;
133
+ }
134
+
135
+ /**
136
+ * Generate SSH config block for an entry
137
+ */
138
+ function generateEntryBlock(entry: SSHConfigEntry): string {
139
+ // Create aliases: node-<id> and <name> (sanitized)
140
+ const sanitizedName = entry.name.replace(/[^a-zA-Z0-9_-]/g, "-");
141
+ const aliases = `node-${entry.id} ${sanitizedName}`;
142
+
143
+ // Resolve key path to absolute
144
+ const absoluteKeyPath = resolveKeyPath(entry.keyPath);
145
+
146
+ return `# node-id: ${entry.id}
147
+ Host ${aliases}
148
+ HostName ${entry.host}
149
+ User ${entry.user || "root"}
150
+ IdentityFile "${absoluteKeyPath}"
151
+ Port ${entry.port || 22}
152
+ StrictHostKeyChecking no
153
+ UserKnownHostsFile /dev/null
154
+ LogLevel ERROR
155
+ IdentitiesOnly yes
156
+
157
+ `;
158
+ }
159
+
160
+ /**
161
+ * Rebuild the managed block from entries
162
+ */
163
+ function buildManagedBlock(entries: Map<string, SSHConfigEntry>): string {
164
+ if (entries.size === 0) {
165
+ return "";
166
+ }
167
+
168
+ let block = `${BLOCK_START}\n`;
169
+ block += "# Auto-generated SSH aliases for Hetzner nodes\n";
170
+ block += "# Do not edit manually - changes will be overwritten\n\n";
171
+
172
+ for (const entry of entries.values()) {
173
+ block += generateEntryBlock(entry);
174
+ }
175
+
176
+ block += `${BLOCK_END}\n`;
177
+ return block;
178
+ }
179
+
180
+ /**
181
+ * Add or update an SSH config entry for a node
182
+ */
183
+ export function addSSHConfigEntry(entry: SSHConfigEntry): void {
184
+ const config = readSSHConfig();
185
+ const { before, managed, after } = extractManagedBlock(config);
186
+
187
+ // Parse existing entries
188
+ const entries = parseManagedEntries(managed);
189
+
190
+ // Add/update entry
191
+ entries.set(entry.id, entry);
192
+
193
+ // Rebuild config
194
+ const newManaged = buildManagedBlock(entries);
195
+ const newConfig = before.trimEnd() + "\n\n" + newManaged + after.trimStart();
196
+
197
+ writeSSHConfig(newConfig);
198
+
199
+ console.log(`[SSH Config] Added alias: ssh node-${entry.id} / ssh ${entry.name.replace(/[^a-zA-Z0-9_-]/g, "-")}`);
200
+ }
201
+
202
+ /**
203
+ * Remove an SSH config entry for a node
204
+ */
205
+ export function removeSSHConfigEntry(id: string): void {
206
+ const config = readSSHConfig();
207
+ const { before, managed, after } = extractManagedBlock(config);
208
+
209
+ // Parse existing entries
210
+ const entries = parseManagedEntries(managed);
211
+
212
+ // Remove entry
213
+ if (!entries.has(id)) {
214
+ return; // Nothing to remove
215
+ }
216
+
217
+ entries.delete(id);
218
+
219
+ // Rebuild config
220
+ const newManaged = buildManagedBlock(entries);
221
+ const newConfig = before.trimEnd() + (newManaged ? "\n\n" + newManaged : "") + after.trimStart();
222
+
223
+ writeSSHConfig(newConfig);
224
+
225
+ console.log(`[SSH Config] Removed alias for node-${id}`);
226
+ }
227
+
228
+ /**
229
+ * Update IP address for an existing node (e.g., after rebuild)
230
+ */
231
+ export function updateSSHConfigHost(id: string, newHost: string): void {
232
+ const config = readSSHConfig();
233
+ const { before, managed, after } = extractManagedBlock(config);
234
+
235
+ // Parse existing entries
236
+ const entries = parseManagedEntries(managed);
237
+
238
+ // Update entry
239
+ const entry = entries.get(id);
240
+ if (!entry) {
241
+ console.warn(`[SSH Config] No entry found for node-${id}`);
242
+ return;
243
+ }
244
+
245
+ entry.host = newHost;
246
+ entries.set(id, entry);
247
+
248
+ // Rebuild config
249
+ const newManaged = buildManagedBlock(entries);
250
+ const newConfig = before.trimEnd() + "\n\n" + newManaged + after.trimStart();
251
+
252
+ writeSSHConfig(newConfig);
253
+
254
+ console.log(`[SSH Config] Updated node-${id} host to ${newHost}`);
255
+ }
256
+
257
+ /**
258
+ * List all managed SSH config entries
259
+ */
260
+ export function listSSHConfigEntries(): SSHConfigEntry[] {
261
+ const config = readSSHConfig();
262
+ const { managed } = extractManagedBlock(config);
263
+ const entries = parseManagedEntries(managed);
264
+ return Array.from(entries.values());
265
+ }
266
+
267
+ /**
268
+ * Validate SSH connection works with the configured key
269
+ * Returns true if connection succeeds, throws on failure with diagnostic info
270
+ */
271
+ export async function validateSSHConnection(
272
+ host: string,
273
+ keyPath: string,
274
+ user: string = "root",
275
+ timeoutSeconds: number = 10
276
+ ): Promise<{ success: boolean; error?: string; diagnostics?: string }> {
277
+ try {
278
+ // Test connection with explicit key and bypass agent
279
+ const cmd = [
280
+ "ssh",
281
+ "-o", "StrictHostKeyChecking=no",
282
+ "-o", "UserKnownHostsFile=/dev/null",
283
+ "-o", `ConnectTimeout=${timeoutSeconds}`,
284
+ "-o", "IdentitiesOnly=yes", // Only use specified key, not ssh-agent
285
+ "-o", "BatchMode=yes", // Fail instead of prompting for password
286
+ "-i", keyPath,
287
+ `${user}@${host}`,
288
+ "echo CONNECTION_OK"
289
+ ].join(" ");
290
+
291
+ const { stdout } = await execAsync(cmd, { timeout: (timeoutSeconds + 5) * 1000 });
292
+
293
+ if (stdout.includes("CONNECTION_OK")) {
294
+ return { success: true };
295
+ }
296
+
297
+ return {
298
+ success: false,
299
+ error: "Connection established but test command failed",
300
+ diagnostics: stdout
301
+ };
302
+ } catch (error: any) {
303
+ // Gather diagnostic info
304
+ let diagnostics = "";
305
+
306
+ // Check if key file exists
307
+ if (!existsSync(keyPath)) {
308
+ diagnostics += `Key file missing: ${keyPath}\n`;
309
+ }
310
+
311
+ // Check ssh-agent keys
312
+ try {
313
+ const { stdout: agentKeys } = await execAsync("ssh-add -l 2>&1");
314
+ diagnostics += `ssh-agent keys:\n${agentKeys}\n`;
315
+ } catch {
316
+ diagnostics += "ssh-agent: no keys loaded\n";
317
+ }
318
+
319
+ return {
320
+ success: false,
321
+ error: error.message || String(error),
322
+ diagnostics,
323
+ };
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Ensure SSH key is loaded correctly and agent doesn't interfere
329
+ * Clears wrong keys from agent and adds the correct one
330
+ */
331
+ export async function ensureCorrectSSHKey(keyPath: string): Promise<void> {
332
+ try {
333
+ // Get fingerprint of our key
334
+ const { stdout: ourFingerprint } = await execAsync(`ssh-keygen -lf "${keyPath}.pub"`);
335
+ const ourFp = ourFingerprint.split(/\s+/)[1];
336
+
337
+ // Check what's in the agent
338
+ const { stdout: agentList } = await execAsync("ssh-add -l 2>&1").catch(() => ({ stdout: "" }));
339
+
340
+ // If our key isn't in the agent, add it
341
+ if (!agentList.includes(ourFp)) {
342
+ // Clear agent and add our key
343
+ await execAsync("ssh-add -D 2>/dev/null").catch(() => {});
344
+ await execAsync(`ssh-add "${keyPath}"`);
345
+ console.log(`[SSH] Added key to ssh-agent: ${keyPath}`);
346
+ }
347
+ } catch (error) {
348
+ // Non-fatal - we can still use the key file directly
349
+ console.warn(`[SSH] Could not configure ssh-agent: ${error}`);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Wait for SSH to become ready on a new server
355
+ * Polls until connection succeeds or timeout
356
+ */
357
+ export async function waitForSSHReady(
358
+ host: string,
359
+ keyPath: string,
360
+ options: {
361
+ user?: string;
362
+ maxAttempts?: number;
363
+ intervalMs?: number;
364
+ onAttempt?: (attempt: number, maxAttempts: number) => void;
365
+ } = {}
366
+ ): Promise<{ success: boolean; attempts: number; error?: string }> {
367
+ const {
368
+ user = "root",
369
+ maxAttempts = 30, // 30 attempts
370
+ intervalMs = 5000, // 5 seconds between attempts = 2.5 min max wait
371
+ onAttempt,
372
+ } = options;
373
+
374
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
375
+ onAttempt?.(attempt, maxAttempts);
376
+
377
+ const result = await validateSSHConnection(host, keyPath, user, 5);
378
+
379
+ if (result.success) {
380
+ return { success: true, attempts: attempt };
381
+ }
382
+
383
+ // Check for fatal errors (not just "connection refused")
384
+ if (result.error?.includes("Permission denied")) {
385
+ // Key mismatch - won't resolve by waiting
386
+ return {
387
+ success: false,
388
+ attempts: attempt,
389
+ error: `SSH key rejected: ${result.error}\n${result.diagnostics || ""}`,
390
+ };
391
+ }
392
+
393
+ if (attempt < maxAttempts) {
394
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
395
+ }
396
+ }
397
+
398
+ return {
399
+ success: false,
400
+ attempts: maxAttempts,
401
+ error: `SSH not ready after ${maxAttempts} attempts (${(maxAttempts * intervalMs) / 1000}s)`,
402
+ };
403
+ }
404
+
405
+ /**
406
+ * Sync result for a single node
407
+ */
408
+ export interface SyncResult {
409
+ id: string;
410
+ name: string;
411
+ ip: string;
412
+ status: "added" | "updated" | "skipped" | "error";
413
+ error?: string;
414
+ sshReady?: boolean;
415
+ }
416
+
417
+ /**
418
+ * Sync all existing Hetzner nodes to SSH config
419
+ * Call this to add aliases for nodes created before this feature
420
+ */
421
+ export async function syncNodesToSSHConfig(
422
+ nodes: Array<{
423
+ id: string;
424
+ name: string;
425
+ ip: string;
426
+ keyPath: string;
427
+ }>,
428
+ options: {
429
+ validateSSH?: boolean;
430
+ onProgress?: (result: SyncResult) => void;
431
+ } = {}
432
+ ): Promise<SyncResult[]> {
433
+ const { validateSSH = false, onProgress } = options;
434
+ const results: SyncResult[] = [];
435
+
436
+ // Get current entries
437
+ const existingEntries = listSSHConfigEntries();
438
+ const existingIds = new Set(existingEntries.map((e) => e.id));
439
+
440
+ for (const node of nodes) {
441
+ const result: SyncResult = {
442
+ id: node.id,
443
+ name: node.name,
444
+ ip: node.ip,
445
+ status: "added",
446
+ };
447
+
448
+ try {
449
+ // Check if already exists
450
+ if (existingIds.has(node.id)) {
451
+ const existing = existingEntries.find((e) => e.id === node.id);
452
+ if (existing?.host === node.ip) {
453
+ result.status = "skipped";
454
+ } else {
455
+ // IP changed, update it
456
+ updateSSHConfigHost(node.id, node.ip);
457
+ result.status = "updated";
458
+ }
459
+ } else {
460
+ // Add new entry
461
+ addSSHConfigEntry({
462
+ id: node.id,
463
+ name: node.name,
464
+ host: node.ip,
465
+ user: "root",
466
+ keyPath: node.keyPath,
467
+ });
468
+ result.status = "added";
469
+ }
470
+
471
+ // Optionally validate SSH works
472
+ if (validateSSH && result.status !== "skipped") {
473
+ const sshResult = await validateSSHConnection(node.ip, node.keyPath);
474
+ result.sshReady = sshResult.success;
475
+ if (!sshResult.success) {
476
+ result.error = sshResult.error;
477
+ }
478
+ }
479
+ } catch (error) {
480
+ result.status = "error";
481
+ result.error = String(error);
482
+ }
483
+
484
+ results.push(result);
485
+ onProgress?.(result);
486
+ }
487
+
488
+ return results;
489
+ }
package/src/error.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * SSH error class
3
+ */
4
+
5
+ export class SSHError extends Error {
6
+ constructor(
7
+ message: string,
8
+ public readonly cause?: unknown,
9
+ ) {
10
+ super(message);
11
+ this.name = "SSHError";
12
+ }
13
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * SSH command execution functions
3
+ */
4
+
5
+ import type { SSHOptions } from "./types.js";
6
+ import { execSSH } from "./client.js";
7
+ import { getSSHPool } from "./pool.js";
8
+
9
+ /**
10
+ * Execute multiple SSH commands in parallel using multiple connections
11
+ *
12
+ * DESIGN DECISION: Multiple Connections vs Single Connection
13
+ * ===========================================================
14
+ *
15
+ * We use MULTIPLE SSH connections to avoid channel saturation issues.
16
+ * SSH servers typically limit concurrent channels per connection (~10).
17
+ * When executing 9+ commands in parallel, we can exceed this limit.
18
+ *
19
+ * Solution: Distribute commands across multiple pooled connections.
20
+ * Each connection handles a subset of commands, staying within channel limits.
21
+ *
22
+ * DESIGN DECISION: Promise.allSettled() vs Promise.all()
23
+ * ======================================================
24
+ *
25
+ * We use Promise.allSettled() instead of Promise.all() for a critical reason:
26
+ * Resource monitoring should be RESILIENT. If one command fails (e.g., GPU
27
+ * query on a CPU-only server), we still want results from all other commands.
28
+ *
29
+ * Example scenario:
30
+ * - CPU, memory, disk commands: succeed
31
+ * - GPU command: fails (no NVIDIA GPU)
32
+ * - Network command: succeeds
33
+ *
34
+ * With Promise.all(): entire batch fails, no metrics collected
35
+ * With Promise.allSettled(): we get 6/7 metrics, GPU returns "0" fallback
36
+ *
37
+ * ERROR HANDLING:
38
+ * ==============
39
+ * 1. Individual command failures are logged to console
40
+ * 2. Failed commands return "0" as fallback (matches execSSH default)
41
+ * 3. The function always completes successfully (never throws)
42
+ * 4. Calling code can check for "0" values to detect failures
43
+ */
44
+ export async function execSSHParallel(
45
+ commands: Record<string, string>,
46
+ options: SSHOptions,
47
+ ): Promise<Record<string, string>> {
48
+ const entries = Object.entries(commands);
49
+ const pool = getSSHPool();
50
+
51
+ // Determine optimal number of connections (3-4 connections is a good balance)
52
+ // This avoids SSH channel limits while maintaining parallelism
53
+ const numCommands = entries.length;
54
+ const numConnections = Math.min(numCommands, 4); // Max 4 connections
55
+
56
+ // Get multiple connections from the pool
57
+ const connections = await pool.getConnections(options, numConnections);
58
+
59
+ // Distribute commands across connections (round-robin)
60
+ const connectionPromises = connections.map((ssh, connIndex) => {
61
+ // Assign commands to this connection
62
+ const assignedCommands = entries.filter((_, i) => i % numConnections === connIndex);
63
+
64
+ // Execute all assigned commands on this connection
65
+ return Promise.allSettled(
66
+ assignedCommands.map(async ([key, cmd]) => {
67
+ try {
68
+ const result = await ssh.execCommand(cmd, {
69
+ execOptions: {
70
+ timeout: (options.timeout || 5) * 1000,
71
+ },
72
+ });
73
+
74
+ // If we have stderr but no stdout, the command failed
75
+ if (result.stderr && !result.stdout) {
76
+ throw new Error(result.stderr);
77
+ }
78
+
79
+ return [key, result.stdout.trim()] as const;
80
+ } catch (error) {
81
+ // Log the error with full details including cause
82
+ console.error(
83
+ `[execSSHParallel] Command "${key}" failed:`,
84
+ error instanceof Error ? error.message : error,
85
+ );
86
+ // Log the underlying cause if available
87
+ if (error instanceof Error && error.cause) {
88
+ console.error(
89
+ `[execSSHParallel] Command "${key}" cause:`,
90
+ error.cause,
91
+ );
92
+ }
93
+ return [key, "0"] as const; // Fallback value
94
+ }
95
+ })
96
+ );
97
+ });
98
+
99
+ // Wait for all connections to complete their assigned commands
100
+ const allSettledResults = await Promise.all(connectionPromises);
101
+
102
+ // Flatten results from all connections
103
+ const results: Array<[string, string]> = [];
104
+ for (const connResults of allSettledResults) {
105
+ for (const result of connResults) {
106
+ if (result.status === "fulfilled") {
107
+ results.push([...result.value]);
108
+ }
109
+ // Rejected promises are already logged and return "0" above
110
+ }
111
+ }
112
+
113
+ return Object.fromEntries(results);
114
+ }
115
+
116
+ /**
117
+ * Test SSH connection to a remote server
118
+ * @param options - SSH connection options
119
+ * @returns True if connection successful
120
+ */
121
+ export async function testSSHConnection(options: SSHOptions): Promise<boolean> {
122
+ try {
123
+ await execSSH('echo "connection_test"', options);
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }