ghostterm 1.2.3 → 2.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/CHANGELOG.md +20 -79
- package/README.md +25 -267
- package/bin/ghostterm-p2p.js +306 -0
- package/lib/auth.js +152 -0
- package/lib/pty-manager.js +336 -0
- package/lib/webrtc-peer.js +193 -0
- package/package.json +23 -19
- package/bin/ghostterm.js +0 -614
package/bin/ghostterm.js
DELETED
|
@@ -1,614 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const WebSocket = require('ws');
|
|
4
|
-
const http = require('http');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const fs = require('fs');
|
|
8
|
-
const { exec } = require('child_process');
|
|
9
|
-
|
|
10
|
-
let pty;
|
|
11
|
-
try {
|
|
12
|
-
pty = require('node-pty');
|
|
13
|
-
} catch (e) {
|
|
14
|
-
console.error('');
|
|
15
|
-
console.error(' node-pty is required but failed to load.');
|
|
16
|
-
console.error(' On Windows, you may need: npm install --global windows-build-tools');
|
|
17
|
-
console.error(' On macOS/Linux, you may need: xcode-select --install or apt install build-essential');
|
|
18
|
-
console.error('');
|
|
19
|
-
console.error(' Error:', e.message);
|
|
20
|
-
process.exit(1);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const RELAY_URL = process.env.GHOSTTERM_RELAY || 'wss://ghostterm-relay.fly.dev?role=companion';
|
|
24
|
-
const GOOGLE_CLIENT_ID = '967114885808-9lrt6j05ip65t88sm1rq0foetcu52kdv.apps.googleusercontent.com';
|
|
25
|
-
const MAX_SESSIONS = 4;
|
|
26
|
-
const UPLOAD_DIR = path.join(os.homedir(), 'Desktop', 'claude-uploads');
|
|
27
|
-
const CRED_DIR = path.join(os.homedir(), '.ghostterm');
|
|
28
|
-
const CRED_FILE = path.join(CRED_DIR, 'credentials.json');
|
|
29
|
-
|
|
30
|
-
if (!fs.existsSync(UPLOAD_DIR)) {
|
|
31
|
-
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ==================== Google Auth ====================
|
|
35
|
-
function loadToken() {
|
|
36
|
-
try {
|
|
37
|
-
if (fs.existsSync(CRED_FILE)) {
|
|
38
|
-
const cred = JSON.parse(fs.readFileSync(CRED_FILE, 'utf8'));
|
|
39
|
-
if (cred.id_token) {
|
|
40
|
-
// Long token format: base64payload.signature (no dots in payload)
|
|
41
|
-
const parts = cred.id_token.split('.');
|
|
42
|
-
if (parts.length === 2) {
|
|
43
|
-
// Long token — check our own expiry
|
|
44
|
-
try {
|
|
45
|
-
const data = JSON.parse(Buffer.from(parts[0], 'base64').toString());
|
|
46
|
-
if (data.exp > Date.now()) return cred.id_token;
|
|
47
|
-
console.log(' Long token expired, need to re-login');
|
|
48
|
-
} catch {}
|
|
49
|
-
} else if (parts.length === 3) {
|
|
50
|
-
// Google JWT — check Google expiry
|
|
51
|
-
try {
|
|
52
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
53
|
-
if (payload.exp * 1000 > Date.now()) return cred.id_token;
|
|
54
|
-
console.log(' Google token expired, need to re-login');
|
|
55
|
-
} catch {}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
} catch {}
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function saveToken(idToken) {
|
|
64
|
-
if (!fs.existsSync(CRED_DIR)) {
|
|
65
|
-
fs.mkdirSync(CRED_DIR, { recursive: true });
|
|
66
|
-
}
|
|
67
|
-
fs.writeFileSync(CRED_FILE, JSON.stringify({ id_token: idToken }, null, 2));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function openBrowser(url) {
|
|
71
|
-
const cmd = os.platform() === 'win32' ? `start "" "${url}"`
|
|
72
|
-
: os.platform() === 'darwin' ? `open "${url}"`
|
|
73
|
-
: `xdg-open "${url}"`;
|
|
74
|
-
exec(cmd);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function startLoginFlow() {
|
|
78
|
-
return new Promise((resolve, reject) => {
|
|
79
|
-
const loginPage = `<!DOCTYPE html>
|
|
80
|
-
<html><head>
|
|
81
|
-
<meta charset="UTF-8">
|
|
82
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
83
|
-
<title>GhostTerm - Sign In</title>
|
|
84
|
-
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
|
85
|
-
<style>
|
|
86
|
-
body { font-family: -apple-system, system-ui, sans-serif; background: #0f0f1a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
87
|
-
.container { text-align: center; }
|
|
88
|
-
h1 { color: #8b5cf6; margin-bottom: 8px; }
|
|
89
|
-
p { color: #94a3b8; margin-bottom: 24px; }
|
|
90
|
-
#status { margin-top: 20px; color: #10b981; display: none; font-size: 18px; }
|
|
91
|
-
</style>
|
|
92
|
-
</head><body>
|
|
93
|
-
<div class="container">
|
|
94
|
-
<h1>GhostTerm</h1>
|
|
95
|
-
<p>Sign in with Google to link your companion</p>
|
|
96
|
-
<div id="g_id_onload"
|
|
97
|
-
data-client_id="${GOOGLE_CLIENT_ID}"
|
|
98
|
-
data-callback="onSignIn"
|
|
99
|
-
data-auto_select="true">
|
|
100
|
-
</div>
|
|
101
|
-
<div class="g_id_signin" data-type="standard" data-size="large" data-theme="filled_black" data-text="signin_with" data-width="300"></div>
|
|
102
|
-
<div id="status">Login successful! You can close this tab.</div>
|
|
103
|
-
</div>
|
|
104
|
-
<script>
|
|
105
|
-
function onSignIn(response) {
|
|
106
|
-
fetch('/callback', {
|
|
107
|
-
method: 'POST',
|
|
108
|
-
headers: { 'Content-Type': 'application/json' },
|
|
109
|
-
body: JSON.stringify({ id_token: response.credential })
|
|
110
|
-
}).then(() => {
|
|
111
|
-
document.getElementById('status').style.display = 'block';
|
|
112
|
-
document.querySelector('.g_id_signin').style.display = 'none';
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
</script>
|
|
116
|
-
</body></html>`;
|
|
117
|
-
|
|
118
|
-
const loginServer = http.createServer((req, res) => {
|
|
119
|
-
if (req.method === 'GET' && req.url === '/') {
|
|
120
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
121
|
-
res.end(loginPage);
|
|
122
|
-
} else if (req.method === 'POST' && req.url === '/callback') {
|
|
123
|
-
let body = '';
|
|
124
|
-
req.on('data', chunk => body += chunk);
|
|
125
|
-
req.on('end', () => {
|
|
126
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
127
|
-
res.end('{"ok":true}');
|
|
128
|
-
try {
|
|
129
|
-
const { id_token } = JSON.parse(body);
|
|
130
|
-
saveToken(id_token);
|
|
131
|
-
console.log(' Login successful!');
|
|
132
|
-
setTimeout(() => {
|
|
133
|
-
loginServer.close();
|
|
134
|
-
resolve(id_token);
|
|
135
|
-
}, 500);
|
|
136
|
-
} catch (e) {
|
|
137
|
-
reject(e);
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
} else {
|
|
141
|
-
res.writeHead(404);
|
|
142
|
-
res.end();
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
loginServer.listen(3000, () => {
|
|
147
|
-
console.log('');
|
|
148
|
-
console.log(' Opening browser for Google login...');
|
|
149
|
-
console.log(' If browser does not open, go to: http://localhost:3000');
|
|
150
|
-
console.log('');
|
|
151
|
-
openBrowser('http://localhost:3000');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
setTimeout(() => {
|
|
155
|
-
loginServer.close();
|
|
156
|
-
reject(new Error('Login timeout (2 min)'));
|
|
157
|
-
}, 120000);
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
async function getToken() {
|
|
162
|
-
let token = loadToken();
|
|
163
|
-
if (token) {
|
|
164
|
-
console.log(' Using cached Google login');
|
|
165
|
-
return token;
|
|
166
|
-
}
|
|
167
|
-
return await startLoginFlow();
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ==================== Terminal Sessions ====================
|
|
171
|
-
const sessions = new Map();
|
|
172
|
-
let nextSessionId = 1;
|
|
173
|
-
const OUTPUT_BUFFER_MAX = 500 * 1024;
|
|
174
|
-
|
|
175
|
-
function createSession() {
|
|
176
|
-
const id = nextSessionId++;
|
|
177
|
-
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
178
|
-
const term = pty.spawn(shell, [], {
|
|
179
|
-
name: 'xterm-256color',
|
|
180
|
-
cols: 80,
|
|
181
|
-
rows: 24,
|
|
182
|
-
cwd: path.join(os.homedir(), 'Desktop'),
|
|
183
|
-
env: (() => { const e = { ...process.env, TERM: 'xterm-256color' }; delete e.CLAUDECODE; delete e.CLAUDE_CODE; return e; })(),
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
const session = { id, term, outputBuffer: '', bufferSeq: 0, exited: false, pendingData: '', flushTimer: null };
|
|
187
|
-
|
|
188
|
-
console.log(` Terminal ${id} spawned (PID: ${term.pid})`);
|
|
189
|
-
|
|
190
|
-
function flushOutput() {
|
|
191
|
-
if (session.pendingData) {
|
|
192
|
-
sendToRelay({ type: 'output', data: session.pendingData, seq: session.bufferSeq, id });
|
|
193
|
-
session.pendingData = '';
|
|
194
|
-
}
|
|
195
|
-
session.flushTimer = null;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
term.onData((data) => {
|
|
199
|
-
session.outputBuffer += data;
|
|
200
|
-
session.bufferSeq += data.length;
|
|
201
|
-
if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
|
|
202
|
-
session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_MAX);
|
|
203
|
-
}
|
|
204
|
-
session.pendingData += data;
|
|
205
|
-
// Adaptive: small output (keystroke echo) → flush immediately
|
|
206
|
-
// Large output (bulk) → batch for 8ms
|
|
207
|
-
if (session.pendingData.length < 64) {
|
|
208
|
-
if (session.flushTimer) { clearTimeout(session.flushTimer); session.flushTimer = null; }
|
|
209
|
-
flushOutput();
|
|
210
|
-
} else if (!session.flushTimer) {
|
|
211
|
-
session.flushTimer = setTimeout(flushOutput, 8);
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
term.onExit(({ exitCode }) => {
|
|
216
|
-
console.log(` Terminal ${id} exited (code: ${exitCode})`);
|
|
217
|
-
session.exited = true;
|
|
218
|
-
sessions.delete(id);
|
|
219
|
-
sendToRelay({ type: 'exit', code: exitCode, id });
|
|
220
|
-
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
sessions.set(id, session);
|
|
224
|
-
return session;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function getSessionList() {
|
|
228
|
-
return Array.from(sessions.values()).map(s => ({ id: s.id, exited: s.exited }));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ==================== Standby Session (pre-spawn for instant open) ====================
|
|
232
|
-
let standbySession = null;
|
|
233
|
-
|
|
234
|
-
function prepareStandby() {
|
|
235
|
-
// Only prepare if under max and no standby exists
|
|
236
|
-
if (standbySession || sessions.size >= MAX_SESSIONS) return;
|
|
237
|
-
standbySession = createSession();
|
|
238
|
-
// Remove from active sessions — it's hidden until needed
|
|
239
|
-
sessions.delete(standbySession.id);
|
|
240
|
-
console.log(` Standby terminal ${standbySession.id} ready`);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function useStandbyOrCreate() {
|
|
244
|
-
let s;
|
|
245
|
-
if (standbySession && !standbySession.exited) {
|
|
246
|
-
s = standbySession;
|
|
247
|
-
sessions.set(s.id, s);
|
|
248
|
-
standbySession = null;
|
|
249
|
-
console.log(` Using standby terminal ${s.id}`);
|
|
250
|
-
} else {
|
|
251
|
-
standbySession = null;
|
|
252
|
-
s = createSession();
|
|
253
|
-
}
|
|
254
|
-
// Prepare next standby after a short delay
|
|
255
|
-
setTimeout(() => prepareStandby(), 1000);
|
|
256
|
-
return s;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ==================== Relay Connection ====================
|
|
260
|
-
let relayWs = null;
|
|
261
|
-
let pairCode = null;
|
|
262
|
-
let reconnectTimer = null;
|
|
263
|
-
let googleToken = null;
|
|
264
|
-
|
|
265
|
-
function connectToRelay() {
|
|
266
|
-
relayWs = new WebSocket(RELAY_URL);
|
|
267
|
-
|
|
268
|
-
relayWs.on('open', () => {
|
|
269
|
-
console.log(' Connected to relay');
|
|
270
|
-
if (reconnectTimer) {
|
|
271
|
-
clearTimeout(reconnectTimer);
|
|
272
|
-
reconnectTimer = null;
|
|
273
|
-
}
|
|
274
|
-
if (googleToken) {
|
|
275
|
-
sendToRelay({ type: 'auth', token: googleToken });
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
relayWs.on('message', (data) => {
|
|
280
|
-
let msg;
|
|
281
|
-
try { msg = JSON.parse(data); } catch { return; }
|
|
282
|
-
handleRelayMessage(msg);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
relayWs.on('close', () => {
|
|
286
|
-
console.log(' Disconnected, reconnecting in 3s...');
|
|
287
|
-
relayWs = null;
|
|
288
|
-
reconnectTimer = setTimeout(connectToRelay, 3000);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
relayWs.on('error', (err) => {
|
|
292
|
-
console.error(' Relay error:', err.message);
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function sendToRelay(msg) {
|
|
297
|
-
if (relayWs?.readyState === WebSocket.OPEN) {
|
|
298
|
-
relayWs.send(JSON.stringify(msg));
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// ==================== Handle Messages ====================
|
|
303
|
-
function handleRelayMessage(msg) {
|
|
304
|
-
switch (msg.type) {
|
|
305
|
-
case 'pair_code':
|
|
306
|
-
pairCode = msg.code;
|
|
307
|
-
console.log('');
|
|
308
|
-
console.log(' ┌────────────────────────────────────┐');
|
|
309
|
-
console.log(` │ Pairing Code: ${pairCode} │`);
|
|
310
|
-
console.log(' │ Open ghostterm.pages.dev on phone │');
|
|
311
|
-
console.log(' └────────────────────────────────────┘');
|
|
312
|
-
console.log('');
|
|
313
|
-
break;
|
|
314
|
-
|
|
315
|
-
case 'auth_ok':
|
|
316
|
-
console.log(` Authenticated as: ${msg.email}`);
|
|
317
|
-
console.log(' Phone will auto-connect with same Google account');
|
|
318
|
-
// Save long token if provided (valid 30 days, no more Google expiry issues)
|
|
319
|
-
if (msg.longToken) {
|
|
320
|
-
saveToken(msg.longToken);
|
|
321
|
-
googleToken = msg.longToken;
|
|
322
|
-
console.log(' Long-lived token saved (30 days)');
|
|
323
|
-
}
|
|
324
|
-
prepareStandby();
|
|
325
|
-
break;
|
|
326
|
-
|
|
327
|
-
case 'auth_error':
|
|
328
|
-
console.log(` Auth failed: ${msg.message}`);
|
|
329
|
-
console.log(' Using pairing code instead');
|
|
330
|
-
googleToken = null;
|
|
331
|
-
try { fs.unlinkSync(CRED_FILE); } catch {}
|
|
332
|
-
break;
|
|
333
|
-
|
|
334
|
-
case 'code_expired':
|
|
335
|
-
console.log(' Code expired, reconnecting...');
|
|
336
|
-
relayWs?.close();
|
|
337
|
-
break;
|
|
338
|
-
|
|
339
|
-
case 'mobile_connected':
|
|
340
|
-
console.log(' Mobile connected!');
|
|
341
|
-
if (sessions.size === 0) {
|
|
342
|
-
// Use standby session if available, otherwise create new
|
|
343
|
-
const s = useStandbyOrCreate();
|
|
344
|
-
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
345
|
-
sendToRelay({ type: 'attached', id: s.id });
|
|
346
|
-
} else {
|
|
347
|
-
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
348
|
-
const first = sessions.values().next().value;
|
|
349
|
-
if (first) sendToRelay({ type: 'attached', id: first.id });
|
|
350
|
-
}
|
|
351
|
-
break;
|
|
352
|
-
|
|
353
|
-
case 'mobile_disconnected':
|
|
354
|
-
console.log(' Mobile disconnected');
|
|
355
|
-
break;
|
|
356
|
-
|
|
357
|
-
case 'input':
|
|
358
|
-
if (msg.sessionId) {
|
|
359
|
-
const session = sessions.get(msg.sessionId);
|
|
360
|
-
if (session && !session.exited) session.term.write(msg.data);
|
|
361
|
-
}
|
|
362
|
-
break;
|
|
363
|
-
|
|
364
|
-
case 'resize':
|
|
365
|
-
if (msg.sessionId) {
|
|
366
|
-
const session = sessions.get(msg.sessionId);
|
|
367
|
-
if (session && !session.exited) {
|
|
368
|
-
try { session.term.resize(Math.max(msg.cols, 10), Math.max(msg.rows, 4)); } catch {}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
break;
|
|
372
|
-
|
|
373
|
-
case 'create-session':
|
|
374
|
-
if (sessions.size < MAX_SESSIONS) {
|
|
375
|
-
const session = useStandbyOrCreate();
|
|
376
|
-
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
377
|
-
sendToRelay({ type: 'attached', id: session.id });
|
|
378
|
-
} else {
|
|
379
|
-
sendToRelay({ type: 'error_msg', message: `Max ${MAX_SESSIONS} sessions` });
|
|
380
|
-
}
|
|
381
|
-
break;
|
|
382
|
-
|
|
383
|
-
case 'close-session': {
|
|
384
|
-
const s = sessions.get(msg.id);
|
|
385
|
-
if (s && !s.exited) s.term.kill();
|
|
386
|
-
break;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
case 'attach': {
|
|
390
|
-
const session = sessions.get(msg.id);
|
|
391
|
-
if (session && !session.exited) sendToRelay({ type: 'attached', id: msg.id });
|
|
392
|
-
break;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
case 'sync': {
|
|
396
|
-
const syncSession = sessions.get(msg.id);
|
|
397
|
-
if (!syncSession || syncSession.exited) return;
|
|
398
|
-
const bufStart = syncSession.bufferSeq - syncSession.outputBuffer.length;
|
|
399
|
-
if (msg.clientSeq >= syncSession.bufferSeq) {
|
|
400
|
-
sendToRelay({ type: 'delta', data: '', seq: syncSession.bufferSeq, id: msg.id });
|
|
401
|
-
} else if (msg.clientSeq >= bufStart) {
|
|
402
|
-
const offset = msg.clientSeq - bufStart;
|
|
403
|
-
sendToRelay({ type: 'delta', data: syncSession.outputBuffer.slice(offset), seq: syncSession.bufferSeq, id: msg.id });
|
|
404
|
-
} else {
|
|
405
|
-
sendToRelay({ type: 'history', data: syncSession.outputBuffer, seq: syncSession.bufferSeq, id: msg.id });
|
|
406
|
-
}
|
|
407
|
-
break;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
case 'upload':
|
|
411
|
-
try {
|
|
412
|
-
const UPLOAD_MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
|
413
|
-
const UPLOAD_EXT_WHITELIST = ['.png', '.jpg', '.jpeg', '.pdf', '.txt', '.md', '.json', '.csv', '.py', '.js', '.ts', '.html', '.css'];
|
|
414
|
-
const ext = (msg.ext || '.png').toLowerCase();
|
|
415
|
-
if (!UPLOAD_EXT_WHITELIST.includes(ext)) {
|
|
416
|
-
sendToRelay({ type: 'upload_error', message: `File type not allowed: ${ext}` });
|
|
417
|
-
break;
|
|
418
|
-
}
|
|
419
|
-
const buf = Buffer.from(msg.data, 'base64');
|
|
420
|
-
if (buf.length > UPLOAD_MAX_SIZE) {
|
|
421
|
-
sendToRelay({ type: 'upload_error', message: `File too large: ${(buf.length / 1024 / 1024).toFixed(1)}MB (max 5MB)` });
|
|
422
|
-
break;
|
|
423
|
-
}
|
|
424
|
-
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}${ext}`;
|
|
425
|
-
const filePath = path.join(UPLOAD_DIR, filename);
|
|
426
|
-
fs.writeFileSync(filePath, buf);
|
|
427
|
-
sendToRelay({ type: 'upload_result', path: filePath, filename, size: buf.length });
|
|
428
|
-
console.log(` File saved: ${filePath}`);
|
|
429
|
-
} catch (err) {
|
|
430
|
-
sendToRelay({ type: 'upload_error', message: err.message });
|
|
431
|
-
}
|
|
432
|
-
break;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// ==================== Background Mode ====================
|
|
437
|
-
const PID_FILE = path.join(CRED_DIR, 'ghostterm.pid');
|
|
438
|
-
const LOG_FILE = path.join(CRED_DIR, 'ghostterm.log');
|
|
439
|
-
|
|
440
|
-
function isRunning(pid) {
|
|
441
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function handleSubcommand() {
|
|
445
|
-
const arg = process.argv[2];
|
|
446
|
-
|
|
447
|
-
if (arg === 'stop') {
|
|
448
|
-
if (fs.existsSync(PID_FILE)) {
|
|
449
|
-
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8'));
|
|
450
|
-
if (isRunning(pid)) {
|
|
451
|
-
process.kill(pid);
|
|
452
|
-
fs.unlinkSync(PID_FILE);
|
|
453
|
-
console.log(` GhostTerm stopped (PID: ${pid})`);
|
|
454
|
-
} else {
|
|
455
|
-
fs.unlinkSync(PID_FILE);
|
|
456
|
-
console.log(' GhostTerm was not running');
|
|
457
|
-
}
|
|
458
|
-
} else {
|
|
459
|
-
console.log(' GhostTerm is not running');
|
|
460
|
-
}
|
|
461
|
-
process.exit(0);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (arg === 'status') {
|
|
465
|
-
if (fs.existsSync(PID_FILE)) {
|
|
466
|
-
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8'));
|
|
467
|
-
if (isRunning(pid)) {
|
|
468
|
-
console.log(` GhostTerm is running (PID: ${pid})`);
|
|
469
|
-
} else {
|
|
470
|
-
fs.unlinkSync(PID_FILE);
|
|
471
|
-
console.log(' GhostTerm is not running');
|
|
472
|
-
}
|
|
473
|
-
} else {
|
|
474
|
-
console.log(' GhostTerm is not running');
|
|
475
|
-
}
|
|
476
|
-
process.exit(0);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (arg === 'logs') {
|
|
480
|
-
if (fs.existsSync(LOG_FILE)) {
|
|
481
|
-
const lines = fs.readFileSync(LOG_FILE, 'utf8').split('\n').slice(-50);
|
|
482
|
-
console.log(lines.join('\n'));
|
|
483
|
-
} else {
|
|
484
|
-
console.log(' No logs found');
|
|
485
|
-
}
|
|
486
|
-
process.exit(0);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// No subcommand = start in background (unless already daemonized via --daemon or --supervisor)
|
|
490
|
-
if (arg !== '--daemon' && arg !== '--supervisor') {
|
|
491
|
-
// Check if already running
|
|
492
|
-
if (fs.existsSync(PID_FILE)) {
|
|
493
|
-
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8'));
|
|
494
|
-
if (isRunning(pid)) {
|
|
495
|
-
console.log(` GhostTerm is already running (PID: ${pid})`);
|
|
496
|
-
console.log(' Stop: npx ghostterm stop');
|
|
497
|
-
process.exit(0);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// First-time login needs foreground (browser opens)
|
|
502
|
-
const needsLogin = !fs.existsSync(CRED_FILE);
|
|
503
|
-
if (needsLogin) {
|
|
504
|
-
// Run in foreground for first login, then restart as daemon
|
|
505
|
-
return 'foreground-first-login';
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Spawn self as supervisor in background
|
|
509
|
-
const { spawn, execSync } = require('child_process');
|
|
510
|
-
const out = fs.openSync(LOG_FILE, 'a');
|
|
511
|
-
|
|
512
|
-
if (os.platform() === 'win32') {
|
|
513
|
-
// Windows: use VBS to launch completely hidden (no taskbar, no window)
|
|
514
|
-
// node-pty needs a console; VBS ws.Run with 0 = hidden window
|
|
515
|
-
const vbsFile = path.join(CRED_DIR, 'launcher.vbs');
|
|
516
|
-
const cmd = `"${process.execPath}" "${__filename}" --supervisor`;
|
|
517
|
-
fs.writeFileSync(vbsFile,
|
|
518
|
-
`Set ws = CreateObject("WScript.Shell")\n` +
|
|
519
|
-
`ws.Run "${cmd.replace(/"/g, '""')}", 0, False\n`
|
|
520
|
-
);
|
|
521
|
-
require('child_process').execSync(`cscript //nologo "${vbsFile}"`, { stdio: 'ignore' });
|
|
522
|
-
// Wait for supervisor to write PID file
|
|
523
|
-
const waitUntil = Date.now() + 10000;
|
|
524
|
-
while (Date.now() < waitUntil) {
|
|
525
|
-
if (fs.existsSync(PID_FILE)) {
|
|
526
|
-
const pid = fs.readFileSync(PID_FILE, 'utf8').trim();
|
|
527
|
-
if (pid && isRunning(parseInt(pid))) break;
|
|
528
|
-
}
|
|
529
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
|
|
530
|
-
}
|
|
531
|
-
} else {
|
|
532
|
-
// macOS/Linux: detached works fine
|
|
533
|
-
const child = spawn(process.execPath, [__filename, '--supervisor'], {
|
|
534
|
-
detached: true,
|
|
535
|
-
stdio: ['ignore', out, out],
|
|
536
|
-
env: { ...process.env },
|
|
537
|
-
});
|
|
538
|
-
child.unref();
|
|
539
|
-
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
540
|
-
}
|
|
541
|
-
const pid = fs.existsSync(PID_FILE) ? fs.readFileSync(PID_FILE, 'utf8').trim() : '?';
|
|
542
|
-
console.log('');
|
|
543
|
-
console.log(` ✅ GhostTerm running in background (PID: ${pid})`);
|
|
544
|
-
console.log(' 📱 Open: ghostterm.pages.dev');
|
|
545
|
-
console.log(' 🛑 Stop: npx ghostterm stop');
|
|
546
|
-
console.log(' 📋 Logs: npx ghostterm logs');
|
|
547
|
-
console.log('');
|
|
548
|
-
process.exit(0);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// --supervisor: watchdog that restarts worker on crash
|
|
552
|
-
if (arg === '--supervisor') {
|
|
553
|
-
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
554
|
-
const { spawn } = require('child_process');
|
|
555
|
-
let restartCount = 0;
|
|
556
|
-
|
|
557
|
-
function spawnWorker() {
|
|
558
|
-
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
559
|
-
const worker = spawn(process.execPath, [__filename, '--daemon'], {
|
|
560
|
-
stdio: ['ignore', logFd, logFd],
|
|
561
|
-
env: { ...process.env },
|
|
562
|
-
});
|
|
563
|
-
worker.on('exit', (code) => {
|
|
564
|
-
if (code === 0) {
|
|
565
|
-
// Clean exit (e.g. from stop command), don't restart
|
|
566
|
-
process.exit(0);
|
|
567
|
-
}
|
|
568
|
-
restartCount++;
|
|
569
|
-
const delay = Math.min(restartCount * 3000, 30000); // 3s, 6s, 9s... max 30s
|
|
570
|
-
console.log(` [supervisor] Worker exited (code ${code}), restarting in ${delay / 1000}s... (restart #${restartCount})`);
|
|
571
|
-
setTimeout(spawnWorker, delay);
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
spawnWorker();
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// --daemon: write PID file and continue to main()
|
|
580
|
-
return 'daemon';
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// ==================== Main ====================
|
|
584
|
-
async function main() {
|
|
585
|
-
const mode = handleSubcommand();
|
|
586
|
-
|
|
587
|
-
console.log('');
|
|
588
|
-
console.log(' ╔═══════════════════════════════════╗');
|
|
589
|
-
console.log(' ║ GhostTerm Companion ║');
|
|
590
|
-
console.log(' ║ Mobile terminal for Claude Code ║');
|
|
591
|
-
console.log(' ╚═══════════════════════════════════╝');
|
|
592
|
-
console.log('');
|
|
593
|
-
|
|
594
|
-
try {
|
|
595
|
-
googleToken = await getToken();
|
|
596
|
-
} catch (err) {
|
|
597
|
-
console.log(` Google login skipped: ${err.message}`);
|
|
598
|
-
console.log(' Will use pairing code instead');
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// First login done in foreground — now restart in background
|
|
602
|
-
if (mode === 'foreground-first-login' && googleToken) {
|
|
603
|
-
console.log('');
|
|
604
|
-
console.log(' Login successful! Starting in background...');
|
|
605
|
-
// Re-run ourselves which will take the normal background start path
|
|
606
|
-
const { execSync } = require('child_process');
|
|
607
|
-
execSync(`"${process.execPath}" "${__filename}"`, { stdio: 'inherit' });
|
|
608
|
-
process.exit(0);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
connectToRelay();
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
main();
|