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/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();