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