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.
@@ -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 session.scrollback) {
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(session.pty);
145
+ attachToPty(ptySession.pty);
95
146
  const ptyReplacedHandler = (newPty) => attachToPty(newPty);
96
- session.onPtyReplacedCallbacks.push(ptyReplacedHandler);
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(session.id, parsed.cols, parsed.rows);
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 (session.needsBranchRename) {
109
- if (!session._renameBuffer)
110
- session._renameBuffer = '';
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
- session._renameBuffer += str;
115
- session.pty.write(str);
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 = session._renameBuffer;
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.${session.branchRenamePrompt ? ' User preferences: ' + session.branchRenamePrompt : ''} Do not ask for confirmation — just rename and proceed.\n\n`;
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
- session.pty.write(clearLine + renamePrompt + beforeEnter + afterEnter);
125
- session.needsBranchRename = false;
126
- delete session._renameBuffer;
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 session.pty dynamically so writes go to current PTY
130
- session.pty.write(str);
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 = session.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
188
+ const idx = ptySession.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
136
189
  if (idx !== -1)
137
- session.onPtyReplacedCallbacks.splice(idx, 1);
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
+ });