@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/pty.ts ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * SSH PTY session manager for interactive terminal sessions
3
+ * Handles bidirectional communication with remote shells
4
+ */
5
+
6
+ import type { SSHOptions } from "./types.js";
7
+
8
+ interface PTYSession {
9
+ id: string;
10
+ host: string;
11
+ user: string;
12
+ proc: any;
13
+ stdin: WritableStream<Uint8Array>;
14
+ stdout: ReadableStream<Uint8Array>;
15
+ stderr: ReadableStream<Uint8Array>;
16
+ rows: number;
17
+ cols: number;
18
+ createdAt: number;
19
+ }
20
+
21
+ // Active PTY sessions storage
22
+ const activeSessions = new Map<string, PTYSession>();
23
+
24
+ /**
25
+ * Generate a unique session ID
26
+ */
27
+ function generateSessionId(): string {
28
+ return `pty-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
29
+ }
30
+
31
+ /**
32
+ * Create a new SSH PTY session
33
+ * Uses script or expect to wrap SSH with PTY allocation
34
+ */
35
+ export async function createPTYSession(
36
+ host: string,
37
+ user: string = "root",
38
+ options: {
39
+ rows?: number;
40
+ cols?: number;
41
+ port?: number;
42
+ keyPath?: string;
43
+ } = {},
44
+ ): Promise<{ sessionId: string; initialOutput: string }> {
45
+ const { rows = 24, cols = 80, port = 22, keyPath } = options;
46
+ const sessionId = generateSessionId();
47
+
48
+ try {
49
+ // Build SSH command with explicit PTY request
50
+ const sshArgs = [
51
+ "ssh",
52
+ "-F",
53
+ "/dev/null", // Skip user SSH config to avoid conflicts
54
+ "-o",
55
+ "StrictHostKeyChecking=no",
56
+ "-o",
57
+ "UserKnownHostsFile=/dev/null",
58
+ "-o",
59
+ "ServerAliveInterval=30",
60
+ "-o",
61
+ "ServerAliveCountMax=3",
62
+ "-p",
63
+ port.toString(),
64
+ ];
65
+
66
+ if (keyPath) {
67
+ // Ensure keyPath is properly escaped for SSH
68
+ // Bun.spawn() passes args directly without shell, but SSH -i needs proper path handling
69
+ // When keyPath contains spaces, we need to ensure it's a single argument
70
+ sshArgs.push("-i", String(keyPath));
71
+ }
72
+
73
+ // Use script command to ensure PTY allocation
74
+ // This creates a proper terminal session with full VT100 support
75
+ sshArgs.push("-t", "-t");
76
+ sshArgs.push(`${user}@${host}`);
77
+ sshArgs.push("TERM=xterm-256color");
78
+ sshArgs.push("script", "-q", "/dev/null", "/bin/bash");
79
+
80
+ const proc = Bun.spawn(sshArgs, {
81
+ stdin: "pipe",
82
+ stdout: "pipe",
83
+ stderr: "pipe",
84
+ env: {
85
+ ...process.env,
86
+ COLUMNS: cols.toString(),
87
+ LINES: rows.toString(),
88
+ TERM: "xterm-256color",
89
+ },
90
+ });
91
+
92
+ // Set up streams
93
+ const stdin = proc.stdin as unknown as WritableStream<Uint8Array>;
94
+ const stdout = proc.stdout as ReadableStream<Uint8Array>;
95
+ const stderr = proc.stderr as ReadableStream<Uint8Array>;
96
+
97
+ // Wait a moment for connection and initial output
98
+ await new Promise((resolve) => setTimeout(resolve, 500));
99
+
100
+ let initialOutput = "";
101
+
102
+ // Read initial output (non-blocking)
103
+ try {
104
+ const reader = stdout.getReader();
105
+ const { value, done } = await reader.read();
106
+ if (value && !done) {
107
+ initialOutput = new TextDecoder().decode(value);
108
+ }
109
+ reader.releaseLock();
110
+ } catch {
111
+ // Ignore initial read errors
112
+ }
113
+
114
+ // Store session
115
+ const session: PTYSession = {
116
+ id: sessionId,
117
+ host,
118
+ user,
119
+ proc,
120
+ stdin,
121
+ stdout,
122
+ stderr,
123
+ rows,
124
+ cols,
125
+ createdAt: Date.now(),
126
+ };
127
+
128
+ activeSessions.set(sessionId, session);
129
+
130
+ // Set up cleanup on process exit
131
+ proc.exited.then(() => {
132
+ activeSessions.delete(sessionId);
133
+ });
134
+
135
+ return { sessionId, initialOutput };
136
+ } catch (error) {
137
+ throw new Error(`Failed to create PTY session: ${error}`);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Write data to PTY session stdin
143
+ */
144
+ export async function writeToPTY(
145
+ sessionId: string,
146
+ data: string,
147
+ ): Promise<boolean> {
148
+ const session = activeSessions.get(sessionId);
149
+ if (!session) {
150
+ return false;
151
+ }
152
+
153
+ try {
154
+ const writer = session.stdin.getWriter();
155
+ await writer.write(new TextEncoder().encode(data));
156
+ writer.releaseLock();
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Set PTY size (rows and columns)
165
+ */
166
+ export async function setPTYSize(
167
+ sessionId: string,
168
+ rows: number,
169
+ cols: number,
170
+ ): Promise<boolean> {
171
+ const session = activeSessions.get(sessionId);
172
+ if (!session) {
173
+ return false;
174
+ }
175
+
176
+ try {
177
+ // Send SIGWINCH to resize the terminal
178
+ // We need to use stty or similar command on remote side
179
+ const resizeCmd = `\u001B[8;${rows};${cols}t`;
180
+ const writer = session.stdin.getWriter();
181
+ await writer.write(new TextEncoder().encode(resizeCmd));
182
+ writer.releaseLock();
183
+
184
+ session.rows = rows;
185
+ session.cols = cols;
186
+ return true;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Read from PTY session stdout (non-blocking)
194
+ */
195
+ export async function readFromPTY(
196
+ sessionId: string,
197
+ timeout: number = 100,
198
+ ): Promise<string | null> {
199
+ const session = activeSessions.get(sessionId);
200
+ if (!session) {
201
+ return null;
202
+ }
203
+
204
+ try {
205
+ const reader = session.stdout.getReader();
206
+ const { value, done } = await Promise.race([
207
+ reader.read(),
208
+ new Promise<{ value: Uint8Array | undefined; done: boolean }>((resolve) =>
209
+ setTimeout(() => resolve({ value: undefined, done: false }), timeout),
210
+ ),
211
+ ]);
212
+ reader.releaseLock();
213
+
214
+ if (done) {
215
+ return null; // Session closed
216
+ }
217
+
218
+ if (value) {
219
+ return new TextDecoder().decode(value);
220
+ }
221
+
222
+ return "";
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Close PTY session
230
+ */
231
+ export async function closePTYSession(sessionId: string): Promise<boolean> {
232
+ const session = activeSessions.get(sessionId);
233
+ if (!session) {
234
+ return false;
235
+ }
236
+
237
+ try {
238
+ // Send exit sequence to gracefully close
239
+ const writer = session.stdin.getWriter();
240
+ await writer.write(new TextEncoder().encode("exit\r\n"));
241
+ writer.releaseLock();
242
+
243
+ // Give it a moment to close gracefully
244
+ await new Promise((resolve) => setTimeout(resolve, 200));
245
+
246
+ // Kill the process if still running
247
+ try {
248
+ session.proc.kill();
249
+ } catch {
250
+ // Process already terminated
251
+ }
252
+
253
+ activeSessions.delete(sessionId);
254
+ return true;
255
+ } catch {
256
+ activeSessions.delete(sessionId);
257
+ return false;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Get session info
263
+ */
264
+ export function getPTYSession(sessionId: string): PTYSession | undefined {
265
+ return activeSessions.get(sessionId);
266
+ }
267
+
268
+ /**
269
+ * Get all active sessions
270
+ */
271
+ export function getActivePTYSessions(): PTYSession[] {
272
+ return Array.from(activeSessions.values());
273
+ }
274
+
275
+ /**
276
+ * Clean up stale sessions (older than specified milliseconds)
277
+ */
278
+ export function cleanupStaleSessions(maxAge: number = 3600000): void {
279
+ const now = Date.now();
280
+ for (const [id, session] of activeSessions.entries()) {
281
+ if (now - session.createdAt > maxAge) {
282
+ closePTYSession(id);
283
+ }
284
+ }
285
+ }
package/src/scp.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * SCP/SFTP file transfer operations
3
+ * Uses SSH connection pool and SFTP for efficient transfers
4
+ */
5
+
6
+ import { z } from 'zod'
7
+ import type { SCPOptions } from "./types.js";
8
+ import { SSHError } from "./error.js";
9
+ import { SCPOptionsSchema } from '@ebowwa/codespaces-types/runtime/ssh'
10
+ import { getSSHPool } from './pool.js'
11
+
12
+ /**
13
+ * Upload a file to remote server via SFTP
14
+ * @param options - SCP options including source and destination
15
+ * @returns True if successful
16
+ */
17
+ export async function scpUpload(options: SCPOptions): Promise<boolean> {
18
+ // Validate inputs with Zod
19
+ const validated = SCPOptionsSchema.safeParse(options)
20
+ if (!validated.success) {
21
+ throw new Error(`Invalid SCP options: ${validated.error.issues.map(i => i.message).join(', ')}`)
22
+ }
23
+
24
+ const {
25
+ host,
26
+ user = "root",
27
+ timeout = 30,
28
+ port = 22,
29
+ keyPath,
30
+ source,
31
+ destination,
32
+ recursive = false,
33
+ preserve = false,
34
+ } = validated.data;
35
+
36
+ try {
37
+ const pool = getSSHPool()
38
+ const ssh = await pool.getConnection({ host, user, port, keyPath, timeout })
39
+
40
+ // Use SFTP for file transfer
41
+ await ssh.putFile(source, destination, null, {
42
+ mode: recursive ? 'recursive' : undefined,
43
+ })
44
+
45
+ return true
46
+ } catch (error) {
47
+ throw new SSHError(`SFTP upload failed: ${source} -> ${destination}`, error);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Download a file from remote server via SFTP
53
+ * @param options - SCP options including source (remote) and destination (local)
54
+ * @returns True if successful
55
+ */
56
+ export async function scpDownload(options: SCPOptions): Promise<boolean> {
57
+ // Validate inputs with Zod
58
+ const validated = SCPOptionsSchema.safeParse(options)
59
+ if (!validated.success) {
60
+ throw new Error(`Invalid SCP options: ${validated.error.issues.map(i => i.message).join(', ')}`)
61
+ }
62
+
63
+ const {
64
+ host,
65
+ user = "root",
66
+ timeout = 30,
67
+ port = 22,
68
+ keyPath,
69
+ source,
70
+ destination,
71
+ recursive = false,
72
+ preserve = false,
73
+ } = validated.data;
74
+
75
+ try {
76
+ const pool = getSSHPool()
77
+ const ssh = await pool.getConnection({ host, user, port, keyPath, timeout })
78
+
79
+ // Use SFTP for file transfer
80
+ await ssh.getFile(destination, source, null, {
81
+ mode: recursive ? 'recursive' : undefined,
82
+ })
83
+
84
+ return true
85
+ } catch (error) {
86
+ throw new SSHError(
87
+ `SFTP download failed: ${source} -> ${destination}`,
88
+ error,
89
+ );
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Upload a directory to remote server via SFTP
95
+ * @param options - SCP options with source directory
96
+ * @returns True if successful
97
+ */
98
+ export async function scpUploadDirectory(options: SCPOptions): Promise<boolean> {
99
+ return scpUpload({ ...options, recursive: true })
100
+ }
101
+
102
+ /**
103
+ * Download a directory from remote server via SFTP
104
+ * @param options - SCP options with source directory
105
+ * @returns True if successful
106
+ */
107
+ export async function scpDownloadDirectory(options: SCPOptions): Promise<boolean> {
108
+ return scpDownload({ ...options, recursive: true })
109
+ }