@dinko_abdic/claude-code-remote 0.1.1
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/ecosystem.config.js +17 -0
- package/package.json +22 -0
- package/src/auth.js +43 -0
- package/src/config.js +58 -0
- package/src/dashboard.js +357 -0
- package/src/index.js +445 -0
- package/src/logger.js +9 -0
- package/src/pick-folder.ps1 +82 -0
- package/src/process-scanner.js +256 -0
- package/src/protocol.js +86 -0
- package/src/sandbox.js +37 -0
- package/src/tailscale.js +90 -0
- package/src/terminal-manager.js +258 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const { WebSocketServer } = require('ws');
|
|
7
|
+
const { v4: uuidv4 } = require('uuid');
|
|
8
|
+
const { loadOrCreate, saveConfig, CONFIG_PATH } = require('./config');
|
|
9
|
+
const { authenticate } = require('./auth');
|
|
10
|
+
const { MessageType, validate, makeSessionCreated, makeSessionEnded, makeError } = require('./protocol');
|
|
11
|
+
const tm = require('./terminal-manager');
|
|
12
|
+
const { getTailscaleStatus } = require('./tailscale');
|
|
13
|
+
const { generateDashboard } = require('./dashboard');
|
|
14
|
+
const logger = require('./logger');
|
|
15
|
+
|
|
16
|
+
const config = loadOrCreate();
|
|
17
|
+
const { port, shell, sandboxRoot } = config;
|
|
18
|
+
|
|
19
|
+
// --- Auth helpers ---
|
|
20
|
+
const tsInfo = getTailscaleStatus();
|
|
21
|
+
const localTailscaleIp = tsInfo.ip;
|
|
22
|
+
|
|
23
|
+
function isLocalRequest(req) {
|
|
24
|
+
const addr = req.socket.remoteAddress;
|
|
25
|
+
if (addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1') return true;
|
|
26
|
+
// Requests from the daemon's own Tailscale IP are local (same machine via Tailscale)
|
|
27
|
+
if (localTailscaleIp && (addr === localTailscaleIp || addr === `::ffff:${localTailscaleIp}`)) return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- HTTP request handler (shared by both servers) ---
|
|
32
|
+
async function requestHandler(req, res) {
|
|
33
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
34
|
+
const pathname = url.pathname;
|
|
35
|
+
|
|
36
|
+
// Auth gate: localhost is allowed without token, remote requires auth
|
|
37
|
+
const local = isLocalRequest(req);
|
|
38
|
+
if (!local && !authenticate(req, config.token)) {
|
|
39
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
40
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (pathname === '/' || pathname === '/dashboard') {
|
|
45
|
+
try {
|
|
46
|
+
const tsStatus = getTailscaleStatus();
|
|
47
|
+
const sessionList = tm.getSessionList();
|
|
48
|
+
const connectedDevices = sessionList
|
|
49
|
+
.filter(s => s.hasClient)
|
|
50
|
+
.map(s => ({ sessionId: s.id, name: s.name, deviceName: s.deviceName }));
|
|
51
|
+
const html = await generateDashboard(config, tsStatus, tm.getSessionCount(), connectedDevices, local);
|
|
52
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
53
|
+
res.end(html);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
logger.error(`Dashboard error: ${err.message}`);
|
|
56
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
57
|
+
res.end('Internal server error');
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (pathname === '/api/status') {
|
|
63
|
+
const tsStatus = getTailscaleStatus();
|
|
64
|
+
const sessionList = tm.getSessionList();
|
|
65
|
+
const connectedDevices = sessionList
|
|
66
|
+
.filter(s => s.hasClient)
|
|
67
|
+
.map(s => ({ sessionId: s.id, name: s.name, deviceName: s.deviceName }));
|
|
68
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
69
|
+
res.end(JSON.stringify({
|
|
70
|
+
tailscale: { installed: tsStatus.installed, running: tsStatus.running, ip: tsStatus.ip },
|
|
71
|
+
port: config.port,
|
|
72
|
+
sessions: tm.getSessionCount(),
|
|
73
|
+
connectedDevices,
|
|
74
|
+
defaultCwd: config.defaultCwd || null,
|
|
75
|
+
}));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (pathname === '/api/settings' && req.method === 'GET') {
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
81
|
+
res.end(JSON.stringify({ defaultCwd: config.defaultCwd || '' }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (pathname === '/api/pick-directory' && req.method === 'POST') {
|
|
86
|
+
const { execFile } = require('child_process');
|
|
87
|
+
const scriptPath = path.join(__dirname, 'pick-folder.ps1');
|
|
88
|
+
execFile('powershell', ['-STA', '-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], { encoding: 'utf-8', timeout: 120000, windowsHide: true }, (err, stdout) => {
|
|
89
|
+
if (err) {
|
|
90
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
91
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
95
|
+
res.end(JSON.stringify({ path: stdout.trim() }));
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (pathname === '/api/browse' && req.method === 'GET') {
|
|
101
|
+
const { validatePath } = require('./sandbox');
|
|
102
|
+
const requestedPath = url.searchParams.get('path');
|
|
103
|
+
|
|
104
|
+
// No path provided on Windows → list drive letters
|
|
105
|
+
if (!requestedPath && process.platform === 'win32') {
|
|
106
|
+
const { execSync } = require('child_process');
|
|
107
|
+
try {
|
|
108
|
+
const raw = execSync('wmic logicaldisk get name,volumename', { encoding: 'utf-8' });
|
|
109
|
+
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
|
110
|
+
// Skip header line, parse "C: Label" rows
|
|
111
|
+
const drives = [];
|
|
112
|
+
for (let i = 1; i < lines.length; i++) {
|
|
113
|
+
const match = lines[i].match(/^([A-Z]:)\s*(.*)/i);
|
|
114
|
+
if (match) {
|
|
115
|
+
drives.push({
|
|
116
|
+
name: match[1].toUpperCase() + '\\',
|
|
117
|
+
label: match[2].trim() || null,
|
|
118
|
+
isDirectory: true,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
123
|
+
res.end(JSON.stringify({
|
|
124
|
+
path: null,
|
|
125
|
+
parent: null,
|
|
126
|
+
entries: drives,
|
|
127
|
+
}));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify({ error: 'Failed to list drives: ' + err.message }));
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// No path and not Windows → use defaultCwd or home
|
|
136
|
+
const targetPath = requestedPath || config.defaultCwd || os.homedir();
|
|
137
|
+
|
|
138
|
+
// Sandbox check
|
|
139
|
+
if (config.sandboxRoot) {
|
|
140
|
+
const validated = validatePath(targetPath, config.sandboxRoot);
|
|
141
|
+
if (!validated) {
|
|
142
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
143
|
+
res.end(JSON.stringify({ error: 'Path outside sandbox' }));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const resolved = path.resolve(targetPath);
|
|
150
|
+
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
|
151
|
+
const dirs = entries
|
|
152
|
+
.filter(e => e.isDirectory())
|
|
153
|
+
.map(e => ({ name: e.name, isDirectory: true }))
|
|
154
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
155
|
+
|
|
156
|
+
const parsed = path.parse(resolved);
|
|
157
|
+
const parent = parsed.dir === resolved ? null : parsed.dir;
|
|
158
|
+
|
|
159
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
160
|
+
res.end(JSON.stringify({ path: resolved, parent, entries: dirs }));
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const status = err.code === 'ENOENT' ? 404 : err.code === 'EACCES' ? 403 : 500;
|
|
163
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
164
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (pathname === '/api/mkdir' && req.method === 'POST') {
|
|
170
|
+
const { validatePath } = require('./sandbox');
|
|
171
|
+
let body = '';
|
|
172
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
173
|
+
req.on('end', () => {
|
|
174
|
+
try {
|
|
175
|
+
const { path: dirPath } = JSON.parse(body);
|
|
176
|
+
if (!dirPath || typeof dirPath !== 'string') {
|
|
177
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
178
|
+
res.end(JSON.stringify({ error: 'Missing or invalid "path" field' }));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const resolved = path.resolve(dirPath);
|
|
183
|
+
|
|
184
|
+
if (config.sandboxRoot) {
|
|
185
|
+
const validated = validatePath(resolved, config.sandboxRoot);
|
|
186
|
+
if (!validated) {
|
|
187
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
188
|
+
res.end(JSON.stringify({ error: 'Path outside sandbox' }));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
194
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
195
|
+
res.end(JSON.stringify({ ok: true, path: resolved }));
|
|
196
|
+
} catch (err) {
|
|
197
|
+
const status = err.code === 'EACCES' ? 403 : 500;
|
|
198
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
199
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (pathname === '/api/settings' && req.method === 'POST') {
|
|
206
|
+
let body = '';
|
|
207
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
208
|
+
req.on('end', () => {
|
|
209
|
+
try {
|
|
210
|
+
const updates = JSON.parse(body);
|
|
211
|
+
if (typeof updates.defaultCwd === 'string') {
|
|
212
|
+
config.defaultCwd = updates.defaultCwd || null;
|
|
213
|
+
}
|
|
214
|
+
saveConfig(config);
|
|
215
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
216
|
+
res.end(JSON.stringify({ ok: true }));
|
|
217
|
+
} catch (err) {
|
|
218
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
219
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (pathname === '/api/sessions' && req.method === 'GET') {
|
|
226
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
227
|
+
res.end(JSON.stringify({ sessions: tm.getSessionList() }));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (pathname === '/api/external-sessions' && req.method === 'GET') {
|
|
232
|
+
const { scanExternalClaudeSessions } = require('./process-scanner');
|
|
233
|
+
const daemonPids = tm.getDaemonPtyPids();
|
|
234
|
+
const external = scanExternalClaudeSessions(daemonPids);
|
|
235
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
236
|
+
res.end(JSON.stringify({ sessions: external }));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// DELETE /api/sessions/:id
|
|
241
|
+
const deleteMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
|
|
242
|
+
if (deleteMatch && req.method === 'DELETE') {
|
|
243
|
+
const sessionId = decodeURIComponent(deleteMatch[1]);
|
|
244
|
+
tm.destroySession(sessionId);
|
|
245
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
246
|
+
res.end(JSON.stringify({ ok: true }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
251
|
+
res.end('claude-code-remote daemon is running\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- WebSocket upgrade handler (shared by both servers) ---
|
|
255
|
+
function upgradeHandler(req, socket, head) {
|
|
256
|
+
if (!authenticate(req, config.token)) {
|
|
257
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
258
|
+
socket.destroy();
|
|
259
|
+
logger.warn('Rejected unauthenticated upgrade request');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
264
|
+
wss.emit('connection', ws, req);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- Create servers ---
|
|
269
|
+
const localServer = http.createServer(requestHandler);
|
|
270
|
+
localServer.on('upgrade', upgradeHandler);
|
|
271
|
+
|
|
272
|
+
let tsServer = null;
|
|
273
|
+
|
|
274
|
+
// --- WebSocket server (noServer mode) ---
|
|
275
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
276
|
+
|
|
277
|
+
// --- Connection handling ---
|
|
278
|
+
wss.on('connection', (ws, req) => {
|
|
279
|
+
const clientAddr = req.socket.remoteAddress;
|
|
280
|
+
logger.info(`Client connected from ${clientAddr}`);
|
|
281
|
+
|
|
282
|
+
// Parse desired cols/rows from query params, default 80x24
|
|
283
|
+
let cols = 80;
|
|
284
|
+
let rows = 24;
|
|
285
|
+
let cwd = config.defaultCwd || process.cwd();
|
|
286
|
+
let sessionName;
|
|
287
|
+
let deviceName;
|
|
288
|
+
try {
|
|
289
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
290
|
+
if (url.searchParams.has('cols')) cols = Math.max(1, parseInt(url.searchParams.get('cols'), 10) || 80);
|
|
291
|
+
if (url.searchParams.has('rows')) rows = Math.max(1, parseInt(url.searchParams.get('rows'), 10) || 24);
|
|
292
|
+
if (url.searchParams.has('cwd') && url.searchParams.get('cwd')) cwd = url.searchParams.get('cwd');
|
|
293
|
+
if (url.searchParams.has('name') && url.searchParams.get('name')) sessionName = url.searchParams.get('name');
|
|
294
|
+
if (url.searchParams.has('deviceName') && url.searchParams.get('deviceName')) deviceName = url.searchParams.get('deviceName');
|
|
295
|
+
} catch {
|
|
296
|
+
// use defaults
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Reconnect to existing session or create new one
|
|
300
|
+
let sessionId;
|
|
301
|
+
let requestedId;
|
|
302
|
+
try {
|
|
303
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
304
|
+
requestedId = url.searchParams.get('sessionId');
|
|
305
|
+
} catch {}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
if (requestedId && tm.getSession(requestedId)) {
|
|
309
|
+
// Reattach to existing session
|
|
310
|
+
sessionId = requestedId;
|
|
311
|
+
if (deviceName) tm.setDeviceName(sessionId, deviceName);
|
|
312
|
+
tm.attachWebSocket(sessionId, ws);
|
|
313
|
+
const session = tm.getSession(sessionId);
|
|
314
|
+
ws.send(makeSessionCreated(sessionId, cols, rows, {
|
|
315
|
+
cwd: session.cwd,
|
|
316
|
+
name: session.name,
|
|
317
|
+
createdAt: session.createdAt,
|
|
318
|
+
}));
|
|
319
|
+
logger.info(`Session ${sessionId} reattached`);
|
|
320
|
+
} else {
|
|
321
|
+
// Create new session
|
|
322
|
+
sessionId = uuidv4();
|
|
323
|
+
tm.createSession(sessionId, cwd, cols, rows, shell, sandboxRoot, sessionName, deviceName);
|
|
324
|
+
tm.attachWebSocket(sessionId, ws);
|
|
325
|
+
const session = tm.getSession(sessionId);
|
|
326
|
+
ws.send(makeSessionCreated(sessionId, cols, rows, {
|
|
327
|
+
cwd: session.cwd,
|
|
328
|
+
name: session.name,
|
|
329
|
+
createdAt: session.createdAt,
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
logger.error(`Failed to create/reattach session: ${err.message}`);
|
|
334
|
+
ws.send(makeError(err.message));
|
|
335
|
+
ws.close(1011, 'Session creation failed');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Store session ID on the ws object for cleanup
|
|
340
|
+
ws._sessionId = sessionId;
|
|
341
|
+
|
|
342
|
+
// --- Message routing ---
|
|
343
|
+
ws.on('message', (raw) => {
|
|
344
|
+
let msg;
|
|
345
|
+
try {
|
|
346
|
+
msg = JSON.parse(raw.toString());
|
|
347
|
+
} catch {
|
|
348
|
+
ws.send(makeError('Invalid JSON'));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const validationError = validate(msg);
|
|
353
|
+
if (validationError) {
|
|
354
|
+
ws.send(makeError(validationError));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
switch (msg.type) {
|
|
359
|
+
case MessageType.TERMINAL_INPUT:
|
|
360
|
+
try {
|
|
361
|
+
tm.writeToSession(msg.sessionId, msg.data);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
ws.send(makeError(err.message));
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
|
|
367
|
+
case MessageType.TERMINAL_RESIZE:
|
|
368
|
+
try {
|
|
369
|
+
tm.resizeSession(msg.sessionId, msg.cols, msg.rows);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
ws.send(makeError(err.message));
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
|
|
375
|
+
default:
|
|
376
|
+
ws.send(makeError(`Unhandled message type: ${msg.type}`));
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// --- Disconnect handling ---
|
|
381
|
+
ws.on('close', () => {
|
|
382
|
+
logger.info(`Client disconnected (session ${sessionId})`);
|
|
383
|
+
tm.detachWebSocket(sessionId);
|
|
384
|
+
// Keep pty alive for reconnection (configurable, default 30 min)
|
|
385
|
+
const keepAliveMs = (config.sessionKeepAliveMinutes || 30) * 60 * 1000;
|
|
386
|
+
tm.scheduleDestroy(sessionId, keepAliveMs);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
ws.on('error', (err) => {
|
|
390
|
+
logger.error(`WebSocket error (session ${sessionId}): ${err.message}`);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// --- Start listening ---
|
|
395
|
+
localServer.listen(port, '127.0.0.1', () => {
|
|
396
|
+
logger.info(`Listening on 127.0.0.1:${port}`);
|
|
397
|
+
logger.info(`Config: ${CONFIG_PATH}`);
|
|
398
|
+
if (sandboxRoot) {
|
|
399
|
+
logger.info(`Sandbox root: ${sandboxRoot}`);
|
|
400
|
+
} else {
|
|
401
|
+
logger.info('Sandbox: disabled (no sandboxRoot configured)');
|
|
402
|
+
}
|
|
403
|
+
logger.info(`Local dashboard: http://localhost:${port}`);
|
|
404
|
+
|
|
405
|
+
// Start Tailscale server if IP is available
|
|
406
|
+
const tsStatus = getTailscaleStatus();
|
|
407
|
+
if (tsStatus.ip) {
|
|
408
|
+
tsServer = http.createServer(requestHandler);
|
|
409
|
+
tsServer.on('upgrade', upgradeHandler);
|
|
410
|
+
tsServer.listen(port, tsStatus.ip, () => {
|
|
411
|
+
logger.info(`Listening on ${tsStatus.ip}:${port}`);
|
|
412
|
+
logger.info(`Remote dashboard: http://${tsStatus.ip}:${port}`);
|
|
413
|
+
});
|
|
414
|
+
tsServer.on('error', (err) => {
|
|
415
|
+
logger.warn(`Failed to bind Tailscale server on ${tsStatus.ip}:${port}: ${err.message}`);
|
|
416
|
+
tsServer = null;
|
|
417
|
+
});
|
|
418
|
+
} else {
|
|
419
|
+
logger.warn(`Tailscale: ${tsStatus.error || 'not available'}`);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// --- Graceful shutdown ---
|
|
424
|
+
function shutdown(signal) {
|
|
425
|
+
logger.info(`${signal} received, shutting down...`);
|
|
426
|
+
tm.destroyAll();
|
|
427
|
+
wss.close(() => {
|
|
428
|
+
localServer.close(() => {
|
|
429
|
+
if (tsServer) {
|
|
430
|
+
tsServer.close(() => {
|
|
431
|
+
logger.info('Daemon stopped');
|
|
432
|
+
process.exit(0);
|
|
433
|
+
});
|
|
434
|
+
} else {
|
|
435
|
+
logger.info('Daemon stopped');
|
|
436
|
+
process.exit(0);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
// Force exit after 5 seconds
|
|
441
|
+
setTimeout(() => process.exit(1), 5000);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
445
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const PREFIX = '[claude-remote]';
|
|
2
|
+
|
|
3
|
+
const logger = {
|
|
4
|
+
info: (...args) => console.log(PREFIX, new Date().toISOString(), ...args),
|
|
5
|
+
warn: (...args) => console.warn(PREFIX, new Date().toISOString(), ...args),
|
|
6
|
+
error: (...args) => console.error(PREFIX, new Date().toISOString(), ...args),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
module.exports = logger;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
2
|
+
Add-Type -TypeDefinition @"
|
|
3
|
+
using System;
|
|
4
|
+
using System.Runtime.InteropServices;
|
|
5
|
+
|
|
6
|
+
[ComImport, Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
|
|
7
|
+
internal class FileOpenDialogRCW {}
|
|
8
|
+
|
|
9
|
+
[ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
|
|
10
|
+
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
11
|
+
public interface IShellItem {
|
|
12
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
13
|
+
void GetParent(out IShellItem ppsi);
|
|
14
|
+
void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
|
|
15
|
+
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
|
|
16
|
+
int Compare(IShellItem psi, uint hint);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
[ComImport, Guid("42F85136-DB7E-439C-85F1-E4075D135FC8")]
|
|
20
|
+
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
21
|
+
internal interface IFileOpenDialog {
|
|
22
|
+
[PreserveSig] int Show(IntPtr hwndOwner);
|
|
23
|
+
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
|
|
24
|
+
void SetFileTypeIndex(uint iFileType);
|
|
25
|
+
void GetFileTypeIndex(out uint piFileType);
|
|
26
|
+
void Advise(IntPtr pfde, out uint pdwCookie);
|
|
27
|
+
void Unadvise(uint dwCookie);
|
|
28
|
+
void SetOptions(uint fos);
|
|
29
|
+
void GetOptions(out uint pfos);
|
|
30
|
+
void SetDefaultFolder(IShellItem psi);
|
|
31
|
+
void SetFolder(IShellItem psi);
|
|
32
|
+
IShellItem GetFolder();
|
|
33
|
+
IShellItem GetCurrentSelection();
|
|
34
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
|
35
|
+
[return: MarshalAs(UnmanagedType.LPWStr)] string GetFileName();
|
|
36
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
|
|
37
|
+
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
|
|
38
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
|
|
39
|
+
IShellItem GetResult();
|
|
40
|
+
void AddPlace(IShellItem psi, int fdap);
|
|
41
|
+
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
|
|
42
|
+
void Close(int hr);
|
|
43
|
+
void SetClientGuid(ref Guid guid);
|
|
44
|
+
void ClearClientData();
|
|
45
|
+
void SetFilter(IntPtr pFilter);
|
|
46
|
+
void GetResults(out IntPtr ppenum);
|
|
47
|
+
void GetSelectedItems(out IntPtr ppsai);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public class ModernFolderPicker {
|
|
51
|
+
public static string ShowDialog(string title, IntPtr owner) {
|
|
52
|
+
IFileOpenDialog dlg = (IFileOpenDialog)new FileOpenDialogRCW();
|
|
53
|
+
try {
|
|
54
|
+
uint options;
|
|
55
|
+
dlg.GetOptions(out options);
|
|
56
|
+
dlg.SetOptions(options | 0x20u);
|
|
57
|
+
dlg.SetTitle(title);
|
|
58
|
+
if (dlg.Show(owner) == 0) {
|
|
59
|
+
IShellItem result = dlg.GetResult();
|
|
60
|
+
string path;
|
|
61
|
+
result.GetDisplayName(0x80058000u, out path);
|
|
62
|
+
return path ?? "";
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
finally { Marshal.FinalReleaseComObject(dlg); }
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
"@
|
|
70
|
+
|
|
71
|
+
# Create a hidden topmost form to own the dialog and bring it to front
|
|
72
|
+
$form = New-Object System.Windows.Forms.Form
|
|
73
|
+
$form.TopMost = $true
|
|
74
|
+
$form.ShowInTaskbar = $false
|
|
75
|
+
$form.WindowState = 'Minimized'
|
|
76
|
+
$form.Opacity = 0
|
|
77
|
+
$form.Show()
|
|
78
|
+
|
|
79
|
+
$result = [ModernFolderPicker]::ShowDialog("Select default directory", $form.Handle)
|
|
80
|
+
$form.Close()
|
|
81
|
+
$form.Dispose()
|
|
82
|
+
if ($result) { Write-Output $result }
|