heyvm 1.0.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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +621 -0
  3. package/bin/file-browser +0 -0
  4. package/bin/heyvm +0 -0
  5. package/bin/heyvm-core +0 -0
  6. package/bin/heyvm.js +61 -0
  7. package/core/README.md +51 -0
  8. package/core/cmd/heyvm-core/main.go +73 -0
  9. package/core/go.mod +25 -0
  10. package/core/go.sum +47 -0
  11. package/core/internal/auth/errors.go +29 -0
  12. package/core/internal/auth/password.go +187 -0
  13. package/core/internal/auth/provider.go +33 -0
  14. package/core/internal/auth/ssh_key.go +142 -0
  15. package/core/internal/config/config.go +111 -0
  16. package/core/internal/ipc/actions.go +738 -0
  17. package/core/internal/ipc/handler.go +281 -0
  18. package/core/internal/ipc/protocol.go +126 -0
  19. package/core/internal/sftp/errors.go +29 -0
  20. package/core/internal/sftp/manager.go +303 -0
  21. package/core/internal/sftp/types.go +30 -0
  22. package/core/internal/ssh/errors.go +23 -0
  23. package/core/internal/ssh/manager.go +226 -0
  24. package/core/internal/ssh/session.go +105 -0
  25. package/core/internal/vm/errors.go +35 -0
  26. package/core/internal/vm/models.go +84 -0
  27. package/core/internal/vm/registry.go +240 -0
  28. package/package.json +59 -0
  29. package/scripts/install.js +100 -0
  30. package/ui/README.md +43 -0
  31. package/ui/dist/App.d.ts +3 -0
  32. package/ui/dist/App.d.ts.map +1 -0
  33. package/ui/dist/App.js +142 -0
  34. package/ui/dist/App.js.map +1 -0
  35. package/ui/dist/components/ConfirmDialog.d.ts +9 -0
  36. package/ui/dist/components/ConfirmDialog.d.ts.map +1 -0
  37. package/ui/dist/components/ConfirmDialog.js +22 -0
  38. package/ui/dist/components/ConfirmDialog.js.map +1 -0
  39. package/ui/dist/components/ErrorMessage.d.ts +8 -0
  40. package/ui/dist/components/ErrorMessage.d.ts.map +1 -0
  41. package/ui/dist/components/ErrorMessage.js +10 -0
  42. package/ui/dist/components/ErrorMessage.js.map +1 -0
  43. package/ui/dist/components/Header.d.ts +8 -0
  44. package/ui/dist/components/Header.d.ts.map +1 -0
  45. package/ui/dist/components/Header.js +10 -0
  46. package/ui/dist/components/Header.js.map +1 -0
  47. package/ui/dist/components/LoadingSpinner.d.ts +7 -0
  48. package/ui/dist/components/LoadingSpinner.d.ts.map +1 -0
  49. package/ui/dist/components/LoadingSpinner.js +7 -0
  50. package/ui/dist/components/LoadingSpinner.js.map +1 -0
  51. package/ui/dist/components/StatusBar.d.ts +11 -0
  52. package/ui/dist/components/StatusBar.d.ts.map +1 -0
  53. package/ui/dist/components/StatusBar.js +11 -0
  54. package/ui/dist/components/StatusBar.js.map +1 -0
  55. package/ui/dist/core/ipc.d.ts +96 -0
  56. package/ui/dist/core/ipc.d.ts.map +1 -0
  57. package/ui/dist/core/ipc.js +310 -0
  58. package/ui/dist/core/ipc.js.map +1 -0
  59. package/ui/dist/core/types.d.ts +45 -0
  60. package/ui/dist/core/types.d.ts.map +1 -0
  61. package/ui/dist/core/types.js +3 -0
  62. package/ui/dist/core/types.js.map +1 -0
  63. package/ui/dist/hooks/useFiles.d.ts +14 -0
  64. package/ui/dist/hooks/useFiles.d.ts.map +1 -0
  65. package/ui/dist/hooks/useFiles.js +102 -0
  66. package/ui/dist/hooks/useFiles.js.map +1 -0
  67. package/ui/dist/hooks/useVM.d.ts +10 -0
  68. package/ui/dist/hooks/useVM.d.ts.map +1 -0
  69. package/ui/dist/hooks/useVM.js +54 -0
  70. package/ui/dist/hooks/useVM.js.map +1 -0
  71. package/ui/dist/hooks/useVMList.d.ts +10 -0
  72. package/ui/dist/hooks/useVMList.d.ts.map +1 -0
  73. package/ui/dist/hooks/useVMList.js +56 -0
  74. package/ui/dist/hooks/useVMList.js.map +1 -0
  75. package/ui/dist/index.d.ts +3 -0
  76. package/ui/dist/index.d.ts.map +1 -0
  77. package/ui/dist/index.js +7488 -0
  78. package/ui/dist/index.js.map +1 -0
  79. package/ui/dist/keybindings.d.ts +146 -0
  80. package/ui/dist/keybindings.d.ts.map +1 -0
  81. package/ui/dist/keybindings.js +96 -0
  82. package/ui/dist/keybindings.js.map +1 -0
  83. package/ui/dist/screens/AddVMScreen.d.ts +9 -0
  84. package/ui/dist/screens/AddVMScreen.d.ts.map +1 -0
  85. package/ui/dist/screens/AddVMScreen.js +163 -0
  86. package/ui/dist/screens/AddVMScreen.js.map +1 -0
  87. package/ui/dist/screens/VMDetailScreen.d.ts +12 -0
  88. package/ui/dist/screens/VMDetailScreen.d.ts.map +1 -0
  89. package/ui/dist/screens/VMDetailScreen.js +96 -0
  90. package/ui/dist/screens/VMDetailScreen.js.map +1 -0
  91. package/ui/dist/screens/VMListScreen.d.ts +12 -0
  92. package/ui/dist/screens/VMListScreen.d.ts.map +1 -0
  93. package/ui/dist/screens/VMListScreen.js +158 -0
  94. package/ui/dist/screens/VMListScreen.js.map +1 -0
  95. package/ui/dist/screens/tabs/FilesTab.d.ts +9 -0
  96. package/ui/dist/screens/tabs/FilesTab.d.ts.map +1 -0
  97. package/ui/dist/screens/tabs/FilesTab.js +374 -0
  98. package/ui/dist/screens/tabs/FilesTab.js.map +1 -0
  99. package/ui/dist/screens/tabs/OverviewTab.d.ts +10 -0
  100. package/ui/dist/screens/tabs/OverviewTab.d.ts.map +1 -0
  101. package/ui/dist/screens/tabs/OverviewTab.js +110 -0
  102. package/ui/dist/screens/tabs/OverviewTab.js.map +1 -0
  103. package/ui/dist/screens/tabs/TerminalTab.d.ts +9 -0
  104. package/ui/dist/screens/tabs/TerminalTab.d.ts.map +1 -0
  105. package/ui/dist/screens/tabs/TerminalTab.js +270 -0
  106. package/ui/dist/screens/tabs/TerminalTab.js.map +1 -0
  107. package/ui/dist/utils/terminalEmulator.d.ts +49 -0
  108. package/ui/dist/utils/terminalEmulator.d.ts.map +1 -0
  109. package/ui/dist/utils/terminalEmulator.js +88 -0
  110. package/ui/dist/utils/terminalEmulator.js.map +1 -0
  111. package/ui/package.json +34 -0
  112. package/ui/scripts/start-with-core.js +81 -0
@@ -0,0 +1,303 @@
1
+ package sftp
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "os"
7
+ "path/filepath"
8
+
9
+ "github.com/pkg/sftp"
10
+ "golang.org/x/crypto/ssh"
11
+ )
12
+
13
+ // Manager handles SFTP file operations
14
+ type Manager struct {
15
+ client *sftp.Client
16
+ ssh *ssh.Client
17
+ }
18
+
19
+ // NewManager creates a new SFTP manager from an SSH client
20
+ func NewManager(sshClient *ssh.Client) (*Manager, error) {
21
+ if sshClient == nil {
22
+ return nil, fmt.Errorf("SSH client cannot be nil")
23
+ }
24
+
25
+ // Create SFTP client
26
+ client, err := sftp.NewClient(sshClient)
27
+ if err != nil {
28
+ return nil, fmt.Errorf("failed to create SFTP client: %w", err)
29
+ }
30
+
31
+ return &Manager{
32
+ client: client,
33
+ ssh: sshClient,
34
+ }, nil
35
+ }
36
+
37
+ // List lists files and directories at the specified path
38
+ func (m *Manager) List(path string) ([]FileInfo, error) {
39
+ if m.client == nil {
40
+ return nil, ErrSFTPClientNotInitialized
41
+ }
42
+
43
+ // Read directory
44
+ entries, err := m.client.ReadDir(path)
45
+ if err != nil {
46
+ return nil, fmt.Errorf("failed to read directory: %w", err)
47
+ }
48
+
49
+ // Convert to FileInfo
50
+ files := make([]FileInfo, 0, len(entries))
51
+ for _, entry := range entries {
52
+ files = append(files, FileInfo{
53
+ Name: entry.Name(),
54
+ Size: entry.Size(),
55
+ Mode: entry.Mode(),
56
+ ModTime: entry.ModTime(),
57
+ IsDir: entry.IsDir(),
58
+ })
59
+ }
60
+
61
+ return files, nil
62
+ }
63
+
64
+ // Upload uploads a local file to a remote path
65
+ func (m *Manager) Upload(localPath, remotePath string, opts *TransferOptions) error {
66
+ if m.client == nil {
67
+ return ErrSFTPClientNotInitialized
68
+ }
69
+
70
+ if opts == nil {
71
+ opts = &TransferOptions{Overwrite: true}
72
+ }
73
+
74
+ // Open local file
75
+ localFile, err := os.Open(localPath)
76
+ if err != nil {
77
+ return fmt.Errorf("failed to open local file: %w", err)
78
+ }
79
+ defer localFile.Close()
80
+
81
+ // Get file info for size and permissions
82
+ localInfo, err := localFile.Stat()
83
+ if err != nil {
84
+ return fmt.Errorf("failed to stat local file: %w", err)
85
+ }
86
+
87
+ // Check if remote file exists
88
+ if !opts.Overwrite {
89
+ if _, err := m.client.Stat(remotePath); err == nil {
90
+ return ErrFileAlreadyExists
91
+ }
92
+ }
93
+
94
+ // Create remote file
95
+ remoteFile, err := m.client.Create(remotePath)
96
+ if err != nil {
97
+ return fmt.Errorf("failed to create remote file: %w", err)
98
+ }
99
+ defer remoteFile.Close()
100
+
101
+ // Copy file with progress tracking
102
+ totalBytes := localInfo.Size()
103
+ var bytesTransferred int64
104
+
105
+ // Use 128KB buffer for better throughput on large files
106
+ buf := make([]byte, 128*1024)
107
+ for {
108
+ n, err := localFile.Read(buf)
109
+ if err != nil && err != io.EOF {
110
+ return fmt.Errorf("%w: %v", ErrTransferFailed, err)
111
+ }
112
+
113
+ if n > 0 {
114
+ if _, err := remoteFile.Write(buf[:n]); err != nil {
115
+ return fmt.Errorf("%w: %v", ErrTransferFailed, err)
116
+ }
117
+
118
+ bytesTransferred += int64(n)
119
+
120
+ // Report progress
121
+ if opts.OnProgress != nil {
122
+ opts.OnProgress(bytesTransferred, totalBytes)
123
+ }
124
+ }
125
+
126
+ if err == io.EOF {
127
+ break
128
+ }
129
+ }
130
+
131
+ // Preserve permissions if requested
132
+ if opts.PreservePermissions {
133
+ if err := m.client.Chmod(remotePath, localInfo.Mode()); err != nil {
134
+ // Don't fail the transfer if chmod fails, just log
135
+ fmt.Fprintf(os.Stderr, "Warning: failed to set permissions on %s: %v\n", remotePath, err)
136
+ }
137
+ }
138
+
139
+ return nil
140
+ }
141
+
142
+ // Download downloads a remote file to a local path
143
+ func (m *Manager) Download(remotePath, localPath string, opts *TransferOptions) error {
144
+ if m.client == nil {
145
+ return ErrSFTPClientNotInitialized
146
+ }
147
+
148
+ if opts == nil {
149
+ opts = &TransferOptions{Overwrite: true}
150
+ }
151
+
152
+ // Open remote file
153
+ remoteFile, err := m.client.Open(remotePath)
154
+ if err != nil {
155
+ return fmt.Errorf("failed to open remote file: %w", err)
156
+ }
157
+ defer remoteFile.Close()
158
+
159
+ // Get file info for size
160
+ remoteInfo, err := remoteFile.Stat()
161
+ if err != nil {
162
+ return fmt.Errorf("failed to stat remote file: %w", err)
163
+ }
164
+
165
+ if remoteInfo.IsDir() {
166
+ return ErrIsDirectory
167
+ }
168
+
169
+ // Check if local file exists
170
+ if !opts.Overwrite {
171
+ if _, err := os.Stat(localPath); err == nil {
172
+ return ErrFileAlreadyExists
173
+ }
174
+ }
175
+
176
+ // Create local file
177
+ localFile, err := os.Create(localPath)
178
+ if err != nil {
179
+ return fmt.Errorf("failed to create local file: %w", err)
180
+ }
181
+ defer localFile.Close()
182
+
183
+ // Copy file with progress tracking
184
+ totalBytes := remoteInfo.Size()
185
+ var bytesTransferred int64
186
+
187
+ // Use 128KB buffer for better throughput on large files
188
+ buf := make([]byte, 128*1024)
189
+ for {
190
+ n, err := remoteFile.Read(buf)
191
+ if err != nil && err != io.EOF {
192
+ return fmt.Errorf("%w: %v", ErrTransferFailed, err)
193
+ }
194
+
195
+ if n > 0 {
196
+ if _, err := localFile.Write(buf[:n]); err != nil {
197
+ return fmt.Errorf("%w: %v", ErrTransferFailed, err)
198
+ }
199
+
200
+ bytesTransferred += int64(n)
201
+
202
+ // Report progress
203
+ if opts.OnProgress != nil {
204
+ opts.OnProgress(bytesTransferred, totalBytes)
205
+ }
206
+ }
207
+
208
+ if err == io.EOF {
209
+ break
210
+ }
211
+ }
212
+
213
+ // Preserve permissions if requested
214
+ if opts.PreservePermissions {
215
+ if err := os.Chmod(localPath, remoteInfo.Mode()); err != nil {
216
+ // Don't fail the transfer if chmod fails, just log
217
+ fmt.Fprintf(os.Stderr, "Warning: failed to set permissions on %s: %v\n", localPath, err)
218
+ }
219
+ }
220
+
221
+ return nil
222
+ }
223
+
224
+ // Delete deletes a file or directory
225
+ func (m *Manager) Delete(path string) error {
226
+ if m.client == nil {
227
+ return ErrSFTPClientNotInitialized
228
+ }
229
+
230
+ // Check if path is a directory
231
+ info, err := m.client.Stat(path)
232
+ if err != nil {
233
+ return fmt.Errorf("failed to stat path: %w", err)
234
+ }
235
+
236
+ if info.IsDir() {
237
+ // Remove directory (must be empty)
238
+ if err := m.client.RemoveDirectory(path); err != nil {
239
+ return fmt.Errorf("failed to remove directory: %w", err)
240
+ }
241
+ } else {
242
+ // Remove file
243
+ if err := m.client.Remove(path); err != nil {
244
+ return fmt.Errorf("failed to remove file: %w", err)
245
+ }
246
+ }
247
+
248
+ return nil
249
+ }
250
+
251
+ // Rename renames or moves a file/directory
252
+ func (m *Manager) Rename(oldPath, newPath string) error {
253
+ if m.client == nil {
254
+ return ErrSFTPClientNotInitialized
255
+ }
256
+
257
+ if err := m.client.Rename(oldPath, newPath); err != nil {
258
+ return fmt.Errorf("failed to rename: %w", err)
259
+ }
260
+
261
+ return nil
262
+ }
263
+
264
+ // MkdirAll creates a directory and all necessary parent directories
265
+ func (m *Manager) MkdirAll(path string) error {
266
+ if m.client == nil {
267
+ return ErrSFTPClientNotInitialized
268
+ }
269
+
270
+ if err := m.client.MkdirAll(path); err != nil {
271
+ return fmt.Errorf("failed to create directory: %w", err)
272
+ }
273
+
274
+ return nil
275
+ }
276
+
277
+ // Stat returns file/directory information
278
+ func (m *Manager) Stat(path string) (*FileInfo, error) {
279
+ if m.client == nil {
280
+ return nil, ErrSFTPClientNotInitialized
281
+ }
282
+
283
+ info, err := m.client.Stat(path)
284
+ if err != nil {
285
+ return nil, fmt.Errorf("failed to stat path: %w", err)
286
+ }
287
+
288
+ return &FileInfo{
289
+ Name: filepath.Base(path),
290
+ Size: info.Size(),
291
+ Mode: info.Mode(),
292
+ ModTime: info.ModTime(),
293
+ IsDir: info.IsDir(),
294
+ }, nil
295
+ }
296
+
297
+ // Close closes the SFTP client
298
+ func (m *Manager) Close() error {
299
+ if m.client != nil {
300
+ return m.client.Close()
301
+ }
302
+ return nil
303
+ }
@@ -0,0 +1,30 @@
1
+ package sftp
2
+
3
+ import (
4
+ "os"
5
+ "time"
6
+ )
7
+
8
+ // FileInfo represents information about a file or directory
9
+ type FileInfo struct {
10
+ Name string `json:"name"`
11
+ Size int64 `json:"size"`
12
+ Mode os.FileMode `json:"mode"`
13
+ ModTime time.Time `json:"modTime"`
14
+ IsDir bool `json:"isDir"`
15
+ }
16
+
17
+ // ProgressCallback is called during file transfers to report progress
18
+ type ProgressCallback func(bytesTransferred, totalBytes int64)
19
+
20
+ // TransferOptions contains options for file transfers
21
+ type TransferOptions struct {
22
+ // Progress callback (optional)
23
+ OnProgress ProgressCallback
24
+
25
+ // Overwrite existing files
26
+ Overwrite bool
27
+
28
+ // Preserve file permissions
29
+ PreservePermissions bool
30
+ }
@@ -0,0 +1,23 @@
1
+ package ssh
2
+
3
+ import "errors"
4
+
5
+ var (
6
+ // ErrNotConnected is returned when attempting operations on a disconnected client
7
+ ErrNotConnected = errors.New("SSH client not connected")
8
+
9
+ // ErrAlreadyConnected is returned when attempting to connect an already connected client
10
+ ErrAlreadyConnected = errors.New("SSH client already connected")
11
+
12
+ // ErrSessionFailed is returned when SSH session creation fails
13
+ ErrSessionFailed = errors.New("failed to create SSH session")
14
+
15
+ // ErrCommandFailed is returned when command execution fails
16
+ ErrCommandFailed = errors.New("command execution failed")
17
+
18
+ // ErrPTYFailed is returned when PTY allocation fails
19
+ ErrPTYFailed = errors.New("failed to allocate PTY")
20
+
21
+ // ErrSessionClosed is returned when operating on a closed session
22
+ ErrSessionClosed = errors.New("session is closed")
23
+ )
@@ -0,0 +1,226 @@
1
+ package ssh
2
+
3
+ import (
4
+ "bytes"
5
+ "fmt"
6
+ "io"
7
+ "sync"
8
+
9
+ "github.com/adishm/heyvm/internal/auth"
10
+ "github.com/adishm/heyvm/internal/vm"
11
+ "golang.org/x/crypto/ssh"
12
+ )
13
+
14
+ // Manager manages SSH connections and sessions
15
+ type Manager struct {
16
+ vm *vm.VM
17
+ auth auth.Provider
18
+ client *ssh.Client
19
+ sessions map[string]*Session
20
+ mu sync.RWMutex
21
+ }
22
+
23
+ // NewManager creates a new SSH manager
24
+ func NewManager(v *vm.VM, authProvider auth.Provider) *Manager {
25
+ return &Manager{
26
+ vm: v,
27
+ auth: authProvider,
28
+ sessions: make(map[string]*Session),
29
+ }
30
+ }
31
+
32
+ // Connect establishes an SSH connection
33
+ // This method is idempotent - if already connected, it returns nil
34
+ func (m *Manager) Connect() error {
35
+ m.mu.Lock()
36
+ defer m.mu.Unlock()
37
+
38
+ // Idempotent: if already connected, just return success
39
+ if m.client != nil {
40
+ return nil
41
+ }
42
+
43
+ client, err := m.auth.Connect(m.vm)
44
+ if err != nil {
45
+ return fmt.Errorf("connection failed: %w", err)
46
+ }
47
+
48
+ m.client = client
49
+ return nil
50
+ }
51
+
52
+ // Disconnect closes the SSH connection and all sessions
53
+ func (m *Manager) Disconnect() error {
54
+ m.mu.Lock()
55
+ defer m.mu.Unlock()
56
+
57
+ // Close all sessions
58
+ for _, session := range m.sessions {
59
+ session.Close()
60
+ }
61
+ m.sessions = make(map[string]*Session)
62
+
63
+ // Close client
64
+ if m.client != nil {
65
+ if err := m.client.Close(); err != nil {
66
+ return fmt.Errorf("failed to close SSH client: %w", err)
67
+ }
68
+ m.client = nil
69
+ }
70
+
71
+ return nil
72
+ }
73
+
74
+ // IsConnected returns true if the SSH client is connected
75
+ func (m *Manager) IsConnected() bool {
76
+ m.mu.RLock()
77
+ defer m.mu.RUnlock()
78
+ return m.client != nil
79
+ }
80
+
81
+ // ExecuteCommand executes a single command and returns the output
82
+ func (m *Manager) ExecuteCommand(cmd string) (string, error) {
83
+ m.mu.RLock()
84
+ client := m.client
85
+ m.mu.RUnlock()
86
+
87
+ if client == nil {
88
+ return "", ErrNotConnected
89
+ }
90
+
91
+ // Create a new session for this command
92
+ session, err := client.NewSession()
93
+ if err != nil {
94
+ return "", fmt.Errorf("%w: %v", ErrSessionFailed, err)
95
+ }
96
+ defer session.Close()
97
+
98
+ // Capture output
99
+ var stdout, stderr bytes.Buffer
100
+ session.Stdout = &stdout
101
+ session.Stderr = &stderr
102
+
103
+ // Execute command
104
+ if err := session.Run(cmd); err != nil {
105
+ // Include stderr in error if command failed
106
+ if stderr.Len() > 0 {
107
+ return "", fmt.Errorf("%w: %v (stderr: %s)", ErrCommandFailed, err, stderr.String())
108
+ }
109
+ return "", fmt.Errorf("%w: %v", ErrCommandFailed, err)
110
+ }
111
+
112
+ return stdout.String(), nil
113
+ }
114
+
115
+ // StartPTY starts an interactive PTY session
116
+ func (m *Manager) StartPTY(rows, cols int) (*Session, error) {
117
+ m.mu.RLock()
118
+ client := m.client
119
+ m.mu.RUnlock()
120
+
121
+ if client == nil {
122
+ return nil, ErrNotConnected
123
+ }
124
+
125
+ // Create SSH session
126
+ sshSession, err := client.NewSession()
127
+ if err != nil {
128
+ return nil, fmt.Errorf("%w: %v", ErrSessionFailed, err)
129
+ }
130
+
131
+ // Request PTY
132
+ modes := ssh.TerminalModes{
133
+ ssh.ECHO: 1, // Enable echoing
134
+ ssh.TTY_OP_ISPEED: 14400, // Input speed = 14.4kbaud
135
+ ssh.TTY_OP_OSPEED: 14400, // Output speed = 14.4kbaud
136
+ }
137
+
138
+ if err := sshSession.RequestPty("xterm-256color", rows, cols, modes); err != nil {
139
+ sshSession.Close()
140
+ return nil, fmt.Errorf("%w: %v", ErrPTYFailed, err)
141
+ }
142
+
143
+ // Get pipes for stdin/stdout/stderr
144
+ stdin, err := sshSession.StdinPipe()
145
+ if err != nil {
146
+ sshSession.Close()
147
+ return nil, fmt.Errorf("failed to get stdin pipe: %w", err)
148
+ }
149
+
150
+ stdout, err := sshSession.StdoutPipe()
151
+ if err != nil {
152
+ sshSession.Close()
153
+ return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
154
+ }
155
+
156
+ stderr, err := sshSession.StderrPipe()
157
+ if err != nil {
158
+ sshSession.Close()
159
+ return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
160
+ }
161
+
162
+ // Start shell
163
+ if err := sshSession.Shell(); err != nil {
164
+ sshSession.Close()
165
+ return nil, fmt.Errorf("failed to start shell: %w", err)
166
+ }
167
+
168
+ // Merge stdout and stderr into single stream
169
+ // This prevents SSH deadlocks and ensures all output is captured
170
+ combinedOutput := io.MultiReader(stdout, stderr)
171
+
172
+ // Create session wrapper
173
+ session := &Session{
174
+ session: sshSession,
175
+ stdin: stdin,
176
+ combinedOutput: combinedOutput,
177
+ rows: rows,
178
+ cols: cols,
179
+ }
180
+
181
+ // Store session
182
+ m.mu.Lock()
183
+ m.sessions[session.ID()] = session
184
+ m.mu.Unlock()
185
+
186
+ return session, nil
187
+ }
188
+
189
+ // GetSession retrieves a session by ID
190
+ func (m *Manager) GetSession(id string) (*Session, error) {
191
+ m.mu.RLock()
192
+ defer m.mu.RUnlock()
193
+
194
+ session, exists := m.sessions[id]
195
+ if !exists {
196
+ return nil, fmt.Errorf("session not found: %s", id)
197
+ }
198
+
199
+ return session, nil
200
+ }
201
+
202
+ // CloseSession closes a specific session
203
+ func (m *Manager) CloseSession(id string) error {
204
+ m.mu.Lock()
205
+ defer m.mu.Unlock()
206
+
207
+ session, exists := m.sessions[id]
208
+ if !exists {
209
+ return fmt.Errorf("session not found: %s", id)
210
+ }
211
+
212
+ if err := session.Close(); err != nil {
213
+ return fmt.Errorf("failed to close session: %w", err)
214
+ }
215
+
216
+ delete(m.sessions, id)
217
+ return nil
218
+ }
219
+
220
+ // GetClient returns the underlying SSH client
221
+ // This is useful for creating SFTP clients
222
+ func (m *Manager) GetClient() *ssh.Client {
223
+ m.mu.RLock()
224
+ defer m.mu.RUnlock()
225
+ return m.client
226
+ }
@@ -0,0 +1,105 @@
1
+ package ssh
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "encoding/hex"
6
+ "fmt"
7
+ "io"
8
+ "sync"
9
+
10
+ cryptossh "golang.org/x/crypto/ssh"
11
+ )
12
+
13
+ // Session represents an interactive PTY session
14
+ type Session struct {
15
+ id string
16
+ session *cryptossh.Session
17
+ stdin io.WriteCloser
18
+ combinedOutput io.Reader // stdout + stderr merged
19
+ rows int
20
+ cols int
21
+ closed bool
22
+ mu sync.Mutex
23
+ }
24
+
25
+ // ID returns the session ID
26
+ func (s *Session) ID() string {
27
+ if s.id == "" {
28
+ s.id = generateSessionID()
29
+ }
30
+ return s.id
31
+ }
32
+
33
+ // Write sends data to the session's stdin
34
+ func (s *Session) Write(data []byte) (int, error) {
35
+ s.mu.Lock()
36
+ defer s.mu.Unlock()
37
+
38
+ if s.closed {
39
+ return 0, ErrSessionClosed
40
+ }
41
+
42
+ return s.stdin.Write(data)
43
+ }
44
+
45
+ // CombinedOutput returns the merged stdout+stderr reader - for SINGLE goroutine use only
46
+ func (s *Session) CombinedOutput() io.Reader {
47
+ return s.combinedOutput
48
+ }
49
+
50
+ // Resize changes the terminal size
51
+ func (s *Session) Resize(rows, cols int) error {
52
+ s.mu.Lock()
53
+ defer s.mu.Unlock()
54
+
55
+ if s.closed {
56
+ return ErrSessionClosed
57
+ }
58
+
59
+ if err := s.session.WindowChange(rows, cols); err != nil {
60
+ return fmt.Errorf("failed to resize window: %w", err)
61
+ }
62
+
63
+ s.rows = rows
64
+ s.cols = cols
65
+ return nil
66
+ }
67
+
68
+ // Close closes the session
69
+ func (s *Session) Close() error {
70
+ s.mu.Lock()
71
+ defer s.mu.Unlock()
72
+
73
+ if s.closed {
74
+ return nil
75
+ }
76
+
77
+ s.closed = true
78
+
79
+ if s.session != nil {
80
+ return s.session.Close()
81
+ }
82
+
83
+ return nil
84
+ }
85
+
86
+ // IsClosed returns true if the session is closed
87
+ func (s *Session) IsClosed() bool {
88
+ s.mu.Lock()
89
+ defer s.mu.Unlock()
90
+ return s.closed
91
+ }
92
+
93
+ // GetSize returns the current terminal size
94
+ func (s *Session) GetSize() (rows, cols int) {
95
+ s.mu.Lock()
96
+ defer s.mu.Unlock()
97
+ return s.rows, s.cols
98
+ }
99
+
100
+ // generateSessionID generates a unique session ID
101
+ func generateSessionID() string {
102
+ b := make([]byte, 8)
103
+ rand.Read(b)
104
+ return hex.EncodeToString(b)
105
+ }
@@ -0,0 +1,35 @@
1
+ package vm
2
+
3
+ import "errors"
4
+
5
+ var (
6
+ // ErrVMNotFound is returned when a VM is not found in the registry
7
+ ErrVMNotFound = errors.New("VM not found")
8
+
9
+ // ErrVMAlreadyExists is returned when trying to add a VM with duplicate ID
10
+ ErrVMAlreadyExists = errors.New("VM already exists")
11
+
12
+ // ErrInvalidVMName is returned when VM name is empty
13
+ ErrInvalidVMName = errors.New("VM name cannot be empty")
14
+
15
+ // ErrInvalidVMHost is returned when VM host is empty
16
+ ErrInvalidVMHost = errors.New("VM host cannot be empty")
17
+
18
+ // ErrInvalidVMPort is returned when VM port is invalid
19
+ ErrInvalidVMPort = errors.New("VM port must be between 1 and 65535")
20
+
21
+ // ErrInvalidVMUsername is returned when VM username is empty
22
+ ErrInvalidVMUsername = errors.New("VM username cannot be empty")
23
+
24
+ // ErrInvalidAuthType is returned when auth type is not recognized
25
+ ErrInvalidAuthType = errors.New("invalid authentication type")
26
+
27
+ // ErrMissingKeyPath is returned when SSH key auth is selected but no key path provided
28
+ ErrMissingKeyPath = errors.New("SSH key path is required for key authentication")
29
+
30
+ // ErrConfigNotFound is returned when config file doesn't exist
31
+ ErrConfigNotFound = errors.New("configuration file not found")
32
+
33
+ // ErrConfigCorrupted is returned when config file is malformed
34
+ ErrConfigCorrupted = errors.New("configuration file is corrupted")
35
+ )