@startanaicompany/cli 1.4.18 → 1.4.20
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/CLAUDE.md +84 -35
- package/bin/saac.js +1 -3
- package/package.json +3 -2
- package/src/commands/env.js +1 -1
- package/src/commands/logs.js +13 -4
- package/src/commands/shell.js +344 -107
package/CLAUDE.md
CHANGED
|
@@ -838,58 +838,102 @@ saac run -q npm test # Quiet mode (suppress warnings)
|
|
|
838
838
|
|
|
839
839
|
---
|
|
840
840
|
|
|
841
|
-
#### Shell Command Implementation
|
|
841
|
+
#### Shell Command Implementation (Project Aurora)
|
|
842
842
|
|
|
843
|
-
The `shell` command
|
|
843
|
+
The `shell` command provides a **TRUE remote shell** - you're actually inside the Docker container, not just a local shell with env vars loaded. This is powered by Project Aurora (WebSocket + Docker + tmux).
|
|
844
|
+
|
|
845
|
+
**Key Difference from Phase 1:**
|
|
846
|
+
- **OLD (Phase 1)**: Local shell with remote env vars → wrong filesystem, wrong tools, wrong paths
|
|
847
|
+
- **NEW (Aurora)**: Remote shell inside container → TRUE remote experience like SSH/Railway
|
|
844
848
|
|
|
845
849
|
**Usage:**
|
|
846
850
|
```bash
|
|
847
|
-
saac shell
|
|
848
|
-
saac shell --cmd zsh # Use specific shell
|
|
849
|
-
saac shell --sync # Force refresh env vars (skip cache)
|
|
851
|
+
saac shell # Connect to remote container shell
|
|
850
852
|
```
|
|
851
853
|
|
|
852
|
-
**
|
|
853
|
-
-
|
|
854
|
-
-
|
|
855
|
-
-
|
|
856
|
-
-
|
|
857
|
-
-
|
|
858
|
-
- Automatic cleanup when shell exits
|
|
859
|
-
|
|
860
|
-
**Shell-Specific Behavior:**
|
|
861
|
-
- **bash:** Uses `--rcfile` with temporary RC that sources env + user's `.bashrc`
|
|
862
|
-
- **zsh:** Uses `ZDOTDIR` with temporary `.zshrc` that sources env + user's `.zshrc`
|
|
863
|
-
- **other:** Fallback to bash with source then exec
|
|
854
|
+
**Architecture:**
|
|
855
|
+
- WebSocket client connects to backend server
|
|
856
|
+
- Backend creates Docker container with tmux session
|
|
857
|
+
- Commands sent via WebSocket, executed in container
|
|
858
|
+
- Terminal output captured from tmux and streamed back
|
|
859
|
+
- Session persists even after disconnect (up to 1 hour idle)
|
|
864
860
|
|
|
865
|
-
**
|
|
866
|
-
-
|
|
867
|
-
-
|
|
861
|
+
**Implementation Details:**
|
|
862
|
+
- Uses `ws` package for WebSocket client
|
|
863
|
+
- Connects to: `wss://apps.startanaicompany.com/api/v1/shell/connect`
|
|
864
|
+
- Authentication via session token or API key
|
|
865
|
+
- Readline interface for local terminal interaction
|
|
866
|
+
- Automatic reconnection on disconnect (up to 3 attempts)
|
|
867
|
+
- Heartbeat ping/pong every 30 seconds
|
|
868
|
+
|
|
869
|
+
**Session Lifecycle:**
|
|
870
|
+
1. CLI connects via WebSocket with auth token
|
|
871
|
+
2. Backend creates session record in database (status: 'creating')
|
|
872
|
+
3. Python daemon creates Docker container with tmux
|
|
873
|
+
4. Container ready, status updates to 'active'
|
|
874
|
+
5. User interacts with remote shell
|
|
875
|
+
6. On disconnect, session marked 'idle' (persists for 1 hour)
|
|
876
|
+
7. Idle timeout or manual termination cleans up container
|
|
877
|
+
|
|
878
|
+
**Features:**
|
|
879
|
+
- ✅ TRUE remote shell (inside container, not local)
|
|
880
|
+
- ✅ Persistent session (survives reconnections)
|
|
881
|
+
- ✅ Interactive commands (vim, nano, etc work!)
|
|
882
|
+
- ✅ Working directory persists between commands
|
|
883
|
+
- ✅ Command history (up arrow works)
|
|
884
|
+
- ✅ Real-time output streaming
|
|
885
|
+
- ✅ Ctrl+C sends to remote shell
|
|
886
|
+
|
|
887
|
+
**Connection Handling:**
|
|
888
|
+
- 30-second timeout for initial container creation
|
|
889
|
+
- Auto-reconnect on network issues (3 attempts, exponential backoff)
|
|
890
|
+
- Graceful cleanup on exit (Ctrl+D or "exit" command)
|
|
868
891
|
|
|
869
892
|
**User Experience:**
|
|
870
893
|
```bash
|
|
871
894
|
$ saac shell
|
|
872
|
-
✓ Environment variables retrieved
|
|
873
895
|
|
|
874
|
-
|
|
896
|
+
Remote Shell: my-app
|
|
897
|
+
|
|
898
|
+
Connecting to container...
|
|
899
|
+
This may take up to 30 seconds for container creation.
|
|
875
900
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
901
|
+
✓ Connected to remote container
|
|
902
|
+
Container is being created, please wait...
|
|
903
|
+
✓ Container is ready!
|
|
904
|
+
Type commands below. Press Ctrl+D or type "exit" to quit.
|
|
879
905
|
|
|
880
|
-
|
|
881
|
-
|
|
906
|
+
root@container:/app# ls -la
|
|
907
|
+
total 48
|
|
908
|
+
drwxr-xr-x 6 root root 4096 Jan 28 20:00 .
|
|
909
|
+
drwxr-xr-x 18 root root 4096 Jan 28 20:00 ..
|
|
910
|
+
-rw-r--r-- 1 root root 220 Jan 28 20:00 package.json
|
|
911
|
+
drwxr-xr-x 2 root root 4096 Jan 28 20:00 src
|
|
882
912
|
|
|
883
|
-
|
|
884
|
-
|
|
913
|
+
root@container:/app# cd src && pwd
|
|
914
|
+
/app/src
|
|
885
915
|
|
|
886
|
-
|
|
887
|
-
production
|
|
888
|
-
bash-5.0$ exit
|
|
916
|
+
root@container:/app/src# exit
|
|
889
917
|
|
|
890
|
-
|
|
918
|
+
Disconnecting from remote shell...
|
|
891
919
|
```
|
|
892
920
|
|
|
921
|
+
**Error Handling:**
|
|
922
|
+
- `Authentication failed` → Token expired, run `saac login`
|
|
923
|
+
- `Access denied` → User doesn't own the application
|
|
924
|
+
- `Connection timeout` → Server unavailable or container failed to start
|
|
925
|
+
- `Max reconnection attempts` → Network issue, try again later
|
|
926
|
+
|
|
927
|
+
**Backend Requirements:**
|
|
928
|
+
- WebSocket server at `/api/v1/shell/connect` endpoint
|
|
929
|
+
- PostgreSQL tables: `shell_sessions`, `shell_commands`, `shell_output`
|
|
930
|
+
- Python daemon managing Docker containers with tmux
|
|
931
|
+
- Session cleanup after 1 hour idle or 4 hours total
|
|
932
|
+
|
|
933
|
+
**Location:** `src/commands/shell.js` (403 lines, complete rewrite for Aurora)
|
|
934
|
+
|
|
935
|
+
**Note:** Backend infrastructure (WebSocket server + Python daemon) must be deployed before this command works. CLI is ready, waiting for backend Phase 3.5 deployment.
|
|
936
|
+
|
|
893
937
|
**Location:** `src/commands/shell.js`
|
|
894
938
|
|
|
895
939
|
---
|
|
@@ -1075,11 +1119,16 @@ The wrapper API expects Git repositories to be hosted on the StartAnAiCompany Gi
|
|
|
1075
1119
|
- ✅ `saac env set/get/list` - Manage environment variables (fully implemented)
|
|
1076
1120
|
- ✅ `saac domain set/show` - Manage application domain (fully implemented)
|
|
1077
1121
|
|
|
1078
|
-
**Local Development (
|
|
1122
|
+
**Local Development (Phase 1):**
|
|
1079
1123
|
- ✅ `saac run <command>` - Execute local command with remote environment variables
|
|
1080
|
-
- ✅ `saac shell` - Open interactive shell with remote environment variables
|
|
1081
1124
|
- Features: 5-minute caching, secure temp files (0600), automatic cleanup, rate limit handling
|
|
1082
1125
|
|
|
1126
|
+
**Remote Shell (Project Aurora - Phase 3.5):**
|
|
1127
|
+
- ✅ `saac shell` - TRUE remote shell inside Docker container via WebSocket
|
|
1128
|
+
- Features: Persistent sessions (tmux), interactive (vim/nano work), real-time output, auto-reconnect
|
|
1129
|
+
- Architecture: WebSocket client + PostgreSQL + Python daemon + Docker + tmux
|
|
1130
|
+
- **Status:** CLI ready, waiting for backend deployment
|
|
1131
|
+
|
|
1083
1132
|
**Remote Execution (NEW in 1.6.0):**
|
|
1084
1133
|
- ✅ `saac exec <command>` - Execute commands in remote container
|
|
1085
1134
|
- ✅ `saac exec --history` - View execution history
|
package/bin/saac.js
CHANGED
|
@@ -232,9 +232,7 @@ program
|
|
|
232
232
|
|
|
233
233
|
program
|
|
234
234
|
.command('shell')
|
|
235
|
-
.description('Open interactive shell
|
|
236
|
-
.option('--cmd <shell>', 'Shell to use (default: $SHELL or /bin/bash)')
|
|
237
|
-
.option('--sync', 'Force refresh environment variables (skip cache)')
|
|
235
|
+
.description('Open interactive remote shell (inside container via WebSocket)')
|
|
238
236
|
.action(shell);
|
|
239
237
|
|
|
240
238
|
// Remote execution commands
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startanaicompany/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.20",
|
|
4
4
|
"description": "Official CLI for StartAnAiCompany.com - Deploy AI recruitment sites with ease",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"open": "^8.4.2",
|
|
45
45
|
"ora": "^5.4.1",
|
|
46
46
|
"table": "^6.8.1",
|
|
47
|
-
"validator": "^13.11.0"
|
|
47
|
+
"validator": "^13.11.0",
|
|
48
|
+
"ws": "^8.19.0"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"eslint": "^8.54.0"
|
package/src/commands/env.js
CHANGED
package/src/commands/logs.js
CHANGED
|
@@ -188,11 +188,20 @@ async function getRuntimeLogs(applicationUuid, applicationName, options) {
|
|
|
188
188
|
logger.newline();
|
|
189
189
|
|
|
190
190
|
// Display logs
|
|
191
|
-
if (result.logs
|
|
192
|
-
result.logs
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
if (result.logs) {
|
|
192
|
+
if (Array.isArray(result.logs)) {
|
|
193
|
+
// Logs is an array
|
|
194
|
+
result.logs.forEach(log => {
|
|
195
|
+
console.log(log);
|
|
196
|
+
});
|
|
197
|
+
} else if (typeof result.logs === 'string') {
|
|
198
|
+
// Logs is a string (most common format from backend)
|
|
199
|
+
console.log(result.logs);
|
|
200
|
+
} else {
|
|
201
|
+
logger.warn('Unexpected log format');
|
|
202
|
+
}
|
|
195
203
|
} else if (typeof result === 'string') {
|
|
204
|
+
// Entire result is a string
|
|
196
205
|
console.log(result);
|
|
197
206
|
} else {
|
|
198
207
|
logger.warn('No logs available');
|
package/src/commands/shell.js
CHANGED
|
@@ -1,55 +1,321 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shell Command -
|
|
2
|
+
* Shell Command - Interactive remote shell via WebSocket (Project Aurora)
|
|
3
|
+
*
|
|
4
|
+
* This command provides a TRUE remote shell experience - you're actually inside
|
|
5
|
+
* the container, not just a local shell with env vars loaded.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
|
-
const
|
|
6
|
-
const
|
|
8
|
+
const WebSocket = require('ws');
|
|
9
|
+
const readline = require('readline');
|
|
10
|
+
const { getProjectConfig, isAuthenticated, getUser, getApiUrl } = require('../lib/config');
|
|
7
11
|
const logger = require('../lib/logger');
|
|
8
|
-
const { spawn } = require('child_process');
|
|
9
|
-
const fs = require('fs');
|
|
10
|
-
const path = require('path');
|
|
11
|
-
const os = require('os');
|
|
12
|
-
|
|
13
|
-
// In-memory cache for environment variables (5 minutes TTL)
|
|
14
|
-
const envCache = new Map();
|
|
15
|
-
const CACHE_TTL = 300000; // 5 minutes in milliseconds
|
|
16
12
|
|
|
17
13
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* @param {boolean} forceRefresh - Skip cache and fetch fresh
|
|
14
|
+
* WebSocket Shell Client
|
|
15
|
+
* Connects to backend WebSocket server and provides interactive shell
|
|
21
16
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
17
|
+
class ShellClient {
|
|
18
|
+
constructor(serverUrl, token, applicationUuid) {
|
|
19
|
+
this.serverUrl = serverUrl;
|
|
20
|
+
this.token = token;
|
|
21
|
+
this.applicationUuid = applicationUuid;
|
|
22
|
+
this.ws = null;
|
|
23
|
+
this.sessionId = null;
|
|
24
|
+
this.connected = false;
|
|
25
|
+
this.rl = null;
|
|
26
|
+
this.lastScreen = '';
|
|
27
|
+
this.reconnectAttempts = 0;
|
|
28
|
+
this.maxReconnectAttempts = 3;
|
|
29
|
+
this.shouldReconnect = true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async connect() {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
// Build WebSocket URL
|
|
35
|
+
const wsUrl = this.buildWebSocketUrl();
|
|
36
|
+
|
|
37
|
+
logger.info('Connecting to remote shell...');
|
|
38
|
+
|
|
39
|
+
this.ws = new WebSocket(wsUrl, {
|
|
40
|
+
headers: {
|
|
41
|
+
'X-Session-Token': this.token,
|
|
42
|
+
},
|
|
43
|
+
// Handle HTTPS/WSS
|
|
44
|
+
rejectUnauthorized: process.env.NODE_ENV === 'production'
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Connection timeout
|
|
48
|
+
const timeout = setTimeout(() => {
|
|
49
|
+
reject(new Error('Connection timeout'));
|
|
50
|
+
if (this.ws) {
|
|
51
|
+
this.ws.close();
|
|
52
|
+
}
|
|
53
|
+
}, 30000); // 30 second timeout for container creation
|
|
54
|
+
|
|
55
|
+
this.ws.on('open', () => {
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
this.connected = true;
|
|
58
|
+
this.reconnectAttempts = 0;
|
|
59
|
+
logger.success('Connected to remote container');
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
this.ws.on('message', (data) => {
|
|
64
|
+
try {
|
|
65
|
+
const message = JSON.parse(data.toString());
|
|
66
|
+
this.handleMessage(message);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
logger.error('Error parsing message:', err.message);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
this.ws.on('close', (code, reason) => {
|
|
73
|
+
this.connected = false;
|
|
74
|
+
const reasonText = reason ? reason.toString() : '';
|
|
75
|
+
|
|
76
|
+
if (code === 1000) {
|
|
77
|
+
// Normal closure
|
|
78
|
+
logger.info('Disconnected from remote shell');
|
|
79
|
+
} else {
|
|
80
|
+
logger.warn(`Disconnected: ${code}${reasonText ? ' - ' + reasonText : ''}`);
|
|
81
|
+
|
|
82
|
+
if (this.shouldReconnect && code !== 4001 && code !== 4003) {
|
|
83
|
+
this.handleReconnect();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.ws.on('error', (error) => {
|
|
89
|
+
clearTimeout(timeout);
|
|
90
|
+
|
|
91
|
+
// Check for specific error codes
|
|
92
|
+
if (error.message.includes('401') || error.message.includes('Unauthorized')) {
|
|
93
|
+
logger.error('Authentication failed');
|
|
94
|
+
reject(new Error('Authentication failed'));
|
|
95
|
+
} else if (error.message.includes('403') || error.message.includes('Forbidden')) {
|
|
96
|
+
logger.error('Access denied - you do not own this application');
|
|
97
|
+
reject(new Error('Access denied'));
|
|
98
|
+
} else {
|
|
99
|
+
logger.error('WebSocket error:', error.message);
|
|
100
|
+
reject(error);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
buildWebSocketUrl() {
|
|
107
|
+
// Convert HTTP(S) URL to WS(S) URL
|
|
108
|
+
const apiUrl = this.serverUrl;
|
|
109
|
+
let wsUrl = apiUrl.replace(/^http:\/\//, 'ws://').replace(/^https:\/\//, 'wss://');
|
|
110
|
+
|
|
111
|
+
// Remove /api/v1 suffix if present
|
|
112
|
+
wsUrl = wsUrl.replace(/\/api\/v1$/, '');
|
|
113
|
+
|
|
114
|
+
// Build shell connect endpoint
|
|
115
|
+
wsUrl = `${wsUrl}/api/v1/shell/connect?app=${this.applicationUuid}&token=${this.token}`;
|
|
116
|
+
|
|
117
|
+
return wsUrl;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
handleMessage(message) {
|
|
121
|
+
switch (message.type) {
|
|
122
|
+
case 'control':
|
|
123
|
+
this.handleControlMessage(message);
|
|
124
|
+
break;
|
|
125
|
+
|
|
126
|
+
case 'output':
|
|
127
|
+
this.handleOutputMessage(message);
|
|
128
|
+
break;
|
|
129
|
+
|
|
130
|
+
case 'pong':
|
|
131
|
+
// Heartbeat response
|
|
132
|
+
break;
|
|
133
|
+
|
|
134
|
+
default:
|
|
135
|
+
logger.warn('Unknown message type:', message.type);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
handleControlMessage(message) {
|
|
140
|
+
if (message.action === 'session_ready') {
|
|
141
|
+
this.sessionId = message.data.session_id;
|
|
142
|
+
const status = message.data.status;
|
|
143
|
+
|
|
144
|
+
if (status === 'creating') {
|
|
145
|
+
logger.info('Container is being created, please wait...');
|
|
146
|
+
} else if (status === 'active') {
|
|
147
|
+
logger.success('Shell session ready!');
|
|
148
|
+
logger.info('Type commands below. Press Ctrl+D or type "exit" to quit.');
|
|
149
|
+
logger.newline();
|
|
150
|
+
|
|
151
|
+
// Start terminal interface
|
|
152
|
+
this.startTerminal();
|
|
153
|
+
}
|
|
154
|
+
} else if (message.action === 'session_active') {
|
|
155
|
+
logger.success('Container is ready!');
|
|
156
|
+
logger.info('Type commands below. Press Ctrl+D or type "exit" to quit.');
|
|
157
|
+
logger.newline();
|
|
158
|
+
|
|
159
|
+
// Start terminal interface
|
|
160
|
+
if (!this.rl) {
|
|
161
|
+
this.startTerminal();
|
|
162
|
+
}
|
|
163
|
+
} else if (message.action === 'error') {
|
|
164
|
+
logger.error('Server error:', message.data.message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
handleOutputMessage(message) {
|
|
169
|
+
const screen = message.data.screen;
|
|
170
|
+
|
|
171
|
+
// Only update if screen changed
|
|
172
|
+
if (screen !== this.lastScreen) {
|
|
173
|
+
// Clear current readline prompt
|
|
174
|
+
if (this.rl) {
|
|
175
|
+
readline.clearLine(process.stdout, 0);
|
|
176
|
+
readline.cursorTo(process.stdout, 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Display full screen content
|
|
180
|
+
// For better UX, only show last 40 lines
|
|
181
|
+
const lines = screen.split('\n');
|
|
182
|
+
const displayLines = lines.slice(-40);
|
|
183
|
+
|
|
184
|
+
console.log(displayLines.join('\n'));
|
|
185
|
+
|
|
186
|
+
this.lastScreen = screen;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Show prompt again
|
|
190
|
+
if (this.rl) {
|
|
191
|
+
this.rl.prompt(true);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
startTerminal() {
|
|
196
|
+
// Create readline interface
|
|
197
|
+
this.rl = readline.createInterface({
|
|
198
|
+
input: process.stdin,
|
|
199
|
+
output: process.stdout,
|
|
200
|
+
prompt: '', // No prompt - we get it from the container
|
|
201
|
+
terminal: true,
|
|
202
|
+
historySize: 1000
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Handle user input
|
|
206
|
+
this.rl.on('line', (input) => {
|
|
207
|
+
const command = input.trim();
|
|
208
|
+
|
|
209
|
+
// Local exit command
|
|
210
|
+
if (command === 'exit' || command === 'quit') {
|
|
211
|
+
this.cleanup();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Send command to server
|
|
216
|
+
if (this.connected && this.ws.readyState === WebSocket.OPEN) {
|
|
217
|
+
this.sendCommand(command);
|
|
218
|
+
} else {
|
|
219
|
+
logger.error('Not connected to server');
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Handle Ctrl+C (SIGINT)
|
|
224
|
+
this.rl.on('SIGINT', () => {
|
|
225
|
+
// Send Ctrl+C to remote shell
|
|
226
|
+
if (this.connected) {
|
|
227
|
+
this.sendCommand('\x03'); // ASCII ETX (Ctrl+C)
|
|
31
228
|
}
|
|
32
|
-
|
|
33
|
-
|
|
229
|
+
this.rl.prompt();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Handle Ctrl+D (EOF) - exit
|
|
233
|
+
process.stdin.on('keypress', (str, key) => {
|
|
234
|
+
if (key && key.ctrl && key.name === 'd') {
|
|
235
|
+
this.cleanup();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Enable keypress events
|
|
240
|
+
if (process.stdin.isTTY) {
|
|
241
|
+
process.stdin.setRawMode(false); // Keep cooked mode for line editing
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
sendCommand(command) {
|
|
246
|
+
if (!this.connected || this.ws.readyState !== WebSocket.OPEN) {
|
|
247
|
+
logger.error('Not connected to server');
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const message = {
|
|
252
|
+
type: 'command',
|
|
253
|
+
data: {
|
|
254
|
+
command: command
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
this.ws.send(JSON.stringify(message));
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async handleReconnect() {
|
|
263
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
264
|
+
logger.error('Max reconnection attempts reached. Exiting.');
|
|
265
|
+
this.cleanup();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.reconnectAttempts++;
|
|
270
|
+
const delay = 2000 * this.reconnectAttempts; // Exponential backoff
|
|
271
|
+
|
|
272
|
+
logger.info(`Reconnecting in ${delay/1000}s... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
273
|
+
|
|
274
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
await this.connect();
|
|
278
|
+
} catch (err) {
|
|
279
|
+
logger.error('Reconnection failed:', err.message);
|
|
34
280
|
}
|
|
35
281
|
}
|
|
36
282
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
283
|
+
cleanup() {
|
|
284
|
+
logger.newline();
|
|
285
|
+
logger.info('Disconnecting from remote shell...');
|
|
286
|
+
|
|
287
|
+
this.shouldReconnect = false;
|
|
288
|
+
|
|
289
|
+
if (this.rl) {
|
|
290
|
+
this.rl.close();
|
|
291
|
+
this.rl = null;
|
|
292
|
+
}
|
|
40
293
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
294
|
+
if (this.ws) {
|
|
295
|
+
this.ws.close(1000, 'Client closing');
|
|
296
|
+
this.ws = null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Restore terminal
|
|
300
|
+
if (process.stdin.isTTY) {
|
|
301
|
+
process.stdin.setRawMode(false);
|
|
302
|
+
}
|
|
46
303
|
|
|
47
|
-
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
startHeartbeat() {
|
|
308
|
+
// Send ping every 30 seconds to keep connection alive
|
|
309
|
+
setInterval(() => {
|
|
310
|
+
if (this.connected && this.ws.readyState === WebSocket.OPEN) {
|
|
311
|
+
this.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
|
|
312
|
+
}
|
|
313
|
+
}, 30000);
|
|
314
|
+
}
|
|
48
315
|
}
|
|
49
316
|
|
|
50
317
|
/**
|
|
51
|
-
*
|
|
52
|
-
* @param {object} options - Command options
|
|
318
|
+
* Shell command main function
|
|
53
319
|
*/
|
|
54
320
|
async function shell(options = {}) {
|
|
55
321
|
try {
|
|
@@ -72,93 +338,64 @@ async function shell(options = {}) {
|
|
|
72
338
|
|
|
73
339
|
const { applicationUuid, applicationName } = projectConfig;
|
|
74
340
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
let envData;
|
|
80
|
-
try {
|
|
81
|
-
envData = await getEnvironmentVariables(applicationUuid, options.sync);
|
|
82
|
-
spin.succeed('Environment variables retrieved');
|
|
83
|
-
} catch (error) {
|
|
84
|
-
spin.fail('Failed to fetch environment variables');
|
|
85
|
-
|
|
86
|
-
if (error.response?.status === 429) {
|
|
87
|
-
const retryAfter = error.response.headers['retry-after'] || 60;
|
|
88
|
-
logger.newline();
|
|
89
|
-
logger.error(`Rate limit exceeded. Too many requests.`);
|
|
90
|
-
logger.info(`Retry in ${retryAfter} seconds.`);
|
|
91
|
-
logger.newline();
|
|
92
|
-
logger.info('Note: Environment variables are cached for 5 minutes.');
|
|
93
|
-
process.exit(1);
|
|
94
|
-
}
|
|
341
|
+
// Get user and token
|
|
342
|
+
const user = getUser();
|
|
343
|
+
const token = user.sessionToken || user.apiKey;
|
|
95
344
|
|
|
96
|
-
|
|
345
|
+
if (!token) {
|
|
346
|
+
logger.error('No authentication token found. Please login again.');
|
|
347
|
+
process.exit(1);
|
|
97
348
|
}
|
|
98
349
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Determine shell to use
|
|
102
|
-
const userShell = options.cmd || process.env.SHELL || '/bin/bash';
|
|
103
|
-
const shellName = path.basename(userShell);
|
|
350
|
+
// Get server URL
|
|
351
|
+
const serverUrl = getApiUrl();
|
|
104
352
|
|
|
105
|
-
//
|
|
106
|
-
logger.success(`🚀 Opening shell with ${envData.variable_count} environment variables loaded`);
|
|
353
|
+
// Show banner
|
|
107
354
|
logger.newline();
|
|
108
|
-
logger.
|
|
109
|
-
logger.field(' Shell', shellName);
|
|
110
|
-
logger.field(' Variables', envData.variable_count);
|
|
355
|
+
logger.section(`Remote Shell: ${applicationName}`);
|
|
111
356
|
logger.newline();
|
|
112
|
-
logger.
|
|
113
|
-
logger.
|
|
114
|
-
logger.info('Type "exit" or press Ctrl+D to close the shell');
|
|
115
|
-
logger.section('─'.repeat(60));
|
|
357
|
+
logger.info('Connecting to container...');
|
|
358
|
+
logger.info('This may take up to 30 seconds for container creation.');
|
|
116
359
|
logger.newline();
|
|
117
360
|
|
|
118
|
-
//
|
|
119
|
-
const
|
|
120
|
-
...process.env,
|
|
121
|
-
...envData.environment,
|
|
122
|
-
SAAC_ENV_LOADED: '1',
|
|
123
|
-
SAAC_APP_NAME: applicationName,
|
|
124
|
-
SAAC_APP_UUID: applicationUuid
|
|
125
|
-
};
|
|
361
|
+
// Create shell client
|
|
362
|
+
const client = new ShellClient(serverUrl, token, applicationUuid);
|
|
126
363
|
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
shellArgs = ['-i']; // Interactive mode
|
|
131
|
-
} else if (shellName === 'zsh') {
|
|
132
|
-
shellArgs = ['-i']; // Interactive mode
|
|
133
|
-
} else if (shellName === 'fish') {
|
|
134
|
-
shellArgs = ['-i']; // Interactive mode
|
|
135
|
-
}
|
|
136
|
-
// If custom shell, try -i flag (most shells support it)
|
|
137
|
-
else if (!userShell.includes('/')) {
|
|
138
|
-
shellArgs = ['-i'];
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Spawn shell directly with merged environment
|
|
142
|
-
const shellProc = spawn(userShell, shellArgs, {
|
|
143
|
-
stdio: 'inherit',
|
|
144
|
-
cwd: process.cwd(),
|
|
145
|
-
env: mergedEnv
|
|
364
|
+
// Handle process termination
|
|
365
|
+
process.on('SIGTERM', () => {
|
|
366
|
+
client.cleanup();
|
|
146
367
|
});
|
|
147
368
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
logger.success('✓ Shell closed, environment variables cleared');
|
|
151
|
-
process.exit(code || 0);
|
|
369
|
+
process.on('SIGINT', () => {
|
|
370
|
+
// Let readline handle SIGINT
|
|
152
371
|
});
|
|
153
372
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
373
|
+
// Connect to server
|
|
374
|
+
try {
|
|
375
|
+
await client.connect();
|
|
376
|
+
|
|
377
|
+
// Start heartbeat
|
|
378
|
+
client.startHeartbeat();
|
|
379
|
+
|
|
380
|
+
} catch (err) {
|
|
381
|
+
if (err.message === 'Authentication failed') {
|
|
382
|
+
logger.error('Authentication failed. Please login again:');
|
|
383
|
+
logger.log(' saac login');
|
|
384
|
+
} else if (err.message === 'Access denied') {
|
|
385
|
+
logger.error('Access denied. You do not own this application.');
|
|
386
|
+
} else if (err.message === 'Connection timeout') {
|
|
387
|
+
logger.error('Connection timeout. The server may be unavailable or the container failed to start.');
|
|
388
|
+
logger.info('Please try again later or check application status:');
|
|
389
|
+
logger.log(' saac status');
|
|
390
|
+
} else {
|
|
391
|
+
logger.error('Failed to connect:', err.message);
|
|
392
|
+
logger.info('Please check your network connection and try again.');
|
|
393
|
+
}
|
|
157
394
|
process.exit(1);
|
|
158
|
-
}
|
|
395
|
+
}
|
|
159
396
|
|
|
160
397
|
} catch (error) {
|
|
161
|
-
logger.error(error.
|
|
398
|
+
logger.error(error.message);
|
|
162
399
|
process.exit(1);
|
|
163
400
|
}
|
|
164
401
|
}
|