@startanaicompany/cli 1.4.17 → 1.4.19

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.
@@ -0,0 +1,403 @@
1
+ /**
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.
6
+ */
7
+
8
+ const WebSocket = require('ws');
9
+ const readline = require('readline');
10
+ const { getProjectConfig, isAuthenticated, getUser, getApiUrl } = require('../lib/config');
11
+ const logger = require('../lib/logger');
12
+
13
+ /**
14
+ * WebSocket Shell Client
15
+ * Connects to backend WebSocket server and provides interactive shell
16
+ */
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)
228
+ }
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);
280
+ }
281
+ }
282
+
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
+ }
293
+
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
+ }
303
+
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
+ }
315
+ }
316
+
317
+ /**
318
+ * Shell command main function
319
+ */
320
+ async function shell(options = {}) {
321
+ try {
322
+ // Check authentication
323
+ if (!isAuthenticated()) {
324
+ logger.error('Not logged in. Run: saac login');
325
+ process.exit(1);
326
+ }
327
+
328
+ // Check for project config
329
+ const projectConfig = getProjectConfig();
330
+ if (!projectConfig || !projectConfig.applicationUuid) {
331
+ logger.error('No application found in current directory');
332
+ logger.info('Run this command from a project directory (must have .saac/config.json)');
333
+ logger.newline();
334
+ logger.info('Or initialize with:');
335
+ logger.log(' saac init');
336
+ process.exit(1);
337
+ }
338
+
339
+ const { applicationUuid, applicationName } = projectConfig;
340
+
341
+ // Get user and token
342
+ const user = getUser();
343
+ const token = user.sessionToken || user.apiKey;
344
+
345
+ if (!token) {
346
+ logger.error('No authentication token found. Please login again.');
347
+ process.exit(1);
348
+ }
349
+
350
+ // Get server URL
351
+ const serverUrl = getApiUrl();
352
+
353
+ // Show banner
354
+ logger.newline();
355
+ logger.section(`Remote Shell: ${applicationName}`);
356
+ logger.newline();
357
+ logger.info('Connecting to container...');
358
+ logger.info('This may take up to 30 seconds for container creation.');
359
+ logger.newline();
360
+
361
+ // Create shell client
362
+ const client = new ShellClient(serverUrl, token, applicationUuid);
363
+
364
+ // Handle process termination
365
+ process.on('SIGTERM', () => {
366
+ client.cleanup();
367
+ });
368
+
369
+ process.on('SIGINT', () => {
370
+ // Let readline handle SIGINT
371
+ });
372
+
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
+ }
394
+ process.exit(1);
395
+ }
396
+
397
+ } catch (error) {
398
+ logger.error(error.message);
399
+ process.exit(1);
400
+ }
401
+ }
402
+
403
+ module.exports = shell;
@@ -1,4 +1,90 @@
1
- // TODO: Implement whoami command
2
- module.exports = async function() {
3
- console.log('whoami command - Coming soon!');
4
- };
1
+ /**
2
+ * Whoami Command - Show current user information
3
+ */
4
+
5
+ const api = require('../lib/api');
6
+ const { isAuthenticated } = require('../lib/config');
7
+ const logger = require('../lib/logger');
8
+
9
+ /**
10
+ * Display current authenticated user information
11
+ */
12
+ async function whoami() {
13
+ try {
14
+ // Check authentication
15
+ if (!isAuthenticated()) {
16
+ logger.error('Not logged in');
17
+ logger.newline();
18
+ logger.info('Run:');
19
+ logger.log(' saac login -e <email> -k <api-key>');
20
+ process.exit(1);
21
+ }
22
+
23
+ const spin = logger.spinner('Fetching user information...').start();
24
+
25
+ try {
26
+ const user = await api.getUserInfo();
27
+
28
+ spin.succeed('User information retrieved');
29
+
30
+ logger.newline();
31
+
32
+ logger.section('Current User');
33
+ logger.newline();
34
+
35
+ logger.field('Email', user.email);
36
+ logger.field('User ID', user.id);
37
+ logger.field('Verified', user.verified ? logger.chalk.green('Yes ✓') : logger.chalk.red('No ✗'));
38
+ logger.field('Member Since', formatDate(user.created_at));
39
+
40
+ logger.newline();
41
+
42
+ // Git Connections
43
+ if (user.git_connections && user.git_connections.length > 0) {
44
+ logger.info('Git Connections:');
45
+ for (const conn of user.git_connections) {
46
+ logger.log(` • ${conn.gitUsername} @ ${conn.gitHost} (${conn.providerType})`);
47
+ logger.log(` Connected: ${formatDate(conn.connectedAt)}`);
48
+ }
49
+ logger.newline();
50
+ }
51
+
52
+ // Quotas
53
+ logger.info('Quotas:');
54
+ logger.field(' Applications', `${user.application_count} / ${user.max_applications}`);
55
+
56
+ logger.newline();
57
+
58
+ logger.info('Commands:');
59
+ logger.log(' View applications: ' + logger.chalk.cyan('saac list'));
60
+ logger.log(' View status: ' + logger.chalk.cyan('saac status'));
61
+ logger.log(' Logout: ' + logger.chalk.cyan('saac logout'));
62
+
63
+ } catch (error) {
64
+ spin.fail('Failed to fetch user information');
65
+ throw error;
66
+ }
67
+
68
+ } catch (error) {
69
+ logger.error(error.response?.data?.message || error.message);
70
+ process.exit(1);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Format ISO date string to readable format
76
+ * @param {string} isoString - ISO 8601 date string
77
+ * @returns {string} Formatted date
78
+ */
79
+ function formatDate(isoString) {
80
+ if (!isoString) return 'N/A';
81
+
82
+ const date = new Date(isoString);
83
+ return date.toLocaleDateString('en-US', {
84
+ year: 'numeric',
85
+ month: 'short',
86
+ day: 'numeric'
87
+ });
88
+ }
89
+
90
+ module.exports = whoami;
package/src/lib/api.js CHANGED
@@ -186,6 +186,24 @@ async function healthCheck() {
186
186
  return response.data;
187
187
  }
188
188
 
189
+ /**
190
+ * Get deployment history for an application
191
+ */
192
+ async function getDeployments(uuid, params = {}) {
193
+ const client = createClient();
194
+ const response = await client.get(`/applications/${uuid}/deployments`, { params });
195
+ return response.data;
196
+ }
197
+
198
+ /**
199
+ * Get deployment logs (build logs, not runtime logs)
200
+ */
201
+ async function getDeploymentLogs(uuid, params = {}) {
202
+ const client = createClient();
203
+ const response = await client.get(`/applications/${uuid}/deployment-logs`, { params });
204
+ return response.data;
205
+ }
206
+
189
207
  /**
190
208
  * Request login OTP (no API key required)
191
209
  */
@@ -241,6 +259,37 @@ async function getApiKeyInfo() {
241
259
  return response.data;
242
260
  }
243
261
 
262
+ /**
263
+ * Get environment variables for an application
264
+ */
265
+ async function getEnvironmentVariables(uuid) {
266
+ const client = createClient();
267
+ const response = await client.get(`/applications/${uuid}/env`);
268
+ return response.data;
269
+ }
270
+
271
+ /**
272
+ * Execute a command in the application container
273
+ * @param {string} uuid - Application UUID
274
+ * @param {object} execRequest - { command, workdir, timeout }
275
+ */
276
+ async function executeCommand(uuid, execRequest) {
277
+ const client = createClient();
278
+ const response = await client.post(`/applications/${uuid}/exec`, execRequest);
279
+ return response.data;
280
+ }
281
+
282
+ /**
283
+ * Get execution history for an application
284
+ * @param {string} uuid - Application UUID
285
+ * @param {object} params - { limit, offset }
286
+ */
287
+ async function getExecutionHistory(uuid, params = {}) {
288
+ const client = createClient();
289
+ const response = await client.get(`/applications/${uuid}/exec/history`, { params });
290
+ return response.data;
291
+ }
292
+
244
293
  module.exports = {
245
294
  createClient,
246
295
  login,
@@ -254,6 +303,7 @@ module.exports = {
254
303
  deployApplication,
255
304
  getApplicationLogs,
256
305
  updateEnvironmentVariables,
306
+ getEnvironmentVariables,
257
307
  updateDomain,
258
308
  deleteApplication,
259
309
  healthCheck,
@@ -261,4 +311,8 @@ module.exports = {
261
311
  verifyLoginOtp,
262
312
  regenerateApiKey,
263
313
  getApiKeyInfo,
314
+ getDeployments,
315
+ getDeploymentLogs,
316
+ executeCommand,
317
+ getExecutionHistory,
264
318
  };