shell-mirror 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.
- package/README.md +272 -0
- package/auth.js +22 -0
- package/bin/shell-mirror +127 -0
- package/lib/config-manager.js +203 -0
- package/lib/health-checker.js +192 -0
- package/lib/server-manager.js +225 -0
- package/lib/setup-wizard.js +222 -0
- package/package.json +70 -0
- package/public/app/terminal.html +867 -0
- package/public/index.html +809 -0
- package/server.js +171 -0
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Terminal Mirror</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
6
|
+
<style>
|
|
7
|
+
html, body {
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 0;
|
|
10
|
+
height: 100vh;
|
|
11
|
+
width: 100vw;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
background-color: #000;
|
|
14
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
15
|
+
}
|
|
16
|
+
#auth-container {
|
|
17
|
+
display: flex;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
align-items: center;
|
|
20
|
+
height: 100%;
|
|
21
|
+
}
|
|
22
|
+
#login-btn {
|
|
23
|
+
padding: 15px 30px;
|
|
24
|
+
font-size: 18px;
|
|
25
|
+
color: white;
|
|
26
|
+
background-color: #4285F4;
|
|
27
|
+
border: none;
|
|
28
|
+
border-radius: 5px;
|
|
29
|
+
cursor: pointer;
|
|
30
|
+
}
|
|
31
|
+
#app-container {
|
|
32
|
+
display: none; /* Initially hidden */
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: 100%;
|
|
36
|
+
}
|
|
37
|
+
#terminal-container {
|
|
38
|
+
flex-grow: 1;
|
|
39
|
+
width: 100%;
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
position: relative;
|
|
42
|
+
}
|
|
43
|
+
#connection-status {
|
|
44
|
+
position: absolute;
|
|
45
|
+
top: 10px;
|
|
46
|
+
right: 10px;
|
|
47
|
+
padding: 5px 10px;
|
|
48
|
+
border-radius: 15px;
|
|
49
|
+
font-size: 12px;
|
|
50
|
+
font-weight: bold;
|
|
51
|
+
z-index: 1000;
|
|
52
|
+
}
|
|
53
|
+
.status-connected {
|
|
54
|
+
background-color: #4CAF50;
|
|
55
|
+
color: white;
|
|
56
|
+
}
|
|
57
|
+
.status-connecting {
|
|
58
|
+
background-color: #FF9800;
|
|
59
|
+
color: white;
|
|
60
|
+
}
|
|
61
|
+
.status-disconnected {
|
|
62
|
+
background-color: #F44336;
|
|
63
|
+
color: white;
|
|
64
|
+
}
|
|
65
|
+
#keyboard-bar {
|
|
66
|
+
flex-shrink: 0;
|
|
67
|
+
width: 100%;
|
|
68
|
+
background-color: #222;
|
|
69
|
+
display: flex;
|
|
70
|
+
justify-content: space-around;
|
|
71
|
+
padding: 8px 0;
|
|
72
|
+
box-sizing: border-box;
|
|
73
|
+
}
|
|
74
|
+
.key-btn {
|
|
75
|
+
color: white;
|
|
76
|
+
background-color: #444;
|
|
77
|
+
border: 1px solid #666;
|
|
78
|
+
border-radius: 5px;
|
|
79
|
+
font-size: 16px;
|
|
80
|
+
padding: 10px 12px;
|
|
81
|
+
margin: 0 2px;
|
|
82
|
+
touch-action: manipulation;
|
|
83
|
+
-webkit-tap-highlight-color: transparent;
|
|
84
|
+
}
|
|
85
|
+
</style>
|
|
86
|
+
</head>
|
|
87
|
+
<body>
|
|
88
|
+
<div id="auth-container">
|
|
89
|
+
<button id="login-btn">Login with Google</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div id="app-container">
|
|
93
|
+
<div id="terminal-container">
|
|
94
|
+
<div id="connection-status" class="status-connecting">Connecting...</div>
|
|
95
|
+
</div>
|
|
96
|
+
<div id="keyboard-bar">
|
|
97
|
+
<button class="key-btn" data-key-name="ESC">Esc</button>
|
|
98
|
+
<button class="key-btn" data-key-name="TAB">Tab</button>
|
|
99
|
+
<button class="key-btn" data-key-name="ARROW_UP">▲</button>
|
|
100
|
+
<button class="key-btn" data-key-name="ARROW_DOWN">▼</button>
|
|
101
|
+
<button class="key-btn" data-key-name="ARROW_LEFT">◀</button>
|
|
102
|
+
<button class="key-btn" data-key-name="ARROW_RIGHT">▶</button>
|
|
103
|
+
<button class="key-btn" data-key-name="ENTER">Enter</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<!-- Dynamically load xterm scripts only after authentication -->
|
|
108
|
+
<script>
|
|
109
|
+
const authContainer = document.getElementById('auth-container');
|
|
110
|
+
const appContainer = document.getElementById('app-container');
|
|
111
|
+
const loginBtn = document.getElementById('login-btn');
|
|
112
|
+
|
|
113
|
+
loginBtn.addEventListener('click', () => {
|
|
114
|
+
window.location.href = '/php-backend/api/auth-login.php';
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Check authentication status on page load
|
|
118
|
+
fetch('/php-backend/api/auth-status.php', {
|
|
119
|
+
credentials: 'same-origin',
|
|
120
|
+
headers: {
|
|
121
|
+
'Accept': 'application/json',
|
|
122
|
+
'Content-Type': 'application/json'
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
.then(res => {
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
128
|
+
}
|
|
129
|
+
return res.json();
|
|
130
|
+
})
|
|
131
|
+
.then(data => {
|
|
132
|
+
console.log('Auth status response:', data);
|
|
133
|
+
if (data.success && data.data && data.data.authenticated) {
|
|
134
|
+
console.log('✅ User is authenticated, loading terminal...');
|
|
135
|
+
authContainer.style.display = 'none';
|
|
136
|
+
appContainer.style.display = 'flex';
|
|
137
|
+
console.log('📦 Loading xterm scripts...');
|
|
138
|
+
loadXtermScripts(loadTerminal);
|
|
139
|
+
} else {
|
|
140
|
+
console.log('❌ User not authenticated, showing login form');
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
.catch(error => {
|
|
144
|
+
console.error('Auth status check failed:', error);
|
|
145
|
+
console.log('Falling back to login form due to auth check failure');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
function loadXtermScripts(callback) {
|
|
149
|
+
console.log('🔄 Starting to load xterm CSS...');
|
|
150
|
+
const xtermCss = document.createElement('link');
|
|
151
|
+
xtermCss.rel = 'stylesheet';
|
|
152
|
+
xtermCss.href = 'https://unpkg.com/xterm@5.3.0/css/xterm.css';
|
|
153
|
+
document.head.appendChild(xtermCss);
|
|
154
|
+
|
|
155
|
+
console.log('🔄 Starting to load xterm JS...');
|
|
156
|
+
const xtermJs = document.createElement('script');
|
|
157
|
+
xtermJs.src = 'https://unpkg.com/xterm@5.3.0/lib/xterm.js';
|
|
158
|
+
document.head.appendChild(xtermJs);
|
|
159
|
+
|
|
160
|
+
xtermJs.onload = () => {
|
|
161
|
+
console.log('✅ Xterm JS loaded, loading fit addon...');
|
|
162
|
+
const fitAddonJs = document.createElement('script');
|
|
163
|
+
fitAddonJs.src = 'https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js';
|
|
164
|
+
document.head.appendChild(fitAddonJs);
|
|
165
|
+
fitAddonJs.onload = () => {
|
|
166
|
+
console.log('✅ Fit addon loaded, initializing terminal...');
|
|
167
|
+
callback();
|
|
168
|
+
};
|
|
169
|
+
fitAddonJs.onerror = (error) => {
|
|
170
|
+
console.error('❌ Failed to load fit addon:', error);
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
xtermJs.onerror = (error) => {
|
|
174
|
+
console.error('❌ Failed to load xterm JS:', error);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function loadTerminal() {
|
|
179
|
+
console.log('🚀 Initializing terminal...');
|
|
180
|
+
const termContainer = document.getElementById('terminal-container');
|
|
181
|
+
const connectionStatus = document.getElementById('connection-status');
|
|
182
|
+
|
|
183
|
+
if (!termContainer) {
|
|
184
|
+
console.error('❌ Terminal container not found!');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!connectionStatus) {
|
|
189
|
+
console.error('❌ Connection status element not found!');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log('📦 Creating Terminal instance...');
|
|
194
|
+
const term = new Terminal({
|
|
195
|
+
cursorBlink: true,
|
|
196
|
+
convertEol: true,
|
|
197
|
+
fontSize: 14,
|
|
198
|
+
});
|
|
199
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
200
|
+
term.loadAddon(fitAddon);
|
|
201
|
+
term.open(termContainer);
|
|
202
|
+
|
|
203
|
+
const keyMap = {
|
|
204
|
+
ARROW_UP: "\u001b[A",
|
|
205
|
+
ARROW_DOWN: "\u001b[B",
|
|
206
|
+
ARROW_RIGHT: "\u001b[C",
|
|
207
|
+
ARROW_LEFT: "\u001b[D",
|
|
208
|
+
TAB: "\u0009",
|
|
209
|
+
ESC: "\u001b",
|
|
210
|
+
ENTER: "\r"
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
let sessionId = 'terminal_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
214
|
+
let currentCommand = '';
|
|
215
|
+
let commandHistory = [];
|
|
216
|
+
let historyIndex = -1;
|
|
217
|
+
let tabCompletions = [];
|
|
218
|
+
let tabCompletionIndex = -1;
|
|
219
|
+
let lastTabPrefix = '';
|
|
220
|
+
|
|
221
|
+
// Performance enhancement variables
|
|
222
|
+
let commandCache = new Map();
|
|
223
|
+
let directoryCache = new Map();
|
|
224
|
+
let currentWorkingDir = '~';
|
|
225
|
+
let isExecuting = false;
|
|
226
|
+
let executionQueue = [];
|
|
227
|
+
let pendingInput = ''; // Buffer input during execution
|
|
228
|
+
|
|
229
|
+
function updateConnectionStatus(status, message) {
|
|
230
|
+
connectionStatus.className = `status-${status}`;
|
|
231
|
+
|
|
232
|
+
// Add cache info to status
|
|
233
|
+
const cacheSize = commandCache.size;
|
|
234
|
+
const cacheInfo = cacheSize > 0 ? ` (${cacheSize} cached)` : '';
|
|
235
|
+
connectionStatus.textContent = message + cacheInfo;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function initializeTerminal() {
|
|
239
|
+
console.log('🚀 Initializing terminal...');
|
|
240
|
+
updateConnectionStatus('connected', 'Connected');
|
|
241
|
+
|
|
242
|
+
// Load cached state
|
|
243
|
+
loadCachedState();
|
|
244
|
+
|
|
245
|
+
// Show welcome message
|
|
246
|
+
term.write('\x1b[32mTerminal Mirror - Ready\x1b[0m\r\n');
|
|
247
|
+
term.write('Commands will route to available Mac agents or web server.\r\n');
|
|
248
|
+
term.write('High-performance mode: Caching enabled for instant responses.\r\n\r\n');
|
|
249
|
+
|
|
250
|
+
// Show initial prompt
|
|
251
|
+
console.log('📝 Showing initial prompt, current dir:', currentWorkingDir);
|
|
252
|
+
showPrompt();
|
|
253
|
+
|
|
254
|
+
setTimeout(() => {
|
|
255
|
+
fitAndResize();
|
|
256
|
+
term.focus();
|
|
257
|
+
console.log('✅ Terminal focused and ready');
|
|
258
|
+
|
|
259
|
+
// Start background pre-caching
|
|
260
|
+
startBackgroundCaching();
|
|
261
|
+
}, 100);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function loadCachedState() {
|
|
265
|
+
// Restore working directory
|
|
266
|
+
const savedCwd = localStorage.getItem('terminal_cwd');
|
|
267
|
+
if (savedCwd) {
|
|
268
|
+
currentWorkingDir = savedCwd;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Restore command history
|
|
272
|
+
const savedHistory = localStorage.getItem('terminal_history');
|
|
273
|
+
if (savedHistory) {
|
|
274
|
+
try {
|
|
275
|
+
commandHistory = JSON.parse(savedHistory);
|
|
276
|
+
historyIndex = commandHistory.length;
|
|
277
|
+
} catch (e) {
|
|
278
|
+
console.warn('Failed to restore command history');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Store user info for instant whoami
|
|
283
|
+
if (window.userInfo) {
|
|
284
|
+
localStorage.setItem('terminal_user_info', JSON.stringify(window.userInfo));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function startBackgroundCaching() {
|
|
289
|
+
// Pre-cache current directory listing
|
|
290
|
+
preCacheDirectoryContents();
|
|
291
|
+
|
|
292
|
+
// Cache common system info
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
['whoami', 'uname -a'].forEach(cmd => {
|
|
295
|
+
if (!commandCache.has(`${currentWorkingDir}:${cmd}`)) {
|
|
296
|
+
executeServerCommand(cmd, true);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}, 500);
|
|
300
|
+
|
|
301
|
+
// Set up periodic cache refresh
|
|
302
|
+
setInterval(() => {
|
|
303
|
+
refreshStaleCache();
|
|
304
|
+
}, 60000); // Every minute
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function refreshStaleCache() {
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
const staleThreshold = 300000; // 5 minutes
|
|
310
|
+
|
|
311
|
+
for (const [key, entry] of commandCache.entries()) {
|
|
312
|
+
if (now - entry.timestamp > staleThreshold) {
|
|
313
|
+
// Refresh stale entries in background
|
|
314
|
+
const [dir, command] = key.split(':');
|
|
315
|
+
if (dir === currentWorkingDir && (command.startsWith('ls') || command === 'pwd')) {
|
|
316
|
+
executeServerCommand(command, true);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function saveState() {
|
|
323
|
+
// Save command history
|
|
324
|
+
localStorage.setItem('terminal_history', JSON.stringify(commandHistory.slice(-100))); // Keep last 100 commands
|
|
325
|
+
|
|
326
|
+
// Save working directory
|
|
327
|
+
localStorage.setItem('terminal_cwd', currentWorkingDir);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function checkMacAgents() {
|
|
331
|
+
return fetch('/php-backend/api/agents-list.php')
|
|
332
|
+
.then(response => response.json())
|
|
333
|
+
.then(data => {
|
|
334
|
+
if (data.success && data.data.agents && data.data.agents.length > 0) {
|
|
335
|
+
const onlineAgents = data.data.agents.filter(agent => agent.status === 'online');
|
|
336
|
+
if (onlineAgents.length > 0) {
|
|
337
|
+
const agent = onlineAgents[0];
|
|
338
|
+
return {
|
|
339
|
+
hasMacAgents: true,
|
|
340
|
+
agentName: agent.machineName || agent.agentId,
|
|
341
|
+
ownerName: agent.ownerEmail,
|
|
342
|
+
agentId: agent.agentId
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { hasMacAgents: false };
|
|
347
|
+
})
|
|
348
|
+
.catch(error => {
|
|
349
|
+
console.error('Mac agent check failed:', error);
|
|
350
|
+
return { hasMacAgents: false };
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function showPrompt() {
|
|
355
|
+
// Show performance-enhanced prompt with current directory
|
|
356
|
+
let shortDir = currentWorkingDir;
|
|
357
|
+
|
|
358
|
+
// Simplify directory display
|
|
359
|
+
try {
|
|
360
|
+
if (shortDir.length > 20) {
|
|
361
|
+
const parts = shortDir.split('/');
|
|
362
|
+
shortDir = '.../' + parts[parts.length - 1];
|
|
363
|
+
}
|
|
364
|
+
} catch (e) {
|
|
365
|
+
shortDir = '~';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
term.write('\x1b[36m' + shortDir + ' $\x1b[0m ');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function executeCommand(command) {
|
|
372
|
+
const trimmedCommand = command.trim();
|
|
373
|
+
|
|
374
|
+
if (!trimmedCommand) {
|
|
375
|
+
term.write('\r\n');
|
|
376
|
+
showPrompt();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Log the exact command being executed
|
|
381
|
+
console.log('🎯 Executing exact command:', JSON.stringify(trimmedCommand));
|
|
382
|
+
|
|
383
|
+
// Add to history immediately
|
|
384
|
+
commandHistory.push(trimmedCommand);
|
|
385
|
+
historyIndex = commandHistory.length;
|
|
386
|
+
|
|
387
|
+
// Save state for persistence
|
|
388
|
+
saveState();
|
|
389
|
+
|
|
390
|
+
// Set executing state to prevent input bleeding
|
|
391
|
+
isExecuting = true;
|
|
392
|
+
pendingInput = '';
|
|
393
|
+
|
|
394
|
+
// Move to new line (preserve the typed command line)
|
|
395
|
+
term.write('\r\n');
|
|
396
|
+
|
|
397
|
+
// Check for instant commands first
|
|
398
|
+
const instantResult = executeInstantCommand(trimmedCommand);
|
|
399
|
+
if (instantResult !== null) {
|
|
400
|
+
if (instantResult) {
|
|
401
|
+
term.write(instantResult);
|
|
402
|
+
}
|
|
403
|
+
term.write('\r\n');
|
|
404
|
+
isExecuting = false; // Re-enable input
|
|
405
|
+
showPrompt();
|
|
406
|
+
processPendingInput();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Check cache for recently executed commands
|
|
411
|
+
const cacheKey = `${currentWorkingDir}:${trimmedCommand}`;
|
|
412
|
+
if (commandCache.has(cacheKey)) {
|
|
413
|
+
const cachedResult = commandCache.get(cacheKey);
|
|
414
|
+
const cacheAge = Date.now() - cachedResult.timestamp;
|
|
415
|
+
|
|
416
|
+
// Use cache if less than 30 seconds old for most commands
|
|
417
|
+
// or 5 minutes for directory listings
|
|
418
|
+
const maxAge = trimmedCommand.startsWith('ls') ? 300000 : 30000;
|
|
419
|
+
|
|
420
|
+
if (cacheAge < maxAge) {
|
|
421
|
+
term.write('\x1b[90m[Cached result - ' + Math.round(cacheAge/1000) + 's old]\x1b[0m\r\n');
|
|
422
|
+
if (cachedResult.output) {
|
|
423
|
+
term.write(cachedResult.output.replace(/\n/g, '\r\n'));
|
|
424
|
+
}
|
|
425
|
+
term.write('\r\n');
|
|
426
|
+
isExecuting = false; // Re-enable input
|
|
427
|
+
showPrompt();
|
|
428
|
+
processPendingInput();
|
|
429
|
+
|
|
430
|
+
// Refresh cache in background
|
|
431
|
+
setTimeout(() => executeServerCommand(trimmedCommand, true), 100);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Show execution status without cluttering terminal
|
|
437
|
+
updateConnectionStatus('connecting', 'Executing...');
|
|
438
|
+
|
|
439
|
+
// Execute on server
|
|
440
|
+
executeServerCommand(trimmedCommand, false);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function executeInstantCommand(command) {
|
|
444
|
+
const cmd = command.trim().toLowerCase();
|
|
445
|
+
const now = new Date();
|
|
446
|
+
|
|
447
|
+
switch (cmd) {
|
|
448
|
+
case 'pwd':
|
|
449
|
+
return currentWorkingDir;
|
|
450
|
+
case 'date':
|
|
451
|
+
return now.toString();
|
|
452
|
+
case 'whoami':
|
|
453
|
+
// Return cached user info if available
|
|
454
|
+
const userInfo = localStorage.getItem('terminal_user_info');
|
|
455
|
+
if (userInfo) {
|
|
456
|
+
return JSON.parse(userInfo).username || 'user';
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
case 'clear':
|
|
460
|
+
term.clear();
|
|
461
|
+
return '';
|
|
462
|
+
case 'history':
|
|
463
|
+
return commandHistory.map((cmd, i) => `${i + 1} ${cmd}`).join('\n');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return null; // Not an instant command
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function executeServerCommand(command, isBackgroundRefresh = false) {
|
|
470
|
+
const startTime = Date.now();
|
|
471
|
+
|
|
472
|
+
fetch('/php-backend/api/terminal-execute.php', {
|
|
473
|
+
method: 'POST',
|
|
474
|
+
headers: {
|
|
475
|
+
'Content-Type': 'application/json',
|
|
476
|
+
},
|
|
477
|
+
body: JSON.stringify({
|
|
478
|
+
command: command,
|
|
479
|
+
session_id: sessionId
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
.then(response => response.json())
|
|
483
|
+
.then(data => {
|
|
484
|
+
const responseTime = Date.now() - startTime;
|
|
485
|
+
|
|
486
|
+
if (!isBackgroundRefresh) {
|
|
487
|
+
updateConnectionStatus('connected', 'Connected');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (data.success) {
|
|
491
|
+
const output = data.data.output || '';
|
|
492
|
+
|
|
493
|
+
// Cache the result
|
|
494
|
+
const cacheKey = `${currentWorkingDir}:${command}`;
|
|
495
|
+
commandCache.set(cacheKey, {
|
|
496
|
+
output: output,
|
|
497
|
+
timestamp: Date.now(),
|
|
498
|
+
responseTime: responseTime
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Update working directory if cd command
|
|
502
|
+
updateWorkingDirectory(command, output);
|
|
503
|
+
|
|
504
|
+
// Pre-cache directory contents for navigation commands
|
|
505
|
+
if (command.startsWith('cd ')) {
|
|
506
|
+
preCacheDirectoryContents();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (!isBackgroundRefresh) {
|
|
510
|
+
// Show execution source info
|
|
511
|
+
if (data.data.mac_agent) {
|
|
512
|
+
term.write('\x1b[90m[Mac: ' + (data.data.agent_id || 'unknown') + ' - ' + responseTime + 'ms]\x1b[0m\r\n');
|
|
513
|
+
} else {
|
|
514
|
+
term.write('\x1b[90m[Server - ' + responseTime + 'ms]\x1b[0m\r\n');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (output) {
|
|
518
|
+
const formattedOutput = output.replace(/\n/g, '\r\n');
|
|
519
|
+
term.write(formattedOutput);
|
|
520
|
+
}
|
|
521
|
+
term.write('\r\n');
|
|
522
|
+
isExecuting = false; // Re-enable input
|
|
523
|
+
showPrompt();
|
|
524
|
+
processPendingInput();
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
if (!isBackgroundRefresh) {
|
|
528
|
+
term.write('\x1b[31m❌ Error: ' + (data.message || 'Command execution failed') + '\x1b[0m\r\n');
|
|
529
|
+
isExecuting = false; // Re-enable input
|
|
530
|
+
showPrompt();
|
|
531
|
+
processPendingInput();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
})
|
|
535
|
+
.catch(error => {
|
|
536
|
+
if (!isBackgroundRefresh) {
|
|
537
|
+
updateConnectionStatus('disconnected', 'Error');
|
|
538
|
+
console.error('Command execution failed:', error);
|
|
539
|
+
term.write('\x1b[31m🚫 Connection error. Please try again.\x1b[0m\r\n');
|
|
540
|
+
isExecuting = false; // Re-enable input
|
|
541
|
+
showPrompt();
|
|
542
|
+
processPendingInput();
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function updateWorkingDirectory(command, output) {
|
|
548
|
+
if (command.startsWith('cd ')) {
|
|
549
|
+
// Only update directory if cd command was successful
|
|
550
|
+
if (output && output.includes('Changed directory to:')) {
|
|
551
|
+
const match = output.match(/Changed directory to:\s*(.+)/);
|
|
552
|
+
if (match) {
|
|
553
|
+
currentWorkingDir = match[1].trim();
|
|
554
|
+
localStorage.setItem('terminal_cwd', currentWorkingDir);
|
|
555
|
+
console.log('🗂️ Updated working directory to:', currentWorkingDir);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Check if command failed - do NOT update directory
|
|
561
|
+
if (output && (output.includes('no such file or directory') ||
|
|
562
|
+
output.includes('Not a directory') ||
|
|
563
|
+
output.includes('Permission denied') ||
|
|
564
|
+
output.includes('cd:'))) {
|
|
565
|
+
console.log('❌ cd command failed, keeping current directory:', currentWorkingDir);
|
|
566
|
+
return; // Don't update directory on failure
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Only fallback to manual construction if no output (shouldn't happen with Mac agent)
|
|
570
|
+
console.warn('⚠️ No output from cd command, using fallback logic');
|
|
571
|
+
const target = command.substring(3).trim();
|
|
572
|
+
if (target === '' || target === '~') {
|
|
573
|
+
currentWorkingDir = '~';
|
|
574
|
+
localStorage.setItem('terminal_cwd', currentWorkingDir);
|
|
575
|
+
console.log('🗂️ Updated working directory to home:', currentWorkingDir);
|
|
576
|
+
}
|
|
577
|
+
// Don't attempt relative path construction without confirmation
|
|
578
|
+
|
|
579
|
+
} else if (command === 'pwd' && output) {
|
|
580
|
+
currentWorkingDir = output.trim();
|
|
581
|
+
localStorage.setItem('terminal_cwd', currentWorkingDir);
|
|
582
|
+
console.log('🗂️ Updated working directory from pwd:', currentWorkingDir);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function preCacheDirectoryContents() {
|
|
587
|
+
// Pre-cache ls results for current directory
|
|
588
|
+
setTimeout(() => {
|
|
589
|
+
['ls', 'ls -la', 'ls -l'].forEach(lsCmd => {
|
|
590
|
+
const cacheKey = `${currentWorkingDir}:${lsCmd}`;
|
|
591
|
+
if (!commandCache.has(cacheKey)) {
|
|
592
|
+
executeServerCommand(lsCmd, true);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
}, 200);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function handleSpecialKeys(sequence) {
|
|
599
|
+
switch (sequence) {
|
|
600
|
+
case keyMap.ARROW_UP:
|
|
601
|
+
// History up
|
|
602
|
+
if (historyIndex > 0) {
|
|
603
|
+
historyIndex--;
|
|
604
|
+
replaceCurrentCommand(commandHistory[historyIndex] || '');
|
|
605
|
+
resetTabCompletion();
|
|
606
|
+
}
|
|
607
|
+
break;
|
|
608
|
+
case keyMap.ARROW_DOWN:
|
|
609
|
+
// History down
|
|
610
|
+
if (historyIndex < commandHistory.length - 1) {
|
|
611
|
+
historyIndex++;
|
|
612
|
+
replaceCurrentCommand(commandHistory[historyIndex] || '');
|
|
613
|
+
} else {
|
|
614
|
+
historyIndex = commandHistory.length;
|
|
615
|
+
replaceCurrentCommand('');
|
|
616
|
+
}
|
|
617
|
+
resetTabCompletion();
|
|
618
|
+
break;
|
|
619
|
+
case keyMap.ENTER:
|
|
620
|
+
executeCommand(currentCommand);
|
|
621
|
+
currentCommand = '';
|
|
622
|
+
break;
|
|
623
|
+
case keyMap.TAB:
|
|
624
|
+
handleTabCompletion();
|
|
625
|
+
break;
|
|
626
|
+
default:
|
|
627
|
+
return false; // Not handled
|
|
628
|
+
}
|
|
629
|
+
return true; // Handled
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function replaceCurrentCommand(newCommand) {
|
|
633
|
+
// Clear current line
|
|
634
|
+
term.write('\x1b[2K\r'); // Clear line and return to start
|
|
635
|
+
showPrompt();
|
|
636
|
+
term.write(newCommand);
|
|
637
|
+
currentCommand = newCommand;
|
|
638
|
+
console.log('🔄 Command replaced with:', JSON.stringify(newCommand));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function handleTabCompletion() {
|
|
642
|
+
// Reset tab completion if command changed
|
|
643
|
+
const words = currentCommand.split(' ');
|
|
644
|
+
const currentWord = words[words.length - 1] || '';
|
|
645
|
+
|
|
646
|
+
if (lastTabPrefix !== currentWord) {
|
|
647
|
+
tabCompletions = [];
|
|
648
|
+
tabCompletionIndex = -1;
|
|
649
|
+
lastTabPrefix = currentWord;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (tabCompletions.length === 0) {
|
|
653
|
+
// First tab - get completions from server
|
|
654
|
+
getTabCompletions(currentWord).then(completions => {
|
|
655
|
+
if (completions.length === 0) {
|
|
656
|
+
// No completions available
|
|
657
|
+
return;
|
|
658
|
+
} else if (completions.length === 1) {
|
|
659
|
+
// Single completion - auto-complete immediately
|
|
660
|
+
completeCurrentWord(completions[0]);
|
|
661
|
+
} else {
|
|
662
|
+
// Multiple completions - start cycling
|
|
663
|
+
tabCompletions = completions;
|
|
664
|
+
tabCompletionIndex = 0;
|
|
665
|
+
completeCurrentWord(completions[0]);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
} else {
|
|
669
|
+
// Subsequent tabs - cycle through completions
|
|
670
|
+
tabCompletionIndex = (tabCompletionIndex + 1) % tabCompletions.length;
|
|
671
|
+
completeCurrentWord(tabCompletions[tabCompletionIndex]);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function completeCurrentWord(completion) {
|
|
676
|
+
const words = currentCommand.split(' ');
|
|
677
|
+
words[words.length - 1] = completion;
|
|
678
|
+
const newCommand = words.join(' ');
|
|
679
|
+
|
|
680
|
+
// Clear current line and write new command
|
|
681
|
+
term.write('\x1b[2K\r');
|
|
682
|
+
showPrompt();
|
|
683
|
+
term.write(newCommand);
|
|
684
|
+
currentCommand = newCommand;
|
|
685
|
+
console.log('🔤 Tab completed to:', JSON.stringify(newCommand));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function getTabCompletions(prefix) {
|
|
689
|
+
// Try to get completions from cache first
|
|
690
|
+
const cachedCompletions = getCachedCompletions(prefix);
|
|
691
|
+
if (cachedCompletions.length > 0) {
|
|
692
|
+
return Promise.resolve(cachedCompletions);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Fallback to server request
|
|
696
|
+
return fetch('/php-backend/api/terminal-complete.php', {
|
|
697
|
+
method: 'POST',
|
|
698
|
+
headers: {
|
|
699
|
+
'Content-Type': 'application/json',
|
|
700
|
+
},
|
|
701
|
+
body: JSON.stringify({
|
|
702
|
+
prefix: prefix,
|
|
703
|
+
command: currentCommand,
|
|
704
|
+
session_id: sessionId
|
|
705
|
+
})
|
|
706
|
+
})
|
|
707
|
+
.then(response => response.json())
|
|
708
|
+
.then(data => {
|
|
709
|
+
if (data.success && data.data && data.data.completions) {
|
|
710
|
+
return data.data.completions;
|
|
711
|
+
}
|
|
712
|
+
return [];
|
|
713
|
+
})
|
|
714
|
+
.catch(error => {
|
|
715
|
+
console.error('Tab completion failed:', error);
|
|
716
|
+
return getCachedCompletions(prefix); // Fallback to cache on error
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function getCachedCompletions(prefix) {
|
|
721
|
+
const words = currentCommand.split(' ');
|
|
722
|
+
const isFirstWord = words.length <= 1;
|
|
723
|
+
|
|
724
|
+
if (isFirstWord) {
|
|
725
|
+
// Command completion
|
|
726
|
+
const commands = ['ls', 'cd', 'pwd', 'cat', 'echo', 'grep', 'find', 'ps', 'df', 'du', 'uname', 'date', 'which', 'whoami', 'clear', 'history', 'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'chmod', 'chown', 'head', 'tail', 'less', 'more', 'wc', 'sort', 'uniq'];
|
|
727
|
+
return commands.filter(cmd => cmd.startsWith(prefix.toLowerCase()));
|
|
728
|
+
} else {
|
|
729
|
+
// File/directory completion from cached ls results
|
|
730
|
+
const lsCacheKey = `${currentWorkingDir}:ls -la`;
|
|
731
|
+
if (commandCache.has(lsCacheKey)) {
|
|
732
|
+
const lsOutput = commandCache.get(lsCacheKey).output;
|
|
733
|
+
const files = parseLsOutput(lsOutput);
|
|
734
|
+
return files.filter(file => file.startsWith(prefix));
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return [];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function parseLsOutput(lsOutput) {
|
|
742
|
+
if (!lsOutput) return [];
|
|
743
|
+
|
|
744
|
+
const lines = lsOutput.split('\n');
|
|
745
|
+
const files = [];
|
|
746
|
+
|
|
747
|
+
for (const line of lines) {
|
|
748
|
+
const trimmed = line.trim();
|
|
749
|
+
if (!trimmed || trimmed.startsWith('total')) continue;
|
|
750
|
+
|
|
751
|
+
// Parse ls -la format: permissions user group size date filename
|
|
752
|
+
const parts = trimmed.split(/\s+/);
|
|
753
|
+
if (parts.length >= 9) {
|
|
754
|
+
const filename = parts.slice(8).join(' ');
|
|
755
|
+
if (filename !== '.' && filename !== '..') {
|
|
756
|
+
files.push(filename);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return files;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function resetTabCompletion() {
|
|
765
|
+
tabCompletions = [];
|
|
766
|
+
tabCompletionIndex = -1;
|
|
767
|
+
lastTabPrefix = '';
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function fitAndResize() {
|
|
771
|
+
appContainer.style.height = `${window.visualViewport.height}px`;
|
|
772
|
+
fitAddon.fit();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Initialize terminal
|
|
776
|
+
initializeTerminal();
|
|
777
|
+
|
|
778
|
+
// Terminal input handling
|
|
779
|
+
term.onData((data) => {
|
|
780
|
+
try {
|
|
781
|
+
// If command is executing, buffer the input instead of processing it
|
|
782
|
+
if (isExecuting) {
|
|
783
|
+
pendingInput += data;
|
|
784
|
+
console.log('⏳ Buffering input during execution:', JSON.stringify(data));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Handle special key sequences
|
|
789
|
+
if (handleSpecialKeys(data)) {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Handle regular characters
|
|
794
|
+
if (data === '\x7f') { // Backspace
|
|
795
|
+
if (currentCommand.length > 0) {
|
|
796
|
+
currentCommand = currentCommand.slice(0, -1);
|
|
797
|
+
term.write('\b \b');
|
|
798
|
+
resetTabCompletion();
|
|
799
|
+
}
|
|
800
|
+
} else if (data === '\r') { // Enter
|
|
801
|
+
console.log('📤 Executing command:', JSON.stringify(currentCommand));
|
|
802
|
+
executeCommand(currentCommand);
|
|
803
|
+
clearCurrentCommand();
|
|
804
|
+
} else if (data === '\t') { // Tab key
|
|
805
|
+
handleTabCompletion();
|
|
806
|
+
} else if (data.charCodeAt(0) >= 32) { // Printable characters
|
|
807
|
+
currentCommand += data;
|
|
808
|
+
term.write(data);
|
|
809
|
+
console.log('⌨️ Current command buffer:', JSON.stringify(currentCommand));
|
|
810
|
+
// Reset tab completion when user types
|
|
811
|
+
resetTabCompletion();
|
|
812
|
+
}
|
|
813
|
+
} catch (error) {
|
|
814
|
+
console.error('Terminal input error:', error);
|
|
815
|
+
term.write('\r\n\x1b[31mInput error occurred\x1b[0m\r\n');
|
|
816
|
+
clearCurrentCommand();
|
|
817
|
+
showPrompt();
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
function processPendingInput() {
|
|
822
|
+
if (pendingInput) {
|
|
823
|
+
console.log('🔄 Processing pending input:', JSON.stringify(pendingInput));
|
|
824
|
+
// Process buffered input character by character
|
|
825
|
+
for (let i = 0; i < pendingInput.length; i++) {
|
|
826
|
+
const char = pendingInput[i];
|
|
827
|
+
if (char.charCodeAt(0) >= 32) { // Only printable characters
|
|
828
|
+
currentCommand += char;
|
|
829
|
+
term.write(char);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
pendingInput = '';
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function clearCurrentCommand() {
|
|
837
|
+
currentCommand = '';
|
|
838
|
+
console.log('🧹 Command buffer cleared');
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Virtual keyboard handling
|
|
842
|
+
document.getElementById('keyboard-bar').addEventListener('click', (e) => {
|
|
843
|
+
if (e.target.classList.contains('key-btn')) {
|
|
844
|
+
e.preventDefault();
|
|
845
|
+
const keyName = e.target.dataset.keyName;
|
|
846
|
+
const sequence = keyMap[keyName];
|
|
847
|
+
if (sequence) {
|
|
848
|
+
handleSpecialKeys(sequence);
|
|
849
|
+
}
|
|
850
|
+
term.focus();
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Focus terminal on body click
|
|
855
|
+
document.body.addEventListener('click', (e) => {
|
|
856
|
+
if (e.target.classList.contains('key-btn')) return;
|
|
857
|
+
term.focus();
|
|
858
|
+
}, true);
|
|
859
|
+
|
|
860
|
+
// Handle window resize
|
|
861
|
+
window.visualViewport.addEventListener('resize', fitAndResize);
|
|
862
|
+
|
|
863
|
+
term.focus();
|
|
864
|
+
}
|
|
865
|
+
</script>
|
|
866
|
+
</body>
|
|
867
|
+
</html>
|