@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/sessions.ts
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Session Management
|
|
3
|
+
* Handles SSH PTY session lifecycle, persistence, and querying
|
|
4
|
+
* Uses tmux for persistent sessions that survive disconnections
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createOrAttachTmuxSession, killTmuxSession } from "./tmux.js";
|
|
9
|
+
// metadata import for updating bootstrapStatus on terminal connect
|
|
10
|
+
// import { getMetadata, setMetadata } from "cheapspaces/workspace/src/lib/metadata";
|
|
11
|
+
import type { WebSocket, ServerWebSocket, Subprocess } from "bun";
|
|
12
|
+
import { WebSocketCloseCode } from "@ebowwa/codespaces-types/compile";
|
|
13
|
+
|
|
14
|
+
// Lazy-load metadata functions (Hetzner-specific, optional)
|
|
15
|
+
let metadataModule: {
|
|
16
|
+
getMetadata: (id: string) => any;
|
|
17
|
+
setMetadata: (metadata: any) => void;
|
|
18
|
+
} | null = null;
|
|
19
|
+
|
|
20
|
+
function loadMetadata() {
|
|
21
|
+
if (metadataModule === null) {
|
|
22
|
+
try {
|
|
23
|
+
// Try to import from Hetzner workspace
|
|
24
|
+
metadataModule = require("@ebowwa/hetzner-workspace-metadata");
|
|
25
|
+
} catch {
|
|
26
|
+
// Metadata module not available - bootstrap status tracking will be disabled
|
|
27
|
+
metadataModule = { getMetadata: null as any, setMetadata: null as any };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return metadataModule;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve SSH key path to absolute path
|
|
35
|
+
* Handles legacy relative paths from metadata
|
|
36
|
+
*/
|
|
37
|
+
function resolveKeyPath(keyPath: string | undefined): string | undefined {
|
|
38
|
+
if (!keyPath) return undefined;
|
|
39
|
+
if (path.isAbsolute(keyPath)) return keyPath;
|
|
40
|
+
|
|
41
|
+
// Legacy relative paths like "../.ssh-keys/..." - resolve from project root
|
|
42
|
+
const projectRoot = path.resolve(import.meta.dir, "../../com.hetzner.codespaces");
|
|
43
|
+
if (keyPath.includes(".ssh-keys")) {
|
|
44
|
+
const keyName = path.basename(keyPath);
|
|
45
|
+
return path.join(projectRoot, ".ssh-keys", keyName);
|
|
46
|
+
}
|
|
47
|
+
return path.resolve(keyPath);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Terminal session interface
|
|
52
|
+
* Represents an active SSH PTY session
|
|
53
|
+
*/
|
|
54
|
+
export interface TerminalSession {
|
|
55
|
+
sessionId: string;
|
|
56
|
+
proc: Subprocess<"pipe", "pipe", "pipe">;
|
|
57
|
+
stdin: Subprocess<"pipe", "pipe", "pipe">["stdin"];
|
|
58
|
+
stdout: ReadableStream<Uint8Array>;
|
|
59
|
+
stderr: ReadableStream<Uint8Array>;
|
|
60
|
+
host: string;
|
|
61
|
+
user: string;
|
|
62
|
+
ws: ServerWebSocket<unknown> | null;
|
|
63
|
+
createdAt: number;
|
|
64
|
+
lastUsed: number;
|
|
65
|
+
reader: ReadableStreamDefaultReader<Uint8Array> | null;
|
|
66
|
+
stderrReader: ReadableStreamDefaultReader<Uint8Array> | null;
|
|
67
|
+
writer: WritableStreamDefaultWriter<Uint8Array> | null;
|
|
68
|
+
closed: boolean;
|
|
69
|
+
tmuxSessionName?: string; // tmux session name if using tmux
|
|
70
|
+
bootstrapLogStreamer?: Subprocess; // Cloud-init log tail process
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Session info for API responses (safe to expose)
|
|
75
|
+
*/
|
|
76
|
+
export interface SessionInfo {
|
|
77
|
+
sessionId: string;
|
|
78
|
+
host: string;
|
|
79
|
+
user: string;
|
|
80
|
+
createdAt: number;
|
|
81
|
+
lastUsed: number;
|
|
82
|
+
hasActiveWebSocket: boolean;
|
|
83
|
+
closed: boolean;
|
|
84
|
+
uptime: number;
|
|
85
|
+
idleTime: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Active sessions storage
|
|
89
|
+
const terminalSessions = new Map<string, TerminalSession>();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Clean up old sessions (older than specified milliseconds)
|
|
93
|
+
* Called automatically by interval timer
|
|
94
|
+
*/
|
|
95
|
+
export function cleanupStaleSessions(maxAge: number = 30 * 60 * 1000): string[] {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const cleaned: string[] = [];
|
|
98
|
+
|
|
99
|
+
for (const [id, session] of terminalSessions.entries()) {
|
|
100
|
+
// Only clean up sessions that have no active WebSocket and are stale
|
|
101
|
+
if (now - session.lastUsed > maxAge && !session.ws) {
|
|
102
|
+
console.log(
|
|
103
|
+
`[Terminal] Cleaning up stale session ${id} (${session.host})`,
|
|
104
|
+
);
|
|
105
|
+
closeSession(id);
|
|
106
|
+
cleaned.push(id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return cleaned;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Close a specific session
|
|
115
|
+
*/
|
|
116
|
+
export async function closeSession(sessionId: string): Promise<boolean> {
|
|
117
|
+
const session = terminalSessions.get(sessionId);
|
|
118
|
+
if (!session) return false;
|
|
119
|
+
|
|
120
|
+
session.closed = true;
|
|
121
|
+
|
|
122
|
+
// Close stdout reader if exists
|
|
123
|
+
if (session.reader) {
|
|
124
|
+
try {
|
|
125
|
+
session.reader.releaseLock();
|
|
126
|
+
} catch {
|
|
127
|
+
// Already released
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Close stderr reader if exists
|
|
132
|
+
if (session.stderrReader) {
|
|
133
|
+
try {
|
|
134
|
+
session.stderrReader.releaseLock();
|
|
135
|
+
} catch {
|
|
136
|
+
// Already released
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Close WebSocket if still connected
|
|
141
|
+
try {
|
|
142
|
+
session.ws?.close(WebSocketCloseCode.NORMAL_CLOSURE, "Session closed");
|
|
143
|
+
} catch {
|
|
144
|
+
// Already closed
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Kill bootstrap log streamer if still running
|
|
148
|
+
if (session.bootstrapLogStreamer) {
|
|
149
|
+
try {
|
|
150
|
+
session.bootstrapLogStreamer.kill();
|
|
151
|
+
console.log(`[Terminal] Killed bootstrap log streamer for ${sessionId}`);
|
|
152
|
+
} catch {
|
|
153
|
+
// Already killed
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Kill tmux session if it exists (keep session alive on server)
|
|
158
|
+
if (session.tmuxSessionName) {
|
|
159
|
+
console.log(`[Terminal] Leaving tmux session "${session.tmuxSessionName}" alive on server`);
|
|
160
|
+
// Don't kill tmux session - it persists for reconnection
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Kill the SSH process (detaches from tmux but keeps it running)
|
|
164
|
+
try {
|
|
165
|
+
session.proc.kill();
|
|
166
|
+
} catch {
|
|
167
|
+
// Already terminated
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
terminalSessions.delete(sessionId);
|
|
171
|
+
console.log(`[Terminal] Session ${sessionId} closed`);
|
|
172
|
+
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Stream cloud-init bootstrap logs from remote server
|
|
178
|
+
* Spawns a background SSH subprocess that tails the cloud-init output log
|
|
179
|
+
* @param host - Target server hostname or IP
|
|
180
|
+
* @param user - SSH user (default: root)
|
|
181
|
+
* @param keyPath - Optional path to SSH private key
|
|
182
|
+
* @param onOutput - Callback for each line of log output
|
|
183
|
+
* @param stopSignal - Object with `stopped: boolean` to signal when to stop streaming
|
|
184
|
+
* @returns Promise that resolves when streaming stops
|
|
185
|
+
*/
|
|
186
|
+
async function streamCloudInitLogs(
|
|
187
|
+
host: string,
|
|
188
|
+
user: string,
|
|
189
|
+
keyPath: string | undefined,
|
|
190
|
+
onOutput: (data: string) => void,
|
|
191
|
+
stopSignal: { stopped: boolean },
|
|
192
|
+
): Promise<Subprocess | null> {
|
|
193
|
+
try {
|
|
194
|
+
// Build SSH command for tailing cloud-init logs
|
|
195
|
+
const sshCmd: string[] = [
|
|
196
|
+
"ssh",
|
|
197
|
+
"-F", "/dev/null", // Skip user SSH config
|
|
198
|
+
"-o", "StrictHostKeyChecking=no",
|
|
199
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
200
|
+
"-o", "ServerAliveInterval=30",
|
|
201
|
+
"-o", "ServerAliveCountMax=3",
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
if (keyPath) {
|
|
205
|
+
sshCmd.push("-i", String(keyPath));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Tail cloud-init logs, with fallback to seed-setup log
|
|
209
|
+
// Using -n +0 to output all existing content, then follow for new lines
|
|
210
|
+
sshCmd.push(
|
|
211
|
+
"-p", "22",
|
|
212
|
+
`${user}@${host}`,
|
|
213
|
+
"tail -n +0 -f /var/log/cloud-init-output.log 2>/dev/null || tail -n +0 -f /var/log/seed-setup.log 2>/dev/null || echo 'Waiting for bootstrap logs to become available...'"
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
console.log("[Bootstrap] Starting log streamer for", host);
|
|
217
|
+
|
|
218
|
+
// Spawn subprocess to tail logs
|
|
219
|
+
const proc = Bun.spawn(sshCmd, {
|
|
220
|
+
stdout: "pipe",
|
|
221
|
+
stderr: "pipe",
|
|
222
|
+
env: {
|
|
223
|
+
...process.env,
|
|
224
|
+
// Suppress SSH warnings
|
|
225
|
+
LANG: "en_US.UTF-8",
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Read stdout and send to callback
|
|
230
|
+
const reader = proc.stdout.getReader();
|
|
231
|
+
const decoder = new TextDecoder();
|
|
232
|
+
let buffer = "";
|
|
233
|
+
|
|
234
|
+
// Start reading in background
|
|
235
|
+
(async () => {
|
|
236
|
+
try {
|
|
237
|
+
while (!stopSignal.stopped) {
|
|
238
|
+
const { done, value } = await reader.read();
|
|
239
|
+
if (done) {
|
|
240
|
+
console.log("[Bootstrap] Log streamer EOF reached");
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
buffer += decoder.decode(value, { stream: true });
|
|
245
|
+
const lines = buffer.split("\n");
|
|
246
|
+
buffer = lines.pop() || ""; // Keep partial line in buffer
|
|
247
|
+
|
|
248
|
+
for (const line of lines) {
|
|
249
|
+
if (line) {
|
|
250
|
+
onOutput(line + "\n");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (!stopSignal.stopped) {
|
|
256
|
+
console.log("[Bootstrap] Log streamer error:", err);
|
|
257
|
+
}
|
|
258
|
+
} finally {
|
|
259
|
+
reader.releaseLock();
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
|
|
263
|
+
return proc;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error("[Bootstrap] Failed to start log streamer:", err);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Find or create a session for a host
|
|
272
|
+
* If sessionId is provided, try to reuse that specific session
|
|
273
|
+
* If sessionId is null/undefined, always create a new session (for multiple terminals)
|
|
274
|
+
* @param onProgress - Optional callback to send progress updates to WebSocket
|
|
275
|
+
* @param onBootstrapOutput - Optional callback to stream bootstrap log output to WebSocket
|
|
276
|
+
*/
|
|
277
|
+
export async function getOrCreateSession(
|
|
278
|
+
host: string,
|
|
279
|
+
user: string = "root",
|
|
280
|
+
sessionId: string | null = null,
|
|
281
|
+
keyPath?: string,
|
|
282
|
+
onProgress?: (message: string, status: "info" | "success" | "error") => void,
|
|
283
|
+
environmentId?: string,
|
|
284
|
+
onBootstrapOutput?: (data: string) => void,
|
|
285
|
+
): Promise<TerminalSession> {
|
|
286
|
+
// Resolve relative key paths to absolute
|
|
287
|
+
keyPath = resolveKeyPath(keyPath);
|
|
288
|
+
|
|
289
|
+
// If specific sessionId requested, try to find and reuse it
|
|
290
|
+
if (sessionId) {
|
|
291
|
+
const existing = terminalSessions.get(sessionId);
|
|
292
|
+
if (existing && !existing.closed && existing.host === host && existing.user === user) {
|
|
293
|
+
console.log(
|
|
294
|
+
`[Terminal] Reusing requested session ${sessionId} for ${user}@${host}`,
|
|
295
|
+
);
|
|
296
|
+
existing.ws = null; // Will be set by caller
|
|
297
|
+
existing.lastUsed = Date.now();
|
|
298
|
+
return existing;
|
|
299
|
+
}
|
|
300
|
+
// Requested session not found or invalid - will create new below
|
|
301
|
+
console.log(
|
|
302
|
+
`[Terminal] Requested session ${sessionId} not found, creating new one`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Create new session (always creates when sessionId is null or not found)
|
|
307
|
+
const newSessionId = `term-${host.replace(/[^a-zA-Z0-9]/g, "_")}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
308
|
+
console.log(
|
|
309
|
+
`[Terminal] Creating new session ${newSessionId} for ${user}@${host}`,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const progress = (msg: string, status: "info" | "success" | "error" = "info") => {
|
|
313
|
+
console.log(`[Terminal] ${msg}`);
|
|
314
|
+
onProgress?.(msg, status);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// First, verify SSH is reachable before attempting seed installation
|
|
318
|
+
progress(`Waiting for SSH connection to ${host}...`);
|
|
319
|
+
let sshReachable = false;
|
|
320
|
+
let lastError: string | null = null;
|
|
321
|
+
const timeout = 5;
|
|
322
|
+
const maxAttempts = 5; // Increased attempts for faster retries
|
|
323
|
+
const retryDelayMs = 500; // Reduced from 1s to 500ms for faster recovery
|
|
324
|
+
|
|
325
|
+
// Resolve and validate SSH key path before connection attempts
|
|
326
|
+
let resolvedKeyPath: string | null = null;
|
|
327
|
+
let keyFileExists = false;
|
|
328
|
+
let keyFilePermissions = null;
|
|
329
|
+
let keyFileStats = null;
|
|
330
|
+
|
|
331
|
+
if (keyPath) {
|
|
332
|
+
try {
|
|
333
|
+
resolvedKeyPath = require('path').resolve(keyPath);
|
|
334
|
+
const keyStats = await import('fs').then(fs => resolvedKeyPath ? fs.promises.stat(resolvedKeyPath).catch(() => null) : null);
|
|
335
|
+
keyFileExists = keyStats !== null;
|
|
336
|
+
if (keyStats) {
|
|
337
|
+
keyFileStats = {
|
|
338
|
+
size: keyStats.size,
|
|
339
|
+
mode: keyStats.mode.toString(8),
|
|
340
|
+
isFile: keyStats.isFile(),
|
|
341
|
+
};
|
|
342
|
+
// Extract Unix permissions (last 3 octal digits)
|
|
343
|
+
keyFilePermissions = (keyStats.mode & parseInt('777', 8)).toString(8);
|
|
344
|
+
}
|
|
345
|
+
console.log(`[Terminal] SSH key validation: keyPath=${keyPath}, resolvedPath=${resolvedKeyPath}, exists=${keyFileExists}, permissions=${keyFilePermissions ?? 'N/A'}, stats=${keyFileStats ? JSON.stringify(keyFileStats) : 'N/A'}`);
|
|
346
|
+
} catch (pathErr) {
|
|
347
|
+
console.log(`[Terminal] SSH key path resolution failed: keyPath=${keyPath}, error=${pathErr instanceof Error ? pathErr.message : String(pathErr)}`);
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
console.log(`[Terminal] SSH key validation: no keyPath provided (will attempt ssh-agent)`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
console.log(`[Terminal] SSH connection parameters: target=${user}@${host}, timeout=${timeout}s, maxAttempts=${maxAttempts}, retryDelay=${retryDelayMs}ms, keyPath=${keyPath ?? 'none'}, resolvedKeyPath=${resolvedKeyPath ?? 'none'}, keyFileExists=${keyFileExists}, keyFilePermissions=${keyFilePermissions ?? 'N/A'}`);
|
|
354
|
+
|
|
355
|
+
// Try up to maxAttempts times with short timeout to check if SSH is reachable
|
|
356
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
357
|
+
const attempt = i + 1;
|
|
358
|
+
const attemptStart = Date.now();
|
|
359
|
+
try {
|
|
360
|
+
console.log(`[Terminal] SSH connection attempt ${attempt}/${maxAttempts} starting (attemptIdx=${i}, sshReachable=${sshReachable}, lastError=${lastError ?? 'null'})`);
|
|
361
|
+
const { execSSH } = await import("./client.js");
|
|
362
|
+
// Quick connection test with configurable timeout
|
|
363
|
+
await execSSH("echo ok", {
|
|
364
|
+
host,
|
|
365
|
+
user,
|
|
366
|
+
keyPath,
|
|
367
|
+
timeout,
|
|
368
|
+
});
|
|
369
|
+
const elapsed = Date.now() - attemptStart;
|
|
370
|
+
sshReachable = true;
|
|
371
|
+
console.log(`[Terminal] SSH connection successful on attempt ${attempt}/${maxAttempts} (elapsedMs=${elapsed}, sshReachable=true)`);
|
|
372
|
+
break;
|
|
373
|
+
} catch (err) {
|
|
374
|
+
const elapsed = Date.now() - attemptStart;
|
|
375
|
+
const errType = err instanceof Error ? err.constructor.name : typeof err;
|
|
376
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
377
|
+
const errName = err instanceof Error ? err.name : 'UnknownError';
|
|
378
|
+
// Extract cause from SSHError if available
|
|
379
|
+
const cause = (err as { cause?: unknown }).cause;
|
|
380
|
+
const causeType = cause instanceof Error ? cause.constructor.name : typeof cause;
|
|
381
|
+
const causeMsg = cause instanceof Error ? ` (cause: ${cause.message})` : cause ? ` (cause: ${String(cause)})` : "";
|
|
382
|
+
lastError = `${errorMsg}${causeMsg}`;
|
|
383
|
+
console.log(`[Terminal] SSH connection attempt ${attempt}/${maxAttempts} failed (elapsedMs=${elapsed}, errType=${errType}, errName=${errName}, causeType=${causeType}, sshReachable=${sshReachable}, lastError=${lastError})`);
|
|
384
|
+
// If it's a connection error, server might still be booting
|
|
385
|
+
if (i < maxAttempts - 1) {
|
|
386
|
+
// Wait a bit before retrying
|
|
387
|
+
console.log(`[Terminal] Waiting ${retryDelayMs}ms before retry ${attempt + 1}/${maxAttempts}...`);
|
|
388
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (sshReachable) {
|
|
394
|
+
// Check if metadata already says ready — skip polling entirely
|
|
395
|
+
if (environmentId) {
|
|
396
|
+
const metadata = loadMetadata();
|
|
397
|
+
const existing = metadata.getMetadata?.(environmentId);
|
|
398
|
+
if (existing?.bootstrapStatus === "ready") {
|
|
399
|
+
progress(`SSH connected. Environment ready.`, "success");
|
|
400
|
+
// Skip the bootstrap gate entirely
|
|
401
|
+
} else {
|
|
402
|
+
// Fall through to polling below
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Only poll if we don't already know the status
|
|
407
|
+
const metadata = loadMetadata();
|
|
408
|
+
const knownReady = environmentId && metadata.getMetadata
|
|
409
|
+
? metadata.getMetadata(environmentId)?.bootstrapStatus === "ready"
|
|
410
|
+
: false;
|
|
411
|
+
|
|
412
|
+
const bootstrapMaxAttempts = 60; // Poll up to 60 times
|
|
413
|
+
const bootstrapPollIntervalMs = 5000; // Every 5 seconds = 5 min max
|
|
414
|
+
let bootstrapComplete = knownReady;
|
|
415
|
+
|
|
416
|
+
// Start streaming bootstrap logs if callback provided
|
|
417
|
+
const stopLogStreaming = { stopped: false };
|
|
418
|
+
let logStreamer: Subprocess | null = null;
|
|
419
|
+
|
|
420
|
+
if (!knownReady) {
|
|
421
|
+
progress(`SSH connected. Checking bootstrap status...`);
|
|
422
|
+
|
|
423
|
+
// Start streaming logs if callback is provided
|
|
424
|
+
if (onBootstrapOutput) {
|
|
425
|
+
logStreamer = await streamCloudInitLogs(
|
|
426
|
+
host,
|
|
427
|
+
user,
|
|
428
|
+
keyPath,
|
|
429
|
+
onBootstrapOutput,
|
|
430
|
+
stopLogStreaming,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
for (let attempt = 0; !bootstrapComplete && attempt < bootstrapMaxAttempts; attempt++) {
|
|
436
|
+
try {
|
|
437
|
+
const { execSSH } = await import("./client.js");
|
|
438
|
+
const result = await execSSH(
|
|
439
|
+
"cat /root/.bootstrap-status 2>/dev/null || echo 'status=missing'",
|
|
440
|
+
{ host, user, keyPath, timeout: 10 }
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const output = (typeof result === "string" ? result : String(result)).trim();
|
|
444
|
+
// bootstrap-status is appended to — check if the final status= line says complete
|
|
445
|
+
const statusLines = output.split("\n").filter((l: string) => l.startsWith("status="));
|
|
446
|
+
const lastStatus = statusLines.length > 0 ? statusLines[statusLines.length - 1] : "";
|
|
447
|
+
|
|
448
|
+
if (lastStatus === "status=complete") {
|
|
449
|
+
bootstrapComplete = true;
|
|
450
|
+
progress(`Bootstrap complete.`, "success");
|
|
451
|
+
// Stop log streaming
|
|
452
|
+
stopLogStreaming.stopped = true;
|
|
453
|
+
if (logStreamer) {
|
|
454
|
+
try {
|
|
455
|
+
logStreamer.kill();
|
|
456
|
+
} catch {
|
|
457
|
+
// Already killed
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Update metadata so the frontend knows this env is ready
|
|
461
|
+
if (environmentId) {
|
|
462
|
+
try {
|
|
463
|
+
const metadata = loadMetadata();
|
|
464
|
+
const existing = metadata.getMetadata?.(environmentId);
|
|
465
|
+
if (existing && existing.bootstrapStatus !== "ready" && metadata.setMetadata) {
|
|
466
|
+
metadata.setMetadata({ ...existing, bootstrapStatus: "ready" });
|
|
467
|
+
}
|
|
468
|
+
} catch { /* non-fatal */ }
|
|
469
|
+
}
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (lastStatus === "status=missing") {
|
|
474
|
+
// No bootstrap-status file — server may not use cloud-init, allow through
|
|
475
|
+
console.log(`[Terminal] No /root/.bootstrap-status found, skipping gate`);
|
|
476
|
+
bootstrapComplete = true;
|
|
477
|
+
progress(`No bootstrap status file found — skipping readiness gate.`);
|
|
478
|
+
// Stop log streaming
|
|
479
|
+
stopLogStreaming.stopped = true;
|
|
480
|
+
if (logStreamer) {
|
|
481
|
+
try {
|
|
482
|
+
logStreamer.kill();
|
|
483
|
+
} catch {
|
|
484
|
+
// Already killed
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (environmentId) {
|
|
488
|
+
try {
|
|
489
|
+
const metadata = loadMetadata();
|
|
490
|
+
const existing = metadata.getMetadata?.(environmentId);
|
|
491
|
+
if (existing && existing.bootstrapStatus !== "ready" && metadata.setMetadata) {
|
|
492
|
+
metadata.setMetadata({ ...existing, bootstrapStatus: "ready" });
|
|
493
|
+
}
|
|
494
|
+
} catch { /* non-fatal */ }
|
|
495
|
+
}
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Still in progress
|
|
500
|
+
if (attempt === 0) {
|
|
501
|
+
progress(`Server is still bootstrapping. Waiting for cloud-init to finish...`);
|
|
502
|
+
} else if (attempt % 6 === 0) {
|
|
503
|
+
// Log progress every ~30 seconds
|
|
504
|
+
progress(`Still bootstrapping... (${attempt * 5}s elapsed)`);
|
|
505
|
+
}
|
|
506
|
+
} catch (pollErr) {
|
|
507
|
+
console.log(`[Terminal] Bootstrap status poll attempt ${attempt + 1} failed: ${pollErr instanceof Error ? pollErr.message : String(pollErr)}`);
|
|
508
|
+
// SSH might have dropped during heavy bootstrap — keep trying
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
await new Promise(resolve => setTimeout(resolve, bootstrapPollIntervalMs));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Clean up log streamer if still running
|
|
515
|
+
if (!bootstrapComplete) {
|
|
516
|
+
stopLogStreaming.stopped = true;
|
|
517
|
+
if (logStreamer) {
|
|
518
|
+
try {
|
|
519
|
+
logStreamer.kill();
|
|
520
|
+
} catch {
|
|
521
|
+
// Already killed
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
progress(`Bootstrap did not complete within 5 minutes. Connecting anyway...`, "error");
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
// SSH not reachable - server is likely still booting
|
|
528
|
+
progress(`Server not ready yet (${lastError}) - tried 3 attempts with ${timeout}s timeout each`, "info");
|
|
529
|
+
console.log(`[Terminal] All SSH connection attempts exhausted, continuing anyway - frontend will retry`);
|
|
530
|
+
// Continue to create SSH connection anyway - frontend has retry logic
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Use tmux for persistent sessions
|
|
534
|
+
// This will install tmux if not present, then create/attach to session
|
|
535
|
+
let sshCmd: string[];
|
|
536
|
+
let tmuxSessionName: string | undefined;
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
const tmuxResult = await createOrAttachTmuxSession(host, user, keyPath);
|
|
540
|
+
sshCmd = tmuxResult.sshArgs;
|
|
541
|
+
tmuxSessionName = tmuxResult.sessionName;
|
|
542
|
+
console.log(`[Terminal] Using tmux session: ${tmuxSessionName}${tmuxResult.newlyCreated ? ' (newly created)' : ' (reattached)'}`);
|
|
543
|
+
} catch (tmuxError) {
|
|
544
|
+
console.error(`[Terminal] tmux setup failed: ${tmuxError}`);
|
|
545
|
+
// Fallback to plain SSH without tmux
|
|
546
|
+
sshCmd = [
|
|
547
|
+
"ssh",
|
|
548
|
+
"-F", "/dev/null", // Skip user SSH config to avoid conflicts
|
|
549
|
+
"-o", "StrictHostKeyChecking=no",
|
|
550
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
551
|
+
"-o", "ServerAliveInterval=30",
|
|
552
|
+
"-o", "ServerAliveCountMax=3",
|
|
553
|
+
"-p", "22",
|
|
554
|
+
];
|
|
555
|
+
if (keyPath) {
|
|
556
|
+
// Ensure keyPath is properly escaped for SSH
|
|
557
|
+
// Bun.spawn() passes args directly without shell, but SSH -i needs proper path handling
|
|
558
|
+
// When keyPath contains spaces, we need to ensure it's a single argument
|
|
559
|
+
sshCmd.push("-i", String(keyPath));
|
|
560
|
+
}
|
|
561
|
+
sshCmd.push("-t", "-t", `${user}@${host}`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
console.log("[Terminal] Spawning SSH:", sshCmd.join(" "));
|
|
565
|
+
|
|
566
|
+
const proc = Bun.spawn(sshCmd, {
|
|
567
|
+
stdin: "pipe",
|
|
568
|
+
stdout: "pipe",
|
|
569
|
+
stderr: "pipe",
|
|
570
|
+
env: {
|
|
571
|
+
...process.env,
|
|
572
|
+
TERM: "xterm-256color",
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const stdin = proc.stdin;
|
|
577
|
+
const stdout = proc.stdout as ReadableStream<Uint8Array>;
|
|
578
|
+
const stderr = proc.stderr as ReadableStream<Uint8Array>;
|
|
579
|
+
|
|
580
|
+
const session: TerminalSession = {
|
|
581
|
+
sessionId: newSessionId,
|
|
582
|
+
proc,
|
|
583
|
+
stdin,
|
|
584
|
+
stdout,
|
|
585
|
+
stderr,
|
|
586
|
+
host,
|
|
587
|
+
user,
|
|
588
|
+
ws: null,
|
|
589
|
+
createdAt: Date.now(),
|
|
590
|
+
lastUsed: Date.now(),
|
|
591
|
+
reader: null,
|
|
592
|
+
stderrReader: null,
|
|
593
|
+
writer: null,
|
|
594
|
+
closed: false,
|
|
595
|
+
tmuxSessionName,
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
terminalSessions.set(newSessionId, session);
|
|
599
|
+
|
|
600
|
+
// Handle process exit
|
|
601
|
+
proc.exited
|
|
602
|
+
.then((exitCode) => {
|
|
603
|
+
console.log(
|
|
604
|
+
`[Terminal] Process for ${newSessionId} exited with code ${exitCode}`,
|
|
605
|
+
);
|
|
606
|
+
session.closed = true;
|
|
607
|
+
try {
|
|
608
|
+
session.ws?.close(
|
|
609
|
+
WebSocketCloseCode.NORMAL_CLOSURE,
|
|
610
|
+
`SSH process exited (code: ${exitCode})`
|
|
611
|
+
);
|
|
612
|
+
} catch {
|
|
613
|
+
// Already closed
|
|
614
|
+
}
|
|
615
|
+
terminalSessions.delete(newSessionId);
|
|
616
|
+
})
|
|
617
|
+
.catch((err) => {
|
|
618
|
+
console.log(`[Terminal] Process exit error for ${newSessionId}:`, err);
|
|
619
|
+
session.closed = true;
|
|
620
|
+
try {
|
|
621
|
+
session.ws?.close(
|
|
622
|
+
WebSocketCloseCode.INTERNAL_ERROR,
|
|
623
|
+
"SSH process error"
|
|
624
|
+
);
|
|
625
|
+
} catch {
|
|
626
|
+
// Already closed
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
return session;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Get a session by ID
|
|
635
|
+
*/
|
|
636
|
+
export function getSession(sessionId: string): TerminalSession | undefined {
|
|
637
|
+
return terminalSessions.get(sessionId);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Get all active sessions
|
|
642
|
+
*/
|
|
643
|
+
export function getAllSessions(): TerminalSession[] {
|
|
644
|
+
return Array.from(terminalSessions.values());
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Get session info for all sessions (safe for API responses)
|
|
649
|
+
*/
|
|
650
|
+
export function getAllSessionInfo(): SessionInfo[] {
|
|
651
|
+
const now = Date.now();
|
|
652
|
+
return Array.from(terminalSessions.values()).map((session) => ({
|
|
653
|
+
sessionId: session.sessionId,
|
|
654
|
+
host: session.host,
|
|
655
|
+
user: session.user,
|
|
656
|
+
createdAt: session.createdAt,
|
|
657
|
+
lastUsed: session.lastUsed,
|
|
658
|
+
hasActiveWebSocket: !!session.ws,
|
|
659
|
+
closed: session.closed,
|
|
660
|
+
uptime: now - session.createdAt,
|
|
661
|
+
idleTime: now - session.lastUsed,
|
|
662
|
+
}));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Get session info for a specific session
|
|
667
|
+
*/
|
|
668
|
+
export function getSessionInfo(sessionId: string): SessionInfo | undefined {
|
|
669
|
+
const session = terminalSessions.get(sessionId);
|
|
670
|
+
if (!session) return undefined;
|
|
671
|
+
|
|
672
|
+
const now = Date.now();
|
|
673
|
+
return {
|
|
674
|
+
sessionId: session.sessionId,
|
|
675
|
+
host: session.host,
|
|
676
|
+
user: session.user,
|
|
677
|
+
createdAt: session.createdAt,
|
|
678
|
+
lastUsed: session.lastUsed,
|
|
679
|
+
hasActiveWebSocket: !!session.ws,
|
|
680
|
+
closed: session.closed,
|
|
681
|
+
uptime: now - session.createdAt,
|
|
682
|
+
idleTime: now - session.lastUsed,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Get the total number of active sessions
|
|
688
|
+
*/
|
|
689
|
+
export function getSessionCount(): number {
|
|
690
|
+
return terminalSessions.size;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Find sessions by host
|
|
695
|
+
*/
|
|
696
|
+
export function getSessionsByHost(host: string): TerminalSession[] {
|
|
697
|
+
return Array.from(terminalSessions.values()).filter(
|
|
698
|
+
(s) => s.host === host && !s.closed
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Attach a WebSocket to a session
|
|
704
|
+
* Sets up stdin/stdout/stderr streaming
|
|
705
|
+
*/
|
|
706
|
+
export function attachWebSocket(
|
|
707
|
+
session: TerminalSession,
|
|
708
|
+
ws: ServerWebSocket<unknown>,
|
|
709
|
+
wasReused: boolean = false
|
|
710
|
+
): void {
|
|
711
|
+
session.ws = ws;
|
|
712
|
+
session.lastUsed = Date.now();
|
|
713
|
+
|
|
714
|
+
// Send session info to client
|
|
715
|
+
ws.send(
|
|
716
|
+
JSON.stringify({
|
|
717
|
+
type: "session",
|
|
718
|
+
sessionId: session.sessionId,
|
|
719
|
+
existing: wasReused,
|
|
720
|
+
host: session.host,
|
|
721
|
+
user: session.user,
|
|
722
|
+
}),
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
// Start or resume reading stderr
|
|
726
|
+
if (!session.stderrReader) {
|
|
727
|
+
const readStderr = async () => {
|
|
728
|
+
const reader = session.stderr.getReader();
|
|
729
|
+
session.stderrReader = reader;
|
|
730
|
+
try {
|
|
731
|
+
while (!session.closed) {
|
|
732
|
+
const { value, done } = await reader.read();
|
|
733
|
+
if (done || session.closed) break;
|
|
734
|
+
if (value && session.ws) {
|
|
735
|
+
const text = new TextDecoder().decode(value);
|
|
736
|
+
console.log(`[Terminal] stderr: ${text.trim()}`);
|
|
737
|
+
try {
|
|
738
|
+
session.ws.send(text);
|
|
739
|
+
} catch {
|
|
740
|
+
// WebSocket closed, stop reading temporarily
|
|
741
|
+
session.ws = null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
} catch (err) {
|
|
746
|
+
if (!session.closed) {
|
|
747
|
+
console.log(`[Terminal] stderr read error:`, err);
|
|
748
|
+
}
|
|
749
|
+
} finally {
|
|
750
|
+
if (session.closed) {
|
|
751
|
+
session.stderrReader = null;
|
|
752
|
+
reader.releaseLock();
|
|
753
|
+
}
|
|
754
|
+
// Keep reader open for potential reconnection
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
readStderr();
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Start or resume reading stdout
|
|
761
|
+
if (!session.reader) {
|
|
762
|
+
const readOutput = async () => {
|
|
763
|
+
const reader = session.stdout.getReader();
|
|
764
|
+
session.reader = reader;
|
|
765
|
+
|
|
766
|
+
try {
|
|
767
|
+
while (!session.closed) {
|
|
768
|
+
const { value, done } = await reader.read();
|
|
769
|
+
if (done || session.closed) {
|
|
770
|
+
console.log(`[Terminal] stdout closed for ${session.sessionId}`);
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (value) {
|
|
775
|
+
const text = new TextDecoder().decode(value);
|
|
776
|
+
if (session.ws) {
|
|
777
|
+
try {
|
|
778
|
+
session.ws.send(text);
|
|
779
|
+
} catch {
|
|
780
|
+
// WebSocket closed, stop reading temporarily
|
|
781
|
+
session.ws = null;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
} catch (err) {
|
|
787
|
+
if (!session.closed) {
|
|
788
|
+
console.log(`[Terminal] stdout read error:`, err);
|
|
789
|
+
}
|
|
790
|
+
} finally {
|
|
791
|
+
if (session.closed) {
|
|
792
|
+
reader.releaseLock();
|
|
793
|
+
}
|
|
794
|
+
// Keep reader open for potential reconnection
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
readOutput();
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Write data to a session's stdin
|
|
803
|
+
*/
|
|
804
|
+
export async function writeToSession(
|
|
805
|
+
sessionId: string,
|
|
806
|
+
data: string
|
|
807
|
+
): Promise<boolean> {
|
|
808
|
+
const session = terminalSessions.get(sessionId);
|
|
809
|
+
if (!session) {
|
|
810
|
+
console.log(`[Terminal] Write failed: session ${sessionId} not found`);
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
if (session.closed) {
|
|
814
|
+
console.log(`[Terminal] Write failed: session ${sessionId} is closed`);
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
try {
|
|
819
|
+
await session.stdin.write(data);
|
|
820
|
+
session.lastUsed = Date.now();
|
|
821
|
+
return true;
|
|
822
|
+
} catch (err) {
|
|
823
|
+
console.log(`[Terminal] Write error for ${sessionId}:`, err);
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Resize a session's PTY
|
|
830
|
+
*/
|
|
831
|
+
export async function resizeSession(
|
|
832
|
+
sessionId: string,
|
|
833
|
+
rows: number,
|
|
834
|
+
cols: number
|
|
835
|
+
): Promise<boolean> {
|
|
836
|
+
const session = terminalSessions.get(sessionId);
|
|
837
|
+
if (!session || session.closed) return false;
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
// Send SIGWINCH via escape sequence
|
|
841
|
+
await session.stdin.write(`\u001B[8;${rows};${cols}t`);
|
|
842
|
+
session.lastUsed = Date.now();
|
|
843
|
+
return true;
|
|
844
|
+
} catch {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Detach WebSocket from session (without closing session)
|
|
851
|
+
*/
|
|
852
|
+
export function detachWebSocket(sessionId: string, ws: ServerWebSocket): boolean {
|
|
853
|
+
const session = terminalSessions.get(sessionId);
|
|
854
|
+
if (!session || session.ws !== ws) return false;
|
|
855
|
+
|
|
856
|
+
session.ws = null;
|
|
857
|
+
console.log(
|
|
858
|
+
`[Terminal] Detached WebSocket from session ${sessionId}, keeping SSH alive`,
|
|
859
|
+
);
|
|
860
|
+
return true;
|
|
861
|
+
}
|