ghostterm 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/bin/ghostterm.js +375 -0
- package/package.json +31 -0
package/bin/ghostterm.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
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
|
+
const payload = JSON.parse(Buffer.from(cred.id_token.split('.')[1], 'base64').toString());
|
|
41
|
+
if (payload.exp * 1000 > Date.now()) {
|
|
42
|
+
return cred.id_token;
|
|
43
|
+
}
|
|
44
|
+
console.log(' Token expired, need to re-login');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function saveToken(idToken) {
|
|
52
|
+
if (!fs.existsSync(CRED_DIR)) {
|
|
53
|
+
fs.mkdirSync(CRED_DIR, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
fs.writeFileSync(CRED_FILE, JSON.stringify({ id_token: idToken }, null, 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function openBrowser(url) {
|
|
59
|
+
const cmd = os.platform() === 'win32' ? `start "" "${url}"`
|
|
60
|
+
: os.platform() === 'darwin' ? `open "${url}"`
|
|
61
|
+
: `xdg-open "${url}"`;
|
|
62
|
+
exec(cmd);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function startLoginFlow() {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const loginPage = `<!DOCTYPE html>
|
|
68
|
+
<html><head>
|
|
69
|
+
<meta charset="UTF-8">
|
|
70
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
71
|
+
<title>GhostTerm - Sign In</title>
|
|
72
|
+
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
|
73
|
+
<style>
|
|
74
|
+
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; }
|
|
75
|
+
.container { text-align: center; }
|
|
76
|
+
h1 { color: #8b5cf6; margin-bottom: 8px; }
|
|
77
|
+
p { color: #94a3b8; margin-bottom: 24px; }
|
|
78
|
+
#status { margin-top: 20px; color: #10b981; display: none; font-size: 18px; }
|
|
79
|
+
</style>
|
|
80
|
+
</head><body>
|
|
81
|
+
<div class="container">
|
|
82
|
+
<h1>GhostTerm</h1>
|
|
83
|
+
<p>Sign in with Google to link your companion</p>
|
|
84
|
+
<div id="g_id_onload"
|
|
85
|
+
data-client_id="${GOOGLE_CLIENT_ID}"
|
|
86
|
+
data-callback="onSignIn"
|
|
87
|
+
data-auto_select="true">
|
|
88
|
+
</div>
|
|
89
|
+
<div class="g_id_signin" data-type="standard" data-size="large" data-theme="filled_black" data-text="signin_with" data-width="300"></div>
|
|
90
|
+
<div id="status">Login successful! You can close this tab.</div>
|
|
91
|
+
</div>
|
|
92
|
+
<script>
|
|
93
|
+
function onSignIn(response) {
|
|
94
|
+
fetch('/callback', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ id_token: response.credential })
|
|
98
|
+
}).then(() => {
|
|
99
|
+
document.getElementById('status').style.display = 'block';
|
|
100
|
+
document.querySelector('.g_id_signin').style.display = 'none';
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
</body></html>`;
|
|
105
|
+
|
|
106
|
+
const loginServer = http.createServer((req, res) => {
|
|
107
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
108
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
109
|
+
res.end(loginPage);
|
|
110
|
+
} else if (req.method === 'POST' && req.url === '/callback') {
|
|
111
|
+
let body = '';
|
|
112
|
+
req.on('data', chunk => body += chunk);
|
|
113
|
+
req.on('end', () => {
|
|
114
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end('{"ok":true}');
|
|
116
|
+
try {
|
|
117
|
+
const { id_token } = JSON.parse(body);
|
|
118
|
+
saveToken(id_token);
|
|
119
|
+
console.log(' Login successful!');
|
|
120
|
+
setTimeout(() => {
|
|
121
|
+
loginServer.close();
|
|
122
|
+
resolve(id_token);
|
|
123
|
+
}, 500);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
reject(e);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
} else {
|
|
129
|
+
res.writeHead(404);
|
|
130
|
+
res.end();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
loginServer.listen(3000, () => {
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(' Opening browser for Google login...');
|
|
137
|
+
console.log(' If browser does not open, go to: http://localhost:3000');
|
|
138
|
+
console.log('');
|
|
139
|
+
openBrowser('http://localhost:3000');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
loginServer.close();
|
|
144
|
+
reject(new Error('Login timeout (2 min)'));
|
|
145
|
+
}, 120000);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function getToken() {
|
|
150
|
+
let token = loadToken();
|
|
151
|
+
if (token) {
|
|
152
|
+
console.log(' Using cached Google login');
|
|
153
|
+
return token;
|
|
154
|
+
}
|
|
155
|
+
return await startLoginFlow();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ==================== Terminal Sessions ====================
|
|
159
|
+
const sessions = new Map();
|
|
160
|
+
let nextSessionId = 1;
|
|
161
|
+
const OUTPUT_BUFFER_MAX = 500 * 1024;
|
|
162
|
+
|
|
163
|
+
function createSession() {
|
|
164
|
+
const id = nextSessionId++;
|
|
165
|
+
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
166
|
+
const term = pty.spawn(shell, [], {
|
|
167
|
+
name: 'xterm-256color',
|
|
168
|
+
cols: 80,
|
|
169
|
+
rows: 24,
|
|
170
|
+
cwd: path.join(os.homedir(), 'Desktop'),
|
|
171
|
+
env: (() => { const e = { ...process.env, TERM: 'xterm-256color' }; delete e.CLAUDECODE; delete e.CLAUDE_CODE; return e; })(),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const session = { id, term, outputBuffer: '', bufferSeq: 0, exited: false };
|
|
175
|
+
|
|
176
|
+
console.log(` Terminal ${id} spawned (PID: ${term.pid})`);
|
|
177
|
+
|
|
178
|
+
term.onData((data) => {
|
|
179
|
+
session.outputBuffer += data;
|
|
180
|
+
session.bufferSeq += data.length;
|
|
181
|
+
if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
|
|
182
|
+
session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_MAX);
|
|
183
|
+
}
|
|
184
|
+
sendToRelay({ type: 'output', data, seq: session.bufferSeq, id });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
term.onExit(({ exitCode }) => {
|
|
188
|
+
console.log(` Terminal ${id} exited (code: ${exitCode})`);
|
|
189
|
+
session.exited = true;
|
|
190
|
+
sessions.delete(id);
|
|
191
|
+
sendToRelay({ type: 'exit', code: exitCode, id });
|
|
192
|
+
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
sessions.set(id, session);
|
|
196
|
+
return session;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getSessionList() {
|
|
200
|
+
return Array.from(sessions.values()).map(s => ({ id: s.id, exited: s.exited }));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ==================== Relay Connection ====================
|
|
204
|
+
let relayWs = null;
|
|
205
|
+
let pairCode = null;
|
|
206
|
+
let reconnectTimer = null;
|
|
207
|
+
let googleToken = null;
|
|
208
|
+
|
|
209
|
+
function connectToRelay() {
|
|
210
|
+
relayWs = new WebSocket(RELAY_URL);
|
|
211
|
+
|
|
212
|
+
relayWs.on('open', () => {
|
|
213
|
+
console.log(' Connected to relay');
|
|
214
|
+
if (reconnectTimer) {
|
|
215
|
+
clearTimeout(reconnectTimer);
|
|
216
|
+
reconnectTimer = null;
|
|
217
|
+
}
|
|
218
|
+
if (googleToken) {
|
|
219
|
+
sendToRelay({ type: 'auth', token: googleToken });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
relayWs.on('message', (data) => {
|
|
224
|
+
let msg;
|
|
225
|
+
try { msg = JSON.parse(data); } catch { return; }
|
|
226
|
+
handleRelayMessage(msg);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
relayWs.on('close', () => {
|
|
230
|
+
console.log(' Disconnected, reconnecting in 3s...');
|
|
231
|
+
relayWs = null;
|
|
232
|
+
reconnectTimer = setTimeout(connectToRelay, 3000);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
relayWs.on('error', (err) => {
|
|
236
|
+
console.error(' Relay error:', err.message);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function sendToRelay(msg) {
|
|
241
|
+
if (relayWs?.readyState === WebSocket.OPEN) {
|
|
242
|
+
relayWs.send(JSON.stringify(msg));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ==================== Handle Messages ====================
|
|
247
|
+
function handleRelayMessage(msg) {
|
|
248
|
+
switch (msg.type) {
|
|
249
|
+
case 'pair_code':
|
|
250
|
+
pairCode = msg.code;
|
|
251
|
+
console.log('');
|
|
252
|
+
console.log(' ┌────────────────────────────────────┐');
|
|
253
|
+
console.log(` │ Pairing Code: ${pairCode} │`);
|
|
254
|
+
console.log(' │ Open ghostterm.pages.dev on phone │');
|
|
255
|
+
console.log(' └────────────────────────────────────┘');
|
|
256
|
+
console.log('');
|
|
257
|
+
break;
|
|
258
|
+
|
|
259
|
+
case 'auth_ok':
|
|
260
|
+
console.log(` Authenticated as: ${msg.email}`);
|
|
261
|
+
console.log(' Phone will auto-connect with same Google account');
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
case 'auth_error':
|
|
265
|
+
console.log(` Auth failed: ${msg.message}`);
|
|
266
|
+
console.log(' Using pairing code instead');
|
|
267
|
+
googleToken = null;
|
|
268
|
+
try { fs.unlinkSync(CRED_FILE); } catch {}
|
|
269
|
+
break;
|
|
270
|
+
|
|
271
|
+
case 'code_expired':
|
|
272
|
+
console.log(' Code expired, reconnecting...');
|
|
273
|
+
relayWs?.close();
|
|
274
|
+
break;
|
|
275
|
+
|
|
276
|
+
case 'mobile_connected':
|
|
277
|
+
console.log(' Mobile connected!');
|
|
278
|
+
if (sessions.size === 0) createSession();
|
|
279
|
+
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
280
|
+
const first = sessions.values().next().value;
|
|
281
|
+
if (first) sendToRelay({ type: 'attached', id: first.id });
|
|
282
|
+
break;
|
|
283
|
+
|
|
284
|
+
case 'mobile_disconnected':
|
|
285
|
+
console.log(' Mobile disconnected');
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
case 'input':
|
|
289
|
+
if (msg.sessionId) {
|
|
290
|
+
const session = sessions.get(msg.sessionId);
|
|
291
|
+
if (session && !session.exited) session.term.write(msg.data);
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
|
|
295
|
+
case 'resize':
|
|
296
|
+
if (msg.sessionId) {
|
|
297
|
+
const session = sessions.get(msg.sessionId);
|
|
298
|
+
if (session && !session.exited) {
|
|
299
|
+
try { session.term.resize(Math.max(msg.cols, 10), Math.max(msg.rows, 4)); } catch {}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
break;
|
|
303
|
+
|
|
304
|
+
case 'create-session':
|
|
305
|
+
if (sessions.size < MAX_SESSIONS) {
|
|
306
|
+
const session = createSession();
|
|
307
|
+
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
308
|
+
sendToRelay({ type: 'attached', id: session.id });
|
|
309
|
+
} else {
|
|
310
|
+
sendToRelay({ type: 'error_msg', message: `Max ${MAX_SESSIONS} sessions` });
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
|
|
314
|
+
case 'close-session': {
|
|
315
|
+
const s = sessions.get(msg.id);
|
|
316
|
+
if (s && !s.exited) s.term.kill();
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case 'attach': {
|
|
321
|
+
const session = sessions.get(msg.id);
|
|
322
|
+
if (session && !session.exited) sendToRelay({ type: 'attached', id: msg.id });
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
case 'sync': {
|
|
327
|
+
const syncSession = sessions.get(msg.id);
|
|
328
|
+
if (!syncSession || syncSession.exited) return;
|
|
329
|
+
const bufStart = syncSession.bufferSeq - syncSession.outputBuffer.length;
|
|
330
|
+
if (msg.clientSeq >= syncSession.bufferSeq) {
|
|
331
|
+
sendToRelay({ type: 'delta', data: '', seq: syncSession.bufferSeq, id: msg.id });
|
|
332
|
+
} else if (msg.clientSeq >= bufStart) {
|
|
333
|
+
const offset = msg.clientSeq - bufStart;
|
|
334
|
+
sendToRelay({ type: 'delta', data: syncSession.outputBuffer.slice(offset), seq: syncSession.bufferSeq, id: msg.id });
|
|
335
|
+
} else {
|
|
336
|
+
sendToRelay({ type: 'history', data: syncSession.outputBuffer, seq: syncSession.bufferSeq, id: msg.id });
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
case 'upload':
|
|
342
|
+
try {
|
|
343
|
+
const buf = Buffer.from(msg.data, 'base64');
|
|
344
|
+
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}${msg.ext || '.png'}`;
|
|
345
|
+
const filePath = path.join(UPLOAD_DIR, filename);
|
|
346
|
+
fs.writeFileSync(filePath, buf);
|
|
347
|
+
sendToRelay({ type: 'upload_result', path: filePath, filename, size: buf.length });
|
|
348
|
+
console.log(` File saved: ${filePath}`);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
sendToRelay({ type: 'upload_error', message: err.message });
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ==================== Main ====================
|
|
357
|
+
async function main() {
|
|
358
|
+
console.log('');
|
|
359
|
+
console.log(' ╔═══════════════════════════════════╗');
|
|
360
|
+
console.log(' ║ GhostTerm Companion ║');
|
|
361
|
+
console.log(' ║ Mobile terminal for Claude Code ║');
|
|
362
|
+
console.log(' ╚═══════════════════════════════════╝');
|
|
363
|
+
console.log('');
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
googleToken = await getToken();
|
|
367
|
+
} catch (err) {
|
|
368
|
+
console.log(` Google login skipped: ${err.message}`);
|
|
369
|
+
console.log(' Will use pairing code instead');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
connectToRelay();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ghostterm",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Mobile terminal for Claude Code — control your PC from your phone",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ghostterm": "bin/ghostterm.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node bin/ghostterm.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"node-pty": "^1.0.0",
|
|
13
|
+
"ws": "^8.18.0"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude-code",
|
|
17
|
+
"terminal",
|
|
18
|
+
"mobile",
|
|
19
|
+
"remote",
|
|
20
|
+
"ghostterm"
|
|
21
|
+
],
|
|
22
|
+
"author": "GhostTerm",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/user/ghostterm.git"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
}
|
|
31
|
+
}
|