claude-remote-cli 0.2.0 → 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 +35 -2
- package/dist/bin/claude-remote-cli.js +98 -0
- package/dist/server/auth.js +41 -0
- package/dist/server/config.js +20 -0
- package/dist/server/index.js +296 -0
- package/dist/server/service.js +169 -0
- package/dist/server/sessions.js +88 -0
- package/dist/server/types.js +1 -0
- package/dist/server/watcher.js +77 -0
- package/dist/server/ws.js +104 -0
- package/package.json +17 -9
- package/bin/claude-remote-cli.js +0 -51
- package/server/auth.js +0 -58
- package/server/config.js +0 -25
- package/server/index.js +0 -308
- package/server/sessions.js +0 -104
- package/server/watcher.js +0 -78
- package/server/ws.js +0 -115
package/server/index.js
DELETED
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const http = require('http');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const readline = require('readline');
|
|
7
|
-
|
|
8
|
-
const express = require('express');
|
|
9
|
-
const cookieParser = require('cookie-parser');
|
|
10
|
-
|
|
11
|
-
const { loadConfig, saveConfig, DEFAULTS } = require('./config');
|
|
12
|
-
const auth = require('./auth');
|
|
13
|
-
const sessions = require('./sessions');
|
|
14
|
-
const { setupWebSocket } = require('./ws');
|
|
15
|
-
const { WorktreeWatcher } = require('./watcher');
|
|
16
|
-
|
|
17
|
-
// When run via CLI bin, config lives in ~/.config/claude-remote-cli/
|
|
18
|
-
// When run directly (development), fall back to local config.json
|
|
19
|
-
const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', 'config.json');
|
|
20
|
-
|
|
21
|
-
function parseTTL(ttl) {
|
|
22
|
-
if (typeof ttl !== 'string') return 24 * 60 * 60 * 1000;
|
|
23
|
-
const match = ttl.match(/^(\d+)([smhd])$/);
|
|
24
|
-
if (!match) return 24 * 60 * 60 * 1000;
|
|
25
|
-
const value = parseInt(match[1], 10);
|
|
26
|
-
switch (match[2]) {
|
|
27
|
-
case 's': return value * 1000;
|
|
28
|
-
case 'm': return value * 60 * 1000;
|
|
29
|
-
case 'h': return value * 60 * 60 * 1000;
|
|
30
|
-
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
31
|
-
default: return 24 * 60 * 60 * 1000;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function promptPin(question) {
|
|
36
|
-
return new Promise((resolve) => {
|
|
37
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
38
|
-
rl.question(question, (answer) => {
|
|
39
|
-
rl.close();
|
|
40
|
-
resolve(answer.trim());
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function scanReposInRoot(rootDir) {
|
|
46
|
-
const repos = [];
|
|
47
|
-
let entries;
|
|
48
|
-
try {
|
|
49
|
-
entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
50
|
-
} catch (_) {
|
|
51
|
-
return repos;
|
|
52
|
-
}
|
|
53
|
-
for (const entry of entries) {
|
|
54
|
-
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
55
|
-
const fullPath = path.join(rootDir, entry.name);
|
|
56
|
-
if (fs.existsSync(path.join(fullPath, '.git'))) {
|
|
57
|
-
repos.push({ name: entry.name, path: fullPath, root: rootDir });
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return repos;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function scanAllRepos(rootDirs) {
|
|
64
|
-
const repos = [];
|
|
65
|
-
for (const rootDir of rootDirs) {
|
|
66
|
-
repos.push(...scanReposInRoot(rootDir));
|
|
67
|
-
}
|
|
68
|
-
return repos;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function main() {
|
|
72
|
-
let config;
|
|
73
|
-
try {
|
|
74
|
-
config = loadConfig(CONFIG_PATH);
|
|
75
|
-
} catch (_) {
|
|
76
|
-
config = { ...DEFAULTS };
|
|
77
|
-
saveConfig(CONFIG_PATH, config);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// CLI flag overrides
|
|
81
|
-
if (process.env.CLAUDE_REMOTE_PORT) config.port = parseInt(process.env.CLAUDE_REMOTE_PORT, 10);
|
|
82
|
-
if (process.env.CLAUDE_REMOTE_HOST) config.host = process.env.CLAUDE_REMOTE_HOST;
|
|
83
|
-
|
|
84
|
-
if (!config.pinHash) {
|
|
85
|
-
const pin = await promptPin('Set up a PIN for claude-remote-cli:');
|
|
86
|
-
config.pinHash = await auth.hashPin(pin);
|
|
87
|
-
saveConfig(CONFIG_PATH, config);
|
|
88
|
-
console.log('PIN set successfully.');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const authenticatedTokens = new Set();
|
|
92
|
-
|
|
93
|
-
const app = express();
|
|
94
|
-
app.use(express.json());
|
|
95
|
-
app.use(cookieParser());
|
|
96
|
-
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
97
|
-
|
|
98
|
-
function requireAuth(req, res, next) {
|
|
99
|
-
const token = req.cookies && req.cookies.token;
|
|
100
|
-
if (!token || !authenticatedTokens.has(token)) {
|
|
101
|
-
return res.status(401).json({ error: 'Unauthorized' });
|
|
102
|
-
}
|
|
103
|
-
next();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const watcher = new WorktreeWatcher();
|
|
107
|
-
watcher.rebuild(config.rootDirs || []);
|
|
108
|
-
|
|
109
|
-
const server = http.createServer(app);
|
|
110
|
-
const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher);
|
|
111
|
-
|
|
112
|
-
// POST /auth
|
|
113
|
-
app.post('/auth', async (req, res) => {
|
|
114
|
-
const ip = req.ip || req.connection.remoteAddress;
|
|
115
|
-
if (auth.isRateLimited(ip)) {
|
|
116
|
-
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const { pin } = req.body;
|
|
120
|
-
if (!pin) {
|
|
121
|
-
return res.status(400).json({ error: 'PIN required' });
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const valid = await auth.verifyPin(pin, config.pinHash);
|
|
125
|
-
if (!valid) {
|
|
126
|
-
auth.recordFailedAttempt(ip);
|
|
127
|
-
return res.status(401).json({ error: 'Invalid PIN' });
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
auth.clearRateLimit(ip);
|
|
131
|
-
const token = auth.generateCookieToken();
|
|
132
|
-
authenticatedTokens.add(token);
|
|
133
|
-
|
|
134
|
-
const ttlMs = parseTTL(config.cookieTTL);
|
|
135
|
-
setTimeout(() => authenticatedTokens.delete(token), ttlMs);
|
|
136
|
-
|
|
137
|
-
res.cookie('token', token, {
|
|
138
|
-
httpOnly: true,
|
|
139
|
-
sameSite: 'strict',
|
|
140
|
-
maxAge: ttlMs,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
return res.json({ ok: true });
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
// GET /sessions
|
|
147
|
-
app.get('/sessions', requireAuth, (req, res) => {
|
|
148
|
-
res.json(sessions.list());
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
// GET /repos — scan root dirs for repos
|
|
152
|
-
app.get('/repos', requireAuth, (req, res) => {
|
|
153
|
-
const repos = scanAllRepos(config.rootDirs || []);
|
|
154
|
-
// Also include legacy manually-added repos
|
|
155
|
-
if (config.repos) {
|
|
156
|
-
for (const repo of config.repos) {
|
|
157
|
-
if (!repos.some((r) => r.path === repo.path)) {
|
|
158
|
-
repos.push(repo);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
res.json(repos);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
|
|
166
|
-
app.get('/worktrees', requireAuth, (req, res) => {
|
|
167
|
-
const repoParam = req.query.repo;
|
|
168
|
-
const roots = config.rootDirs || [];
|
|
169
|
-
const worktrees = [];
|
|
170
|
-
|
|
171
|
-
let reposToScan;
|
|
172
|
-
if (repoParam) {
|
|
173
|
-
const root = roots.find(function (r) { return repoParam.startsWith(r); }) || '';
|
|
174
|
-
reposToScan = [{ path: repoParam, name: repoParam.split('/').filter(Boolean).pop(), root }];
|
|
175
|
-
} else {
|
|
176
|
-
reposToScan = scanAllRepos(roots);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
for (const repo of reposToScan) {
|
|
180
|
-
const worktreeDir = path.join(repo.path, '.claude', 'worktrees');
|
|
181
|
-
let entries;
|
|
182
|
-
try {
|
|
183
|
-
entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
|
|
184
|
-
} catch (_) {
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
for (const entry of entries) {
|
|
188
|
-
if (!entry.isDirectory()) continue;
|
|
189
|
-
worktrees.push({
|
|
190
|
-
name: entry.name,
|
|
191
|
-
path: path.join(worktreeDir, entry.name),
|
|
192
|
-
repoName: repo.name,
|
|
193
|
-
repoPath: repo.path,
|
|
194
|
-
root: repo.root,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
res.json(worktrees);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// GET /roots — list root directories
|
|
203
|
-
app.get('/roots', requireAuth, (req, res) => {
|
|
204
|
-
res.json(config.rootDirs || []);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
// POST /roots — add a root directory
|
|
208
|
-
app.post('/roots', requireAuth, (req, res) => {
|
|
209
|
-
const { path: rootPath } = req.body;
|
|
210
|
-
if (!rootPath) {
|
|
211
|
-
return res.status(400).json({ error: 'path is required' });
|
|
212
|
-
}
|
|
213
|
-
if (!config.rootDirs) config.rootDirs = [];
|
|
214
|
-
if (config.rootDirs.includes(rootPath)) {
|
|
215
|
-
return res.status(409).json({ error: 'Root already exists' });
|
|
216
|
-
}
|
|
217
|
-
config.rootDirs.push(rootPath);
|
|
218
|
-
saveConfig(CONFIG_PATH, config);
|
|
219
|
-
watcher.rebuild(config.rootDirs);
|
|
220
|
-
broadcastEvent('worktrees-changed');
|
|
221
|
-
res.status(201).json(config.rootDirs);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
// DELETE /roots — remove a root directory
|
|
225
|
-
app.delete('/roots', requireAuth, (req, res) => {
|
|
226
|
-
const { path: rootPath } = req.body;
|
|
227
|
-
if (!rootPath || !config.rootDirs) {
|
|
228
|
-
return res.status(400).json({ error: 'path is required' });
|
|
229
|
-
}
|
|
230
|
-
config.rootDirs = config.rootDirs.filter((r) => r !== rootPath);
|
|
231
|
-
saveConfig(CONFIG_PATH, config);
|
|
232
|
-
watcher.rebuild(config.rootDirs);
|
|
233
|
-
broadcastEvent('worktrees-changed');
|
|
234
|
-
res.json(config.rootDirs);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
// POST /sessions
|
|
238
|
-
app.post('/sessions', requireAuth, (req, res) => {
|
|
239
|
-
const { repoPath, repoName, worktreePath, claudeArgs } = req.body;
|
|
240
|
-
if (!repoPath) {
|
|
241
|
-
return res.status(400).json({ error: 'repoPath is required' });
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const name = repoName || repoPath.split('/').filter(Boolean).pop() || 'session';
|
|
245
|
-
const baseArgs = claudeArgs || config.claudeArgs || [];
|
|
246
|
-
|
|
247
|
-
// Compute root by matching repoPath against configured rootDirs
|
|
248
|
-
const roots = config.rootDirs || [];
|
|
249
|
-
const root = roots.find(function (r) { return repoPath.startsWith(r); }) || '';
|
|
250
|
-
|
|
251
|
-
let args, cwd, worktreeName;
|
|
252
|
-
|
|
253
|
-
if (worktreePath) {
|
|
254
|
-
// Resume existing worktree — run claude inside the worktree directory
|
|
255
|
-
args = [...baseArgs];
|
|
256
|
-
cwd = worktreePath;
|
|
257
|
-
worktreeName = worktreePath.split('/').pop();
|
|
258
|
-
} else {
|
|
259
|
-
// New worktree
|
|
260
|
-
worktreeName = 'mobile-' + name + '-' + Date.now().toString(36);
|
|
261
|
-
args = ['--worktree', worktreeName, ...baseArgs];
|
|
262
|
-
cwd = repoPath;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const session = sessions.create({
|
|
266
|
-
repoName: name,
|
|
267
|
-
repoPath: cwd,
|
|
268
|
-
root,
|
|
269
|
-
worktreeName,
|
|
270
|
-
displayName: worktreeName,
|
|
271
|
-
command: config.claudeCommand,
|
|
272
|
-
args,
|
|
273
|
-
});
|
|
274
|
-
return res.status(201).json(session);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// DELETE /sessions/:id
|
|
278
|
-
app.delete('/sessions/:id', requireAuth, (req, res) => {
|
|
279
|
-
try {
|
|
280
|
-
sessions.kill(req.params.id);
|
|
281
|
-
res.json({ ok: true });
|
|
282
|
-
} catch (_) {
|
|
283
|
-
res.status(404).json({ error: 'Session not found' });
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
// PATCH /sessions/:id — update displayName and send /rename through PTY
|
|
288
|
-
app.patch('/sessions/:id', requireAuth, (req, res) => {
|
|
289
|
-
const { displayName } = req.body;
|
|
290
|
-
if (!displayName) return res.status(400).json({ error: 'displayName is required' });
|
|
291
|
-
try {
|
|
292
|
-
const updated = sessions.updateDisplayName(req.params.id, displayName);
|
|
293
|
-
const session = sessions.get(req.params.id);
|
|
294
|
-
if (session && session.pty) {
|
|
295
|
-
session.pty.write('/rename "' + displayName.replace(/"/g, '\\"') + '"\r');
|
|
296
|
-
}
|
|
297
|
-
res.json(updated);
|
|
298
|
-
} catch (_) {
|
|
299
|
-
res.status(404).json({ error: 'Session not found' });
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
server.listen(config.port, config.host, () => {
|
|
304
|
-
console.log(`claude-remote-cli listening on ${config.host}:${config.port}`);
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
main().catch(console.error);
|
package/server/sessions.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const pty = require('node-pty');
|
|
4
|
-
const crypto = require('crypto');
|
|
5
|
-
|
|
6
|
-
// In-memory registry: id -> {id, root, repoName, repoPath, worktreeName, displayName, pty, createdAt}
|
|
7
|
-
const sessions = new Map();
|
|
8
|
-
|
|
9
|
-
function create({ repoName, repoPath, root, worktreeName, displayName, command, args = [], cols = 80, rows = 24 }) {
|
|
10
|
-
const id = crypto.randomBytes(8).toString('hex');
|
|
11
|
-
const createdAt = new Date().toISOString();
|
|
12
|
-
|
|
13
|
-
// Strip CLAUDECODE env var to allow spawning claude inside a claude-managed server
|
|
14
|
-
const env = Object.assign({}, process.env);
|
|
15
|
-
delete env.CLAUDECODE;
|
|
16
|
-
|
|
17
|
-
const ptyProcess = pty.spawn(command, args, {
|
|
18
|
-
name: 'xterm-256color',
|
|
19
|
-
cols,
|
|
20
|
-
rows,
|
|
21
|
-
cwd: repoPath,
|
|
22
|
-
env,
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
// Scrollback buffer: stores all PTY output so we can replay on WebSocket (re)connect
|
|
26
|
-
const scrollback = [];
|
|
27
|
-
let scrollbackBytes = 0;
|
|
28
|
-
const MAX_SCROLLBACK = 256 * 1024; // 256KB max
|
|
29
|
-
|
|
30
|
-
const session = {
|
|
31
|
-
id,
|
|
32
|
-
root: root || '',
|
|
33
|
-
repoName: repoName || '',
|
|
34
|
-
repoPath,
|
|
35
|
-
worktreeName: worktreeName || '',
|
|
36
|
-
displayName: displayName || worktreeName || repoName || '',
|
|
37
|
-
pty: ptyProcess,
|
|
38
|
-
createdAt,
|
|
39
|
-
lastActivity: createdAt,
|
|
40
|
-
scrollback,
|
|
41
|
-
};
|
|
42
|
-
sessions.set(id, session);
|
|
43
|
-
|
|
44
|
-
ptyProcess.onData((data) => {
|
|
45
|
-
session.lastActivity = new Date().toISOString();
|
|
46
|
-
scrollback.push(data);
|
|
47
|
-
scrollbackBytes += data.length;
|
|
48
|
-
// Trim oldest entries if over limit
|
|
49
|
-
while (scrollbackBytes > MAX_SCROLLBACK && scrollback.length > 1) {
|
|
50
|
-
scrollbackBytes -= scrollback.shift().length;
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
ptyProcess.onExit(() => {
|
|
55
|
-
sessions.delete(id);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
return { id, root: session.root, repoName: session.repoName, repoPath, worktreeName: session.worktreeName, displayName: session.displayName, pid: ptyProcess.pid, createdAt };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function get(id) {
|
|
62
|
-
return sessions.get(id);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function list() {
|
|
66
|
-
return Array.from(sessions.values())
|
|
67
|
-
.map(({ id, root, repoName, repoPath, worktreeName, displayName, createdAt, lastActivity }) => ({
|
|
68
|
-
id,
|
|
69
|
-
root,
|
|
70
|
-
repoName,
|
|
71
|
-
repoPath,
|
|
72
|
-
worktreeName,
|
|
73
|
-
displayName,
|
|
74
|
-
createdAt,
|
|
75
|
-
lastActivity,
|
|
76
|
-
}))
|
|
77
|
-
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function updateDisplayName(id, displayName) {
|
|
81
|
-
const session = sessions.get(id);
|
|
82
|
-
if (!session) throw new Error('Session not found: ' + id);
|
|
83
|
-
session.displayName = displayName;
|
|
84
|
-
return { id, displayName };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function kill(id) {
|
|
88
|
-
const session = sessions.get(id);
|
|
89
|
-
if (!session) {
|
|
90
|
-
throw new Error(`Session not found: ${id}`);
|
|
91
|
-
}
|
|
92
|
-
session.pty.kill('SIGTERM');
|
|
93
|
-
sessions.delete(id);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function resize(id, cols, rows) {
|
|
97
|
-
const session = sessions.get(id);
|
|
98
|
-
if (!session) {
|
|
99
|
-
throw new Error(`Session not found: ${id}`);
|
|
100
|
-
}
|
|
101
|
-
session.pty.resize(cols, rows);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
module.exports = { create, get, list, kill, resize, updateDisplayName };
|
package/server/watcher.js
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const EventEmitter = require('events');
|
|
6
|
-
|
|
7
|
-
class WorktreeWatcher extends EventEmitter {
|
|
8
|
-
constructor() {
|
|
9
|
-
super();
|
|
10
|
-
this._watchers = [];
|
|
11
|
-
this._debounceTimer = null;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
rebuild(rootDirs) {
|
|
15
|
-
this._closeAll();
|
|
16
|
-
|
|
17
|
-
for (const rootDir of rootDirs) {
|
|
18
|
-
let entries;
|
|
19
|
-
try {
|
|
20
|
-
entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
21
|
-
} catch (_) {
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
for (const entry of entries) {
|
|
25
|
-
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
26
|
-
const repoPath = path.join(rootDir, entry.name);
|
|
27
|
-
if (!fs.existsSync(path.join(repoPath, '.git'))) continue;
|
|
28
|
-
this._watchRepo(repoPath);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
_watchRepo(repoPath) {
|
|
34
|
-
const worktreeDir = path.join(repoPath, '.claude', 'worktrees');
|
|
35
|
-
if (fs.existsSync(worktreeDir)) {
|
|
36
|
-
this._addWatch(worktreeDir);
|
|
37
|
-
} else {
|
|
38
|
-
const claudeDir = path.join(repoPath, '.claude');
|
|
39
|
-
if (fs.existsSync(claudeDir)) {
|
|
40
|
-
this._addWatch(claudeDir);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
_addWatch(dirPath) {
|
|
46
|
-
try {
|
|
47
|
-
const watcher = fs.watch(dirPath, { persistent: false }, () => {
|
|
48
|
-
this._debouncedEmit();
|
|
49
|
-
});
|
|
50
|
-
watcher.on('error', () => {});
|
|
51
|
-
this._watchers.push(watcher);
|
|
52
|
-
} catch (_) {}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
_debouncedEmit() {
|
|
56
|
-
if (this._debounceTimer) clearTimeout(this._debounceTimer);
|
|
57
|
-
this._debounceTimer = setTimeout(() => {
|
|
58
|
-
this.emit('worktrees-changed');
|
|
59
|
-
}, 500);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
_closeAll() {
|
|
63
|
-
for (const w of this._watchers) {
|
|
64
|
-
try { w.close(); } catch (_) {}
|
|
65
|
-
}
|
|
66
|
-
this._watchers = [];
|
|
67
|
-
if (this._debounceTimer) {
|
|
68
|
-
clearTimeout(this._debounceTimer);
|
|
69
|
-
this._debounceTimer = null;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
close() {
|
|
74
|
-
this._closeAll();
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
module.exports = { WorktreeWatcher };
|
package/server/ws.js
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { WebSocketServer } = require('ws');
|
|
4
|
-
const sessions = require('./sessions');
|
|
5
|
-
|
|
6
|
-
function parseCookies(cookieHeader) {
|
|
7
|
-
const cookies = {};
|
|
8
|
-
if (!cookieHeader) return cookies;
|
|
9
|
-
cookieHeader.split(';').forEach((part) => {
|
|
10
|
-
const idx = part.indexOf('=');
|
|
11
|
-
if (idx < 0) return;
|
|
12
|
-
const key = part.slice(0, idx).trim();
|
|
13
|
-
const val = part.slice(idx + 1).trim();
|
|
14
|
-
cookies[key] = decodeURIComponent(val);
|
|
15
|
-
});
|
|
16
|
-
return cookies;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
20
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
21
|
-
const eventClients = new Set();
|
|
22
|
-
|
|
23
|
-
function broadcastEvent(type) {
|
|
24
|
-
const msg = JSON.stringify({ type });
|
|
25
|
-
for (const client of eventClients) {
|
|
26
|
-
if (client.readyState === client.OPEN) {
|
|
27
|
-
client.send(msg);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (watcher) {
|
|
33
|
-
watcher.on('worktrees-changed', function () {
|
|
34
|
-
broadcastEvent('worktrees-changed');
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
server.on('upgrade', (request, socket, head) => {
|
|
39
|
-
const cookies = parseCookies(request.headers.cookie);
|
|
40
|
-
if (!authenticatedTokens.has(cookies.token)) {
|
|
41
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
42
|
-
socket.destroy();
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Event channel: /ws/events
|
|
47
|
-
if (request.url === '/ws/events') {
|
|
48
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
49
|
-
eventClients.add(ws);
|
|
50
|
-
ws.on('close', () => { eventClients.delete(ws); });
|
|
51
|
-
});
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// PTY channel: /ws/:sessionId
|
|
56
|
-
const match = request.url && request.url.match(/^\/ws\/([a-f0-9]+)$/);
|
|
57
|
-
if (!match) {
|
|
58
|
-
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
59
|
-
socket.destroy();
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const sessionId = match[1];
|
|
64
|
-
const session = sessions.get(sessionId);
|
|
65
|
-
if (!session) {
|
|
66
|
-
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
67
|
-
socket.destroy();
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
72
|
-
wss.emit('connection', ws, request, session);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
wss.on('connection', (ws, request, session) => {
|
|
77
|
-
const ptyProcess = session.pty;
|
|
78
|
-
|
|
79
|
-
for (const chunk of session.scrollback) {
|
|
80
|
-
ws.send(chunk);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const dataHandler = ptyProcess.onData((data) => {
|
|
84
|
-
if (ws.readyState === ws.OPEN) {
|
|
85
|
-
ws.send(data);
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
ws.on('message', (msg) => {
|
|
90
|
-
const str = msg.toString();
|
|
91
|
-
try {
|
|
92
|
-
const parsed = JSON.parse(str);
|
|
93
|
-
if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
|
|
94
|
-
sessions.resize(session.id, parsed.cols, parsed.rows);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
} catch (_) {}
|
|
98
|
-
ptyProcess.write(str);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
ws.on('close', () => {
|
|
102
|
-
dataHandler.dispose();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
ptyProcess.onExit(() => {
|
|
106
|
-
if (ws.readyState === ws.OPEN) {
|
|
107
|
-
ws.close(1000);
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
return { wss, broadcastEvent };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
module.exports = { setupWebSocket };
|