claude-remote-cli 3.0.6 → 3.1.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/dist/bin/claude-remote-cli.js +3 -0
- package/dist/frontend/assets/index-BEffbpai.js +47 -0
- package/dist/frontend/assets/index-w5wJhB5f.css +32 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server/index.js +75 -6
- package/dist/server/pty-handler.js +216 -0
- package/dist/server/push.js +54 -3
- package/dist/server/sdk-handler.js +539 -0
- package/dist/server/sessions.js +184 -262
- package/dist/server/types.js +13 -0
- package/dist/server/workspaces.js +151 -0
- package/dist/server/ws.js +151 -20
- package/dist/test/branch-rename.test.js +28 -0
- package/dist/test/fs-browse.test.js +202 -0
- package/dist/test/sessions.test.js +23 -7
- package/package.json +2 -1
- package/dist/frontend/assets/index-BBvs0auR.js +0 -47
- package/dist/frontend/assets/index-CVH0jxa8.css +0 -32
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { execFile } from 'node:child_process';
|
|
4
5
|
import { promisify } from 'node:util';
|
|
@@ -7,6 +8,12 @@ import { loadConfig, saveConfig, getWorkspaceSettings, setWorkspaceSettings } fr
|
|
|
7
8
|
import { listBranches, getActivityFeed, getCiStatus, getPrForBranch, switchBranch, getCurrentBranch } from './git.js';
|
|
8
9
|
import { MOUNTAIN_NAMES } from './types.js';
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
11
|
+
const BROWSE_DENYLIST = new Set([
|
|
12
|
+
'node_modules', '.git', '.Trash', '__pycache__',
|
|
13
|
+
'.cache', '.npm', '.yarn', '.nvm',
|
|
14
|
+
]);
|
|
15
|
+
const BROWSE_MAX_ENTRIES = 100;
|
|
16
|
+
const BULK_MAX_PATHS = 50;
|
|
10
17
|
// ---------------------------------------------------------------------------
|
|
11
18
|
// Exported helpers
|
|
12
19
|
// ---------------------------------------------------------------------------
|
|
@@ -159,6 +166,60 @@ export function createWorkspaceRouter(deps) {
|
|
|
159
166
|
res.json({ removed: resolved });
|
|
160
167
|
});
|
|
161
168
|
// -------------------------------------------------------------------------
|
|
169
|
+
// POST /workspaces/bulk — add multiple workspaces at once
|
|
170
|
+
// -------------------------------------------------------------------------
|
|
171
|
+
router.post('/bulk', async (req, res) => {
|
|
172
|
+
const body = req.body;
|
|
173
|
+
const rawPaths = body.paths;
|
|
174
|
+
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
|
|
175
|
+
res.status(400).json({ error: 'paths array is required' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (rawPaths.length > BULK_MAX_PATHS) {
|
|
179
|
+
res.status(400).json({ error: `Too many paths (max ${BULK_MAX_PATHS})` });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const config = getConfig();
|
|
183
|
+
const existing = new Set(config.workspaces ?? []);
|
|
184
|
+
const added = [];
|
|
185
|
+
const errors = [];
|
|
186
|
+
for (const rawPath of rawPaths) {
|
|
187
|
+
if (typeof rawPath !== 'string' || !rawPath) {
|
|
188
|
+
errors.push({ path: String(rawPath), error: 'Invalid path' });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
let resolved;
|
|
192
|
+
try {
|
|
193
|
+
resolved = await validateWorkspacePath(rawPath);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
errors.push({ path: rawPath, error: err instanceof Error ? err.message : String(err) });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (existing.has(resolved)) {
|
|
200
|
+
errors.push({ path: rawPath, error: 'Already exists' });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const { isGitRepo, defaultBranch } = await detectGitRepo(resolved, exec);
|
|
204
|
+
existing.add(resolved);
|
|
205
|
+
added.push({ path: resolved, name: path.basename(resolved), isGitRepo, defaultBranch });
|
|
206
|
+
// Store detected default branch in per-workspace settings
|
|
207
|
+
if (isGitRepo && defaultBranch) {
|
|
208
|
+
if (!config.workspaceSettings)
|
|
209
|
+
config.workspaceSettings = {};
|
|
210
|
+
config.workspaceSettings[resolved] = {
|
|
211
|
+
...config.workspaceSettings[resolved],
|
|
212
|
+
defaultBranch,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (added.length > 0) {
|
|
217
|
+
config.workspaces = [...(config.workspaces ?? []), ...added.map((a) => a.path)];
|
|
218
|
+
saveConfig(configPath, config);
|
|
219
|
+
}
|
|
220
|
+
res.status(201).json({ added, errors });
|
|
221
|
+
});
|
|
222
|
+
// -------------------------------------------------------------------------
|
|
162
223
|
// GET /workspaces/dashboard — aggregated PR + activity data for a workspace
|
|
163
224
|
// -------------------------------------------------------------------------
|
|
164
225
|
router.get('/dashboard', async (req, res) => {
|
|
@@ -414,6 +475,96 @@ export function createWorkspaceRouter(deps) {
|
|
|
414
475
|
res.json({ branch });
|
|
415
476
|
});
|
|
416
477
|
// -------------------------------------------------------------------------
|
|
478
|
+
// GET /workspaces/browse — browse filesystem directories for tree UI
|
|
479
|
+
// -------------------------------------------------------------------------
|
|
480
|
+
router.get('/browse', async (req, res) => {
|
|
481
|
+
const rawPath = typeof req.query.path === 'string' ? req.query.path : '~';
|
|
482
|
+
const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : '';
|
|
483
|
+
const showHidden = req.query.showHidden === 'true';
|
|
484
|
+
// Resolve ~ to home directory
|
|
485
|
+
const expanded = rawPath === '~' || rawPath.startsWith('~/')
|
|
486
|
+
? path.join(os.homedir(), rawPath.slice(1))
|
|
487
|
+
: rawPath;
|
|
488
|
+
const resolved = path.resolve(expanded);
|
|
489
|
+
// Validate path
|
|
490
|
+
let stat;
|
|
491
|
+
try {
|
|
492
|
+
stat = await fs.promises.stat(resolved);
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
const code = err.code;
|
|
496
|
+
if (code === 'EACCES') {
|
|
497
|
+
res.status(403).json({ error: 'Permission denied' });
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
res.status(400).json({ error: `Path does not exist: ${resolved}` });
|
|
501
|
+
}
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!stat.isDirectory()) {
|
|
505
|
+
res.status(400).json({ error: `Not a directory: ${resolved}` });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
// Read directory entries
|
|
509
|
+
let dirents;
|
|
510
|
+
try {
|
|
511
|
+
dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
res.status(403).json({ error: 'Cannot read directory' });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// Filter to directories only, apply denylist, hidden filter, prefix filter
|
|
518
|
+
let dirs = dirents.filter((d) => {
|
|
519
|
+
if (!d.isDirectory())
|
|
520
|
+
return false;
|
|
521
|
+
if (BROWSE_DENYLIST.has(d.name))
|
|
522
|
+
return false;
|
|
523
|
+
// Also check if name contains a path separator component in denylist
|
|
524
|
+
// e.g. "Library/Caches" — we check the full name, not path components
|
|
525
|
+
if (!showHidden && d.name.startsWith('.'))
|
|
526
|
+
return false;
|
|
527
|
+
if (prefix && !d.name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
528
|
+
return false;
|
|
529
|
+
return true;
|
|
530
|
+
});
|
|
531
|
+
// Sort alphabetically case-insensitive
|
|
532
|
+
dirs.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
533
|
+
const total = dirs.length;
|
|
534
|
+
const truncated = dirs.length > BROWSE_MAX_ENTRIES;
|
|
535
|
+
if (truncated)
|
|
536
|
+
dirs = dirs.slice(0, BROWSE_MAX_ENTRIES);
|
|
537
|
+
// Enrich each entry with isGitRepo and hasChildren (parallelized)
|
|
538
|
+
const entries = await Promise.all(dirs.map(async (d) => {
|
|
539
|
+
const entryPath = path.join(resolved, d.name);
|
|
540
|
+
// Check for .git directory (isGitRepo)
|
|
541
|
+
let isGitRepo = false;
|
|
542
|
+
try {
|
|
543
|
+
const gitStat = await fs.promises.stat(path.join(entryPath, '.git'));
|
|
544
|
+
isGitRepo = gitStat.isDirectory();
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
// not a git repo
|
|
548
|
+
}
|
|
549
|
+
// Check if has at least one subdirectory child (hasChildren)
|
|
550
|
+
let hasChildren = false;
|
|
551
|
+
try {
|
|
552
|
+
const children = await fs.promises.readdir(entryPath, { withFileTypes: true });
|
|
553
|
+
hasChildren = children.some((c) => c.isDirectory() && !BROWSE_DENYLIST.has(c.name));
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// can't read — treat as no children
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
name: d.name,
|
|
560
|
+
path: entryPath,
|
|
561
|
+
isGitRepo,
|
|
562
|
+
hasChildren,
|
|
563
|
+
};
|
|
564
|
+
}));
|
|
565
|
+
res.json({ resolved, entries, truncated, total });
|
|
566
|
+
});
|
|
567
|
+
// -------------------------------------------------------------------------
|
|
417
568
|
// GET /workspaces/autocomplete — path prefix autocomplete
|
|
418
569
|
// -------------------------------------------------------------------------
|
|
419
570
|
router.get('/autocomplete', async (req, res) => {
|
package/dist/server/ws.js
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
2
4
|
import * as sessions from './sessions.js';
|
|
5
|
+
import { onSdkEvent, sendMessage as sdkSendMessage, handlePermission as sdkHandlePermission } from './sdk-handler.js';
|
|
6
|
+
import { writeMeta } from './config.js';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const BACKPRESSURE_HIGH = 1024 * 1024; // 1MB
|
|
9
|
+
const BACKPRESSURE_LOW = 512 * 1024; // 512KB
|
|
10
|
+
const BRANCH_POLL_INTERVAL_MS = 3000;
|
|
11
|
+
const BRANCH_POLL_MAX_ATTEMPTS = 10;
|
|
12
|
+
const RENAME_CORE = `rename the current git branch using \`git branch -m <new-name>\` to a short, descriptive kebab-case name based on the task I'm asking about. Do not include any ticket numbers or prefixes.`;
|
|
13
|
+
const SDK_BRANCH_RENAME_INSTRUCTION = `Before responding to my message, first ${RENAME_CORE} After renaming, proceed with my request normally.\n\n`;
|
|
14
|
+
function startBranchWatcher(session, broadcastEvent, cfgPath) {
|
|
15
|
+
const originalBranch = session.branchName;
|
|
16
|
+
let attempts = 0;
|
|
17
|
+
const timer = setInterval(async () => {
|
|
18
|
+
attempts++;
|
|
19
|
+
if (attempts > BRANCH_POLL_MAX_ATTEMPTS) {
|
|
20
|
+
clearInterval(timer);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: session.cwd });
|
|
25
|
+
const currentBranch = stdout.trim();
|
|
26
|
+
if (currentBranch && currentBranch !== originalBranch) {
|
|
27
|
+
clearInterval(timer);
|
|
28
|
+
session.branchName = currentBranch;
|
|
29
|
+
session.displayName = currentBranch;
|
|
30
|
+
broadcastEvent('session-renamed', { sessionId: session.id, branchName: currentBranch, displayName: currentBranch });
|
|
31
|
+
writeMeta(cfgPath, {
|
|
32
|
+
worktreePath: session.repoPath,
|
|
33
|
+
displayName: currentBranch,
|
|
34
|
+
lastActivity: new Date().toISOString(),
|
|
35
|
+
branchName: currentBranch,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// git command failed — session cwd may not exist yet, retry
|
|
41
|
+
}
|
|
42
|
+
}, BRANCH_POLL_INTERVAL_MS);
|
|
43
|
+
}
|
|
3
44
|
function parseCookies(cookieHeader) {
|
|
4
45
|
const cookies = {};
|
|
5
46
|
if (!cookieHeader)
|
|
@@ -14,7 +55,7 @@ function parseCookies(cookieHeader) {
|
|
|
14
55
|
});
|
|
15
56
|
return cookies;
|
|
16
57
|
}
|
|
17
|
-
function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
58
|
+
function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
|
|
18
59
|
const wss = new WebSocketServer({ noServer: true });
|
|
19
60
|
const eventClients = new Set();
|
|
20
61
|
function broadcastEvent(type, data) {
|
|
@@ -47,7 +88,7 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
47
88
|
});
|
|
48
89
|
return;
|
|
49
90
|
}
|
|
50
|
-
// PTY channel: /ws/:sessionId
|
|
91
|
+
// PTY/SDK channel: /ws/:sessionId
|
|
51
92
|
const match = request.url && request.url.match(/^\/ws\/([a-f0-9]+)$/);
|
|
52
93
|
if (!match) {
|
|
53
94
|
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
@@ -71,6 +112,16 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
71
112
|
const session = sessionMap.get(ws);
|
|
72
113
|
if (!session)
|
|
73
114
|
return;
|
|
115
|
+
if (session.mode === 'sdk') {
|
|
116
|
+
handleSdkConnection(ws, session);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// PTY mode — existing behavior
|
|
120
|
+
if (session.mode !== 'pty') {
|
|
121
|
+
ws.close(1008, 'Session mode does not support PTY streaming');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const ptySession = session;
|
|
74
125
|
let dataDisposable = null;
|
|
75
126
|
let exitDisposable = null;
|
|
76
127
|
function attachToPty(ptyProcess) {
|
|
@@ -78,7 +129,7 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
78
129
|
dataDisposable?.dispose();
|
|
79
130
|
exitDisposable?.dispose();
|
|
80
131
|
// Replay scrollback
|
|
81
|
-
for (const chunk of
|
|
132
|
+
for (const chunk of ptySession.scrollback) {
|
|
82
133
|
if (ws.readyState === ws.OPEN)
|
|
83
134
|
ws.send(chunk);
|
|
84
135
|
}
|
|
@@ -91,52 +142,132 @@ function setupWebSocket(server, authenticatedTokens, watcher) {
|
|
|
91
142
|
ws.close(1000);
|
|
92
143
|
});
|
|
93
144
|
}
|
|
94
|
-
attachToPty(
|
|
145
|
+
attachToPty(ptySession.pty);
|
|
95
146
|
const ptyReplacedHandler = (newPty) => attachToPty(newPty);
|
|
96
|
-
|
|
147
|
+
ptySession.onPtyReplacedCallbacks.push(ptyReplacedHandler);
|
|
97
148
|
ws.on('message', (msg) => {
|
|
98
149
|
const str = msg.toString();
|
|
99
150
|
try {
|
|
100
151
|
const parsed = JSON.parse(str);
|
|
101
152
|
if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
|
|
102
|
-
sessions.resize(
|
|
153
|
+
sessions.resize(ptySession.id, parsed.cols, parsed.rows);
|
|
103
154
|
return;
|
|
104
155
|
}
|
|
105
156
|
}
|
|
106
157
|
catch (_) { }
|
|
107
158
|
// Branch rename interception: prepend rename prompt before the user's first message
|
|
108
|
-
if (
|
|
109
|
-
if (!
|
|
110
|
-
|
|
159
|
+
if (ptySession.needsBranchRename) {
|
|
160
|
+
if (!ptySession._renameBuffer)
|
|
161
|
+
ptySession._renameBuffer = '';
|
|
111
162
|
const enterIndex = str.indexOf('\r');
|
|
112
163
|
if (enterIndex === -1) {
|
|
113
164
|
// No Enter yet — buffer and pass through so the user sees echo
|
|
114
|
-
|
|
115
|
-
|
|
165
|
+
ptySession._renameBuffer += str;
|
|
166
|
+
ptySession.pty.write(str);
|
|
116
167
|
return;
|
|
117
168
|
}
|
|
118
169
|
// Enter detected — inject rename prompt before the user's message
|
|
119
|
-
const buffered =
|
|
170
|
+
const buffered = ptySession._renameBuffer;
|
|
120
171
|
const beforeEnter = buffered + str.slice(0, enterIndex);
|
|
121
172
|
const afterEnter = str.slice(enterIndex); // includes the \r
|
|
122
|
-
const renamePrompt = `Before doing anything else, rename the current git branch using \`git branch -m <new-name>\`. Choose a short, descriptive kebab-case branch name based on the task below.${
|
|
173
|
+
const renamePrompt = `Before doing anything else, rename the current git branch using \`git branch -m <new-name>\`. Choose a short, descriptive kebab-case branch name based on the task below.${ptySession.branchRenamePrompt ? ' User preferences: ' + ptySession.branchRenamePrompt : ''} Do not ask for confirmation — just rename and proceed.\n\n`;
|
|
123
174
|
const clearLine = '\x15'; // Ctrl+U clears the current input line
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
delete
|
|
175
|
+
ptySession.pty.write(clearLine + renamePrompt + beforeEnter + afterEnter);
|
|
176
|
+
ptySession.needsBranchRename = false;
|
|
177
|
+
delete ptySession._renameBuffer;
|
|
178
|
+
if (configPath)
|
|
179
|
+
startBranchWatcher(ptySession, broadcastEvent, configPath);
|
|
127
180
|
return;
|
|
128
181
|
}
|
|
129
|
-
// Use
|
|
130
|
-
|
|
182
|
+
// Use ptySession.pty dynamically so writes go to current PTY
|
|
183
|
+
ptySession.pty.write(str);
|
|
131
184
|
});
|
|
132
185
|
ws.on('close', () => {
|
|
133
186
|
dataDisposable?.dispose();
|
|
134
187
|
exitDisposable?.dispose();
|
|
135
|
-
const idx =
|
|
188
|
+
const idx = ptySession.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
|
|
136
189
|
if (idx !== -1)
|
|
137
|
-
|
|
190
|
+
ptySession.onPtyReplacedCallbacks.splice(idx, 1);
|
|
138
191
|
});
|
|
139
192
|
});
|
|
193
|
+
function handleSdkConnection(ws, session) {
|
|
194
|
+
// Send session info
|
|
195
|
+
const sessionInfo = JSON.stringify({
|
|
196
|
+
type: 'session_info',
|
|
197
|
+
mode: 'sdk',
|
|
198
|
+
sessionId: session.id,
|
|
199
|
+
});
|
|
200
|
+
if (ws.readyState === ws.OPEN)
|
|
201
|
+
ws.send(sessionInfo);
|
|
202
|
+
// Replay stored events (send as-is — client expects raw SdkEvent shape)
|
|
203
|
+
for (const event of session.events) {
|
|
204
|
+
if (ws.readyState !== ws.OPEN)
|
|
205
|
+
break;
|
|
206
|
+
ws.send(JSON.stringify(event));
|
|
207
|
+
}
|
|
208
|
+
// Subscribe to live events with backpressure
|
|
209
|
+
let paused = false;
|
|
210
|
+
const unsubscribe = onSdkEvent(session.id, (event) => {
|
|
211
|
+
if (ws.readyState !== ws.OPEN)
|
|
212
|
+
return;
|
|
213
|
+
// Backpressure check
|
|
214
|
+
if (ws.bufferedAmount > BACKPRESSURE_HIGH) {
|
|
215
|
+
paused = true;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
ws.send(JSON.stringify(event));
|
|
219
|
+
});
|
|
220
|
+
// Periodically check if we can resume
|
|
221
|
+
const backpressureInterval = setInterval(() => {
|
|
222
|
+
if (paused && ws.bufferedAmount < BACKPRESSURE_LOW) {
|
|
223
|
+
paused = false;
|
|
224
|
+
}
|
|
225
|
+
}, 100);
|
|
226
|
+
// Handle incoming messages
|
|
227
|
+
ws.on('message', (msg) => {
|
|
228
|
+
const str = msg.toString();
|
|
229
|
+
try {
|
|
230
|
+
const parsed = JSON.parse(str);
|
|
231
|
+
if (parsed.type === 'message' && typeof parsed.text === 'string') {
|
|
232
|
+
if (parsed.text.length > 100_000)
|
|
233
|
+
return;
|
|
234
|
+
if (session.needsBranchRename) {
|
|
235
|
+
session.needsBranchRename = false;
|
|
236
|
+
sdkSendMessage(session.id, SDK_BRANCH_RENAME_INSTRUCTION + parsed.text);
|
|
237
|
+
if (configPath)
|
|
238
|
+
startBranchWatcher(session, broadcastEvent, configPath);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
sdkSendMessage(session.id, parsed.text);
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (parsed.type === 'permission' && typeof parsed.requestId === 'string' && typeof parsed.approved === 'boolean') {
|
|
246
|
+
sdkHandlePermission(session.id, parsed.requestId, parsed.approved);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (parsed.type === 'resize' && typeof parsed.cols === 'number' && typeof parsed.rows === 'number') {
|
|
250
|
+
// TODO: wire up companion shell — currently open_companion message is unhandled server-side
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (parsed.type === 'open_companion') {
|
|
254
|
+
// TODO: spawn companion PTY in session CWD and relay via terminal_data/terminal_exit frames
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (_) {
|
|
259
|
+
// Not JSON — ignore for SDK sessions
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
ws.on('close', () => {
|
|
263
|
+
unsubscribe();
|
|
264
|
+
clearInterval(backpressureInterval);
|
|
265
|
+
});
|
|
266
|
+
ws.on('error', () => {
|
|
267
|
+
unsubscribe();
|
|
268
|
+
clearInterval(backpressureInterval);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
140
271
|
sessions.onIdleChange((sessionId, idle) => {
|
|
141
272
|
broadcastEvent('session-idle-changed', { sessionId, idle });
|
|
142
273
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { MOUNTAIN_NAMES } from '../server/types.js';
|
|
4
|
+
describe('MOUNTAIN_NAMES', () => {
|
|
5
|
+
test('contains 30 mountain names', () => {
|
|
6
|
+
assert.equal(MOUNTAIN_NAMES.length, 30);
|
|
7
|
+
});
|
|
8
|
+
test('all names are lowercase kebab-case', () => {
|
|
9
|
+
for (const name of MOUNTAIN_NAMES) {
|
|
10
|
+
assert.match(name, /^[a-z][a-z0-9-]*$/, `Mountain name "${name}" is not kebab-case`);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
test('no duplicate names', () => {
|
|
14
|
+
const unique = new Set(MOUNTAIN_NAMES);
|
|
15
|
+
assert.equal(unique.size, MOUNTAIN_NAMES.length);
|
|
16
|
+
});
|
|
17
|
+
test('cycling wraps around at array length', () => {
|
|
18
|
+
let idx = 28;
|
|
19
|
+
const name1 = MOUNTAIN_NAMES[idx % MOUNTAIN_NAMES.length];
|
|
20
|
+
idx++;
|
|
21
|
+
const name2 = MOUNTAIN_NAMES[idx % MOUNTAIN_NAMES.length];
|
|
22
|
+
idx++;
|
|
23
|
+
const name3 = MOUNTAIN_NAMES[idx % MOUNTAIN_NAMES.length];
|
|
24
|
+
assert.equal(name1, 'whitney');
|
|
25
|
+
assert.equal(name2, 'hood');
|
|
26
|
+
assert.equal(name3, 'everest'); // wraps back to start
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import express from 'express';
|
|
7
|
+
import { createWorkspaceRouter } from '../server/workspaces.js';
|
|
8
|
+
import { saveConfig, DEFAULTS } from '../server/config.js';
|
|
9
|
+
let tmpDir;
|
|
10
|
+
let configPath;
|
|
11
|
+
let server;
|
|
12
|
+
let baseUrl;
|
|
13
|
+
before(async () => {
|
|
14
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-browse-test-'));
|
|
15
|
+
configPath = path.join(tmpDir, 'config.json');
|
|
16
|
+
// Create a directory tree for testing
|
|
17
|
+
// tmpDir/
|
|
18
|
+
// browsable/
|
|
19
|
+
// visible-dir/
|
|
20
|
+
// nested/
|
|
21
|
+
// .hidden-dir/
|
|
22
|
+
// git-repo/
|
|
23
|
+
// .git/
|
|
24
|
+
// empty-dir/
|
|
25
|
+
// node_modules/
|
|
26
|
+
// file.txt
|
|
27
|
+
const browsable = path.join(tmpDir, 'browsable');
|
|
28
|
+
fs.mkdirSync(path.join(browsable, 'visible-dir', 'nested'), { recursive: true });
|
|
29
|
+
fs.mkdirSync(path.join(browsable, '.hidden-dir'), { recursive: true });
|
|
30
|
+
fs.mkdirSync(path.join(browsable, 'git-repo', '.git'), { recursive: true });
|
|
31
|
+
fs.mkdirSync(path.join(browsable, 'empty-dir'), { recursive: true });
|
|
32
|
+
fs.mkdirSync(path.join(browsable, 'node_modules'), { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(browsable, 'file.txt'), 'not a directory');
|
|
34
|
+
// Create 110 dirs to test truncation
|
|
35
|
+
const manyDir = path.join(tmpDir, 'many');
|
|
36
|
+
fs.mkdirSync(manyDir);
|
|
37
|
+
for (let i = 0; i < 110; i++) {
|
|
38
|
+
fs.mkdirSync(path.join(manyDir, `dir-${String(i).padStart(3, '0')}`));
|
|
39
|
+
}
|
|
40
|
+
// Save a config so the router can load it
|
|
41
|
+
saveConfig(configPath, { ...DEFAULTS, workspaces: [] });
|
|
42
|
+
// Start a test server
|
|
43
|
+
const app = express();
|
|
44
|
+
app.use(express.json());
|
|
45
|
+
app.use('/workspaces', createWorkspaceRouter({ configPath }));
|
|
46
|
+
await new Promise((resolve) => {
|
|
47
|
+
server = app.listen(0, '127.0.0.1', () => resolve());
|
|
48
|
+
});
|
|
49
|
+
const addr = server.address();
|
|
50
|
+
if (typeof addr === 'object' && addr) {
|
|
51
|
+
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
after(() => {
|
|
55
|
+
server?.close();
|
|
56
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
async function browse(query = {}) {
|
|
59
|
+
const params = new URLSearchParams(query);
|
|
60
|
+
const res = await fetch(`${baseUrl}/workspaces/browse?${params}`);
|
|
61
|
+
assert.equal(res.status, 200, `Expected 200 but got ${res.status}`);
|
|
62
|
+
return res.json();
|
|
63
|
+
}
|
|
64
|
+
describe('GET /workspaces/browse', () => {
|
|
65
|
+
test('lists directories in a given path', async () => {
|
|
66
|
+
const browsable = path.join(tmpDir, 'browsable');
|
|
67
|
+
const data = await browse({ path: browsable });
|
|
68
|
+
assert.equal(data.resolved, browsable);
|
|
69
|
+
const names = data.entries.map((e) => e.name);
|
|
70
|
+
// Should include visible directories but not files or denylisted dirs
|
|
71
|
+
assert.ok(names.includes('visible-dir'), 'should include visible-dir');
|
|
72
|
+
assert.ok(names.includes('git-repo'), 'should include git-repo');
|
|
73
|
+
assert.ok(names.includes('empty-dir'), 'should include empty-dir');
|
|
74
|
+
assert.ok(!names.includes('file.txt'), 'should exclude files');
|
|
75
|
+
assert.ok(!names.includes('node_modules'), 'should exclude node_modules');
|
|
76
|
+
});
|
|
77
|
+
test('hides dotfiles by default', async () => {
|
|
78
|
+
const browsable = path.join(tmpDir, 'browsable');
|
|
79
|
+
const data = await browse({ path: browsable });
|
|
80
|
+
const names = data.entries.map((e) => e.name);
|
|
81
|
+
assert.ok(!names.includes('.hidden-dir'), 'should exclude hidden dirs by default');
|
|
82
|
+
});
|
|
83
|
+
test('shows dotfiles when showHidden=true', async () => {
|
|
84
|
+
const browsable = path.join(tmpDir, 'browsable');
|
|
85
|
+
const data = await browse({ path: browsable, showHidden: 'true' });
|
|
86
|
+
const names = data.entries.map((e) => e.name);
|
|
87
|
+
assert.ok(names.includes('.hidden-dir'), 'should include hidden dirs when showHidden');
|
|
88
|
+
// .git should still be excluded (in denylist)
|
|
89
|
+
assert.ok(!names.includes('.git'), 'should still exclude .git');
|
|
90
|
+
});
|
|
91
|
+
test('filters by prefix', async () => {
|
|
92
|
+
const browsable = path.join(tmpDir, 'browsable');
|
|
93
|
+
const data = await browse({ path: browsable, prefix: 'vis' });
|
|
94
|
+
assert.equal(data.entries.length, 1);
|
|
95
|
+
assert.equal(data.entries[0].name, 'visible-dir');
|
|
96
|
+
});
|
|
97
|
+
test('prefix filter is case-insensitive', async () => {
|
|
98
|
+
const browsable = path.join(tmpDir, 'browsable');
|
|
99
|
+
const data = await browse({ path: browsable, prefix: 'VIS' });
|
|
100
|
+
assert.equal(data.entries.length, 1);
|
|
101
|
+
assert.equal(data.entries[0].name, 'visible-dir');
|
|
102
|
+
});
|
|
103
|
+
test('detects isGitRepo correctly', async () => {
|
|
104
|
+
const browsable = path.join(tmpDir, 'browsable');
|
|
105
|
+
const data = await browse({ path: browsable });
|
|
106
|
+
const gitRepo = data.entries.find((e) => e.name === 'git-repo');
|
|
107
|
+
const visibleDir = data.entries.find((e) => e.name === 'visible-dir');
|
|
108
|
+
assert.ok(gitRepo, 'git-repo entry should exist');
|
|
109
|
+
assert.equal(gitRepo.isGitRepo, true, 'git-repo should have isGitRepo=true');
|
|
110
|
+
assert.ok(visibleDir, 'visible-dir entry should exist');
|
|
111
|
+
assert.equal(visibleDir.isGitRepo, false, 'visible-dir should have isGitRepo=false');
|
|
112
|
+
});
|
|
113
|
+
test('detects hasChildren correctly', async () => {
|
|
114
|
+
const browsable = path.join(tmpDir, 'browsable');
|
|
115
|
+
const data = await browse({ path: browsable });
|
|
116
|
+
const visibleDir = data.entries.find((e) => e.name === 'visible-dir');
|
|
117
|
+
const emptyDir = data.entries.find((e) => e.name === 'empty-dir');
|
|
118
|
+
assert.ok(visibleDir, 'visible-dir entry should exist');
|
|
119
|
+
assert.equal(visibleDir.hasChildren, true, 'visible-dir should have children');
|
|
120
|
+
assert.ok(emptyDir, 'empty-dir entry should exist');
|
|
121
|
+
assert.equal(emptyDir.hasChildren, false, 'empty-dir should not have children');
|
|
122
|
+
});
|
|
123
|
+
test('truncates at 100 entries', async () => {
|
|
124
|
+
const manyDir = path.join(tmpDir, 'many');
|
|
125
|
+
const data = await browse({ path: manyDir });
|
|
126
|
+
assert.equal(data.entries.length, 100);
|
|
127
|
+
assert.equal(data.truncated, true);
|
|
128
|
+
assert.equal(data.total, 110);
|
|
129
|
+
});
|
|
130
|
+
test('sorts alphabetically case-insensitive', async () => {
|
|
131
|
+
const browsable = path.join(tmpDir, 'browsable');
|
|
132
|
+
const data = await browse({ path: browsable });
|
|
133
|
+
const names = data.entries.map((e) => e.name);
|
|
134
|
+
const sorted = [...names].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
135
|
+
assert.deepEqual(names, sorted, 'entries should be sorted alphabetically');
|
|
136
|
+
});
|
|
137
|
+
test('returns 400 for non-existent path', async () => {
|
|
138
|
+
const params = new URLSearchParams({ path: path.join(tmpDir, 'nonexistent') });
|
|
139
|
+
const res = await fetch(`${baseUrl}/workspaces/browse?${params}`);
|
|
140
|
+
assert.equal(res.status, 400);
|
|
141
|
+
});
|
|
142
|
+
test('returns 400 for file path', async () => {
|
|
143
|
+
const params = new URLSearchParams({ path: path.join(tmpDir, 'browsable', 'file.txt') });
|
|
144
|
+
const res = await fetch(`${baseUrl}/workspaces/browse?${params}`);
|
|
145
|
+
assert.equal(res.status, 400);
|
|
146
|
+
});
|
|
147
|
+
test('defaults to home directory when no path given', async () => {
|
|
148
|
+
const data = await browse();
|
|
149
|
+
assert.equal(data.resolved, os.homedir());
|
|
150
|
+
// Should have at least some entries (home dir is not empty)
|
|
151
|
+
assert.ok(data.entries.length > 0, 'home directory should have entries');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe('POST /workspaces/bulk', () => {
|
|
155
|
+
test('adds multiple workspaces', async () => {
|
|
156
|
+
const dir1 = path.join(tmpDir, 'browsable', 'visible-dir');
|
|
157
|
+
const dir2 = path.join(tmpDir, 'browsable', 'empty-dir');
|
|
158
|
+
const res = await fetch(`${baseUrl}/workspaces/bulk`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'Content-Type': 'application/json' },
|
|
161
|
+
body: JSON.stringify({ paths: [dir1, dir2] }),
|
|
162
|
+
});
|
|
163
|
+
assert.equal(res.status, 201);
|
|
164
|
+
const data = await res.json();
|
|
165
|
+
assert.equal(data.added.length, 2);
|
|
166
|
+
assert.equal(data.errors.length, 0);
|
|
167
|
+
});
|
|
168
|
+
test('rejects duplicate workspaces', async () => {
|
|
169
|
+
const dir1 = path.join(tmpDir, 'browsable', 'visible-dir');
|
|
170
|
+
const res = await fetch(`${baseUrl}/workspaces/bulk`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({ paths: [dir1] }),
|
|
174
|
+
});
|
|
175
|
+
assert.equal(res.status, 201);
|
|
176
|
+
const data = await res.json();
|
|
177
|
+
assert.equal(data.added.length, 0);
|
|
178
|
+
assert.equal(data.errors.length, 1);
|
|
179
|
+
assert.ok(data.errors[0].error.includes('Already exists'));
|
|
180
|
+
});
|
|
181
|
+
test('returns 400 for empty paths array', async () => {
|
|
182
|
+
const res = await fetch(`${baseUrl}/workspaces/bulk`, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: { 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({ paths: [] }),
|
|
186
|
+
});
|
|
187
|
+
assert.equal(res.status, 400);
|
|
188
|
+
});
|
|
189
|
+
test('handles mixed valid/invalid paths', async () => {
|
|
190
|
+
const validDir = path.join(tmpDir, 'browsable', 'git-repo');
|
|
191
|
+
const invalidDir = path.join(tmpDir, 'nonexistent');
|
|
192
|
+
const res = await fetch(`${baseUrl}/workspaces/bulk`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'Content-Type': 'application/json' },
|
|
195
|
+
body: JSON.stringify({ paths: [validDir, invalidDir] }),
|
|
196
|
+
});
|
|
197
|
+
assert.equal(res.status, 201);
|
|
198
|
+
const data = await res.json();
|
|
199
|
+
assert.equal(data.added.length, 1);
|
|
200
|
+
assert.equal(data.errors.length, 1);
|
|
201
|
+
});
|
|
202
|
+
});
|