@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/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
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
|
+
}
|