claude-remote-cli 3.6.0 → 3.8.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.
@@ -15,9 +15,7 @@ const BROWSE_DENYLIST = new Set([
15
15
  ]);
16
16
  const BROWSE_MAX_ENTRIES = 100;
17
17
  const BULK_MAX_PATHS = 50;
18
- // ---------------------------------------------------------------------------
19
18
  // Exported helpers
20
- // ---------------------------------------------------------------------------
21
19
  /**
22
20
  * Resolves and validates a raw workspace path string.
23
21
  * Throws with a human-readable message if the path is invalid.
@@ -70,9 +68,7 @@ export async function detectGitRepo(dirPath, execAsync = execFileAsync) {
70
68
  }
71
69
  return { isGitRepo: true, defaultBranch };
72
70
  }
73
- // ---------------------------------------------------------------------------
74
71
  // Router factory
75
- // ---------------------------------------------------------------------------
76
72
  /**
77
73
  * Creates and returns an Express Router that handles all /workspaces routes.
78
74
  *
@@ -87,9 +83,7 @@ export function createWorkspaceRouter(deps) {
87
83
  function getConfig() {
88
84
  return loadConfig(configPath);
89
85
  }
90
- // -------------------------------------------------------------------------
91
86
  // GET /workspaces — list all workspaces with git info
92
- // -------------------------------------------------------------------------
93
87
  router.get('/', async (_req, res) => {
94
88
  const config = getConfig();
95
89
  const workspacePaths = config.workspaces ?? [];
@@ -100,9 +94,7 @@ export function createWorkspaceRouter(deps) {
100
94
  }));
101
95
  res.json({ workspaces: results });
102
96
  });
103
- // -------------------------------------------------------------------------
104
97
  // POST /workspaces — add a workspace
105
- // -------------------------------------------------------------------------
106
98
  router.post('/', async (req, res) => {
107
99
  const body = req.body;
108
100
  const rawPath = body.path;
@@ -145,9 +137,7 @@ export function createWorkspaceRouter(deps) {
145
137
  };
146
138
  res.status(201).json(workspace);
147
139
  });
148
- // -------------------------------------------------------------------------
149
140
  // DELETE /workspaces — remove a workspace
150
- // -------------------------------------------------------------------------
151
141
  router.delete('/', async (req, res) => {
152
142
  const body = req.body;
153
143
  const rawPath = body.path;
@@ -168,9 +158,7 @@ export function createWorkspaceRouter(deps) {
168
158
  trackEvent({ category: 'workspace', action: 'removed', target: resolved });
169
159
  res.json({ removed: resolved });
170
160
  });
171
- // -------------------------------------------------------------------------
172
161
  // PUT /workspaces/reorder — reorder workspaces
173
- // -------------------------------------------------------------------------
174
162
  router.put('/reorder', async (req, res) => {
175
163
  const body = req.body;
176
164
  const rawPaths = body.paths;
@@ -201,9 +189,7 @@ export function createWorkspaceRouter(deps) {
201
189
  }));
202
190
  res.json({ workspaces: results });
203
191
  });
204
- // -------------------------------------------------------------------------
205
192
  // POST /workspaces/bulk — add multiple workspaces at once
206
- // -------------------------------------------------------------------------
207
193
  router.post('/bulk', async (req, res) => {
208
194
  const body = req.body;
209
195
  const rawPaths = body.paths;
@@ -255,9 +241,7 @@ export function createWorkspaceRouter(deps) {
255
241
  }
256
242
  res.status(201).json({ added, errors });
257
243
  });
258
- // -------------------------------------------------------------------------
259
244
  // GET /workspaces/dashboard — aggregated PR + activity data for a workspace
260
- // -------------------------------------------------------------------------
261
245
  router.get('/dashboard', async (req, res) => {
262
246
  const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
263
247
  if (!workspacePath) {
@@ -339,36 +323,44 @@ export function createWorkspaceRouter(deps) {
339
323
  activity,
340
324
  });
341
325
  });
342
- // -------------------------------------------------------------------------
343
- // GET /workspaces/settings — per-workspace settings
344
- // -------------------------------------------------------------------------
326
+ function buildMergedSettings(config, workspacePath) {
327
+ const resolved = path.resolve(workspacePath);
328
+ const wsOverrides = config.workspaceSettings?.[resolved] ?? {};
329
+ const effective = getWorkspaceSettings(config, resolved);
330
+ const overridden = [];
331
+ for (const key of ['defaultAgent', 'defaultContinue', 'defaultYolo', 'launchInTmux']) {
332
+ if (wsOverrides[key] !== undefined)
333
+ overridden.push(key);
334
+ }
335
+ return { settings: effective, overridden };
336
+ }
337
+ // GET /workspaces/settings — per-workspace overrides only
345
338
  router.get('/settings', async (req, res) => {
346
339
  const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
347
- const merged = req.query.merged === 'true';
348
340
  if (!workspacePath) {
349
341
  res.status(400).json({ error: 'path query parameter is required' });
350
342
  return;
351
343
  }
344
+ // Backward compat: handle merged=true inline (same logic as /settings/merged)
345
+ if (req.query.merged === 'true') {
346
+ res.json(buildMergedSettings(getConfig(), workspacePath));
347
+ return;
348
+ }
352
349
  const config = getConfig();
353
350
  const resolved = path.resolve(workspacePath);
354
- if (merged) {
355
- const wsOverrides = config.workspaceSettings?.[resolved] ?? {};
356
- const effective = getWorkspaceSettings(config, resolved);
357
- const overridden = [];
358
- for (const key of ['defaultAgent', 'defaultContinue', 'defaultYolo', 'launchInTmux']) {
359
- if (wsOverrides[key] !== undefined)
360
- overridden.push(key);
361
- }
362
- res.json({ settings: effective, overridden });
363
- }
364
- else {
365
- const settings = config.workspaceSettings?.[resolved] ?? {};
366
- res.json(settings);
351
+ const settings = config.workspaceSettings?.[resolved] ?? {};
352
+ res.json(settings);
353
+ });
354
+ // GET /workspaces/settings/merged — effective settings with override tracking
355
+ router.get('/settings/merged', async (req, res) => {
356
+ const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
357
+ if (!workspacePath) {
358
+ res.status(400).json({ error: 'path query parameter is required' });
359
+ return;
367
360
  }
361
+ res.json(buildMergedSettings(getConfig(), workspacePath));
368
362
  });
369
- // -------------------------------------------------------------------------
370
363
  // PATCH /workspaces/settings — update per-workspace settings
371
- // -------------------------------------------------------------------------
372
364
  router.patch('/settings', async (req, res) => {
373
365
  const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
374
366
  if (!workspacePath) {
@@ -401,9 +393,7 @@ export function createWorkspaceRouter(deps) {
401
393
  const final = config.workspaceSettings?.[resolved] ?? {};
402
394
  res.json(final);
403
395
  });
404
- // -------------------------------------------------------------------------
405
396
  // GET /workspaces/pr — PR info for a specific branch
406
- // -------------------------------------------------------------------------
407
397
  router.get('/pr', async (req, res) => {
408
398
  const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
409
399
  const branch = typeof req.query.branch === 'string' ? req.query.branch : undefined;
@@ -430,9 +420,7 @@ export function createWorkspaceRouter(deps) {
430
420
  res.status(404).json({ error: 'No PR found for branch' });
431
421
  }
432
422
  });
433
- // -------------------------------------------------------------------------
434
423
  // GET /workspaces/ci-status — CI check results for a workspace + branch
435
- // -------------------------------------------------------------------------
436
424
  router.get('/ci-status', async (req, res) => {
437
425
  const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
438
426
  const branch = typeof req.query.branch === 'string' ? req.query.branch : undefined;
@@ -448,9 +436,7 @@ export function createWorkspaceRouter(deps) {
448
436
  res.json({ total: 0, passing: 0, failing: 0, pending: 0 });
449
437
  }
450
438
  });
451
- // -------------------------------------------------------------------------
452
439
  // POST /workspaces/branch — switch branch for a workspace
453
- // -------------------------------------------------------------------------
454
440
  router.post('/branch', async (req, res) => {
455
441
  const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
456
442
  if (!workspacePath) {
@@ -471,9 +457,7 @@ export function createWorkspaceRouter(deps) {
471
457
  res.status(400).json({ error: result.error ?? `Failed to switch to branch: ${branch}` });
472
458
  }
473
459
  });
474
- // -------------------------------------------------------------------------
475
460
  // POST /workspaces/worktree — create a new worktree with the next mountain name
476
- // -------------------------------------------------------------------------
477
461
  router.post('/worktree', async (req, res) => {
478
462
  const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
479
463
  if (!workspacePath) {
@@ -534,9 +518,7 @@ export function createWorkspaceRouter(deps) {
534
518
  }
535
519
  res.json({ branchName, mountainName, worktreePath });
536
520
  });
537
- // -------------------------------------------------------------------------
538
521
  // GET /workspaces/current-branch — current checked-out branch for a path
539
- // -------------------------------------------------------------------------
540
522
  router.get('/current-branch', async (req, res) => {
541
523
  const workspacePath = typeof req.query.path === 'string' ? req.query.path : undefined;
542
524
  if (!workspacePath) {
@@ -546,9 +528,7 @@ export function createWorkspaceRouter(deps) {
546
528
  const branch = await getCurrentBranch(path.resolve(workspacePath));
547
529
  res.json({ branch });
548
530
  });
549
- // -------------------------------------------------------------------------
550
531
  // GET /workspaces/browse — browse filesystem directories for tree UI
551
- // -------------------------------------------------------------------------
552
532
  router.get('/browse', async (req, res) => {
553
533
  const rawPath = typeof req.query.path === 'string' ? req.query.path : '~';
554
534
  const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : '';
@@ -558,7 +538,6 @@ export function createWorkspaceRouter(deps) {
558
538
  ? path.join(os.homedir(), rawPath.slice(1))
559
539
  : rawPath;
560
540
  const resolved = path.resolve(expanded);
561
- // Validate path
562
541
  let stat;
563
542
  try {
564
543
  stat = await fs.promises.stat(resolved);
@@ -577,7 +556,6 @@ export function createWorkspaceRouter(deps) {
577
556
  res.status(400).json({ error: `Not a directory: ${resolved}` });
578
557
  return;
579
558
  }
580
- // Read directory entries
581
559
  let dirents;
582
560
  try {
583
561
  dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
@@ -600,7 +578,6 @@ export function createWorkspaceRouter(deps) {
600
578
  return false;
601
579
  return true;
602
580
  });
603
- // Sort alphabetically case-insensitive
604
581
  dirs.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
605
582
  const total = dirs.length;
606
583
  const truncated = dirs.length > BROWSE_MAX_ENTRIES;
@@ -609,7 +586,6 @@ export function createWorkspaceRouter(deps) {
609
586
  // Enrich each entry with isGitRepo and hasChildren (parallelized)
610
587
  const entries = await Promise.all(dirs.map(async (d) => {
611
588
  const entryPath = path.join(resolved, d.name);
612
- // Check for .git directory (isGitRepo)
613
589
  let isGitRepo = false;
614
590
  try {
615
591
  const gitStat = await fs.promises.stat(path.join(entryPath, '.git'));
@@ -618,7 +594,6 @@ export function createWorkspaceRouter(deps) {
618
594
  catch {
619
595
  // not a git repo
620
596
  }
621
- // Check if has at least one subdirectory child (hasChildren)
622
597
  let hasChildren = false;
623
598
  try {
624
599
  const children = await fs.promises.readdir(entryPath, { withFileTypes: true });
@@ -636,9 +611,7 @@ export function createWorkspaceRouter(deps) {
636
611
  }));
637
612
  res.json({ resolved, entries, truncated, total });
638
613
  });
639
- // -------------------------------------------------------------------------
640
614
  // GET /workspaces/autocomplete — path prefix autocomplete
641
- // -------------------------------------------------------------------------
642
615
  router.get('/autocomplete', async (req, res) => {
643
616
  const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : '';
644
617
  if (!prefix) {
package/dist/server/ws.js CHANGED
@@ -1,85 +1,6 @@
1
1
  import { WebSocketServer } from 'ws';
2
- import { execFile } from 'node:child_process';
3
- import { promisify } from 'node:util';
4
2
  import * as sessions from './sessions.js';
5
- import { writeMeta } from './config.js';
6
- import { branchToDisplayName } from './git.js';
7
3
  import { trackEvent } from './analytics.js';
8
- const execFileAsync = promisify(execFile);
9
- const BRANCH_POLL_INTERVAL_MS = 3000;
10
- const BRANCH_POLL_MAX_ATTEMPTS = 10;
11
- function startBranchWatcher(session, broadcastEvent, cfgPath) {
12
- const originalBranch = session.branchName;
13
- let attempts = 0;
14
- const timer = setInterval(async () => {
15
- attempts++;
16
- if (attempts > BRANCH_POLL_MAX_ATTEMPTS) {
17
- clearInterval(timer);
18
- return;
19
- }
20
- try {
21
- const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: session.cwd });
22
- const currentBranch = stdout.trim();
23
- if (currentBranch && currentBranch !== originalBranch) {
24
- clearInterval(timer);
25
- const displayName = branchToDisplayName(currentBranch);
26
- session.branchName = currentBranch;
27
- session.displayName = displayName;
28
- broadcastEvent('session-renamed', { sessionId: session.id, branchName: currentBranch, displayName });
29
- writeMeta(cfgPath, {
30
- worktreePath: session.repoPath,
31
- displayName,
32
- lastActivity: new Date().toISOString(),
33
- branchName: currentBranch,
34
- });
35
- }
36
- }
37
- catch {
38
- // git command failed — session cwd may not exist yet, retry
39
- }
40
- }, BRANCH_POLL_INTERVAL_MS);
41
- }
42
- /** Sideband branch rename: uses headless claude to generate a branch name from the first message */
43
- async function spawnBranchRename(session, firstMessage, cfgPath, broadcastEvent) {
44
- try {
45
- const cleanMessage = firstMessage.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/[\x00-\x1f]/g, ' ').trim();
46
- if (!cleanMessage)
47
- return;
48
- const basePrompt = session.branchRenamePrompt
49
- ?? `Output ONLY a short kebab-case git branch name (no explanation, no backticks, no prefix, just the name) that describes this task:`;
50
- const prompt = `${basePrompt}\n\n${cleanMessage.slice(0, 500)}`;
51
- const { stdout } = await execFileAsync('claude', ['-p', '--model', 'haiku', prompt], {
52
- cwd: session.cwd,
53
- timeout: 30000,
54
- });
55
- const branchName = stdout.trim().replace(/`/g, '').replace(/[^a-z0-9-]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase().slice(0, 60);
56
- if (!branchName)
57
- return;
58
- await execFileAsync('git', ['branch', '-m', branchName], { cwd: session.cwd });
59
- // Update session state
60
- const displayName = branchToDisplayName(branchName);
61
- session.branchName = branchName;
62
- session.displayName = displayName;
63
- broadcastEvent('session-renamed', {
64
- sessionId: session.id,
65
- branchName,
66
- displayName,
67
- });
68
- if (cfgPath) {
69
- writeMeta(cfgPath, {
70
- worktreePath: session.repoPath,
71
- displayName,
72
- lastActivity: new Date().toISOString(),
73
- branchName,
74
- });
75
- }
76
- }
77
- catch {
78
- // Sideband rename is best-effort — fall back to branch watcher if claude CLI isn't available
79
- if (cfgPath)
80
- startBranchWatcher(session, broadcastEvent, cfgPath);
81
- }
82
- }
83
4
  function parseCookies(cookieHeader) {
84
5
  const cookies = {};
85
6
  if (!cookieHeader)
@@ -94,7 +15,7 @@ function parseCookies(cookieHeader) {
94
15
  });
95
16
  return cookies;
96
17
  }
97
- function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
18
+ function setupWebSocket(server, authenticatedTokens, watcher, _configPath) {
98
19
  const wss = new WebSocketServer({ noServer: true });
99
20
  const eventClients = new Set();
100
21
  function broadcastEvent(type, data) {
@@ -151,15 +72,14 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
151
72
  const session = sessionMap.get(ws);
152
73
  if (!session)
153
74
  return;
154
- const ptySession = session;
155
75
  let dataDisposable = null;
156
76
  let exitDisposable = null;
157
- function attachToPty(ptyProcess) {
77
+ const attachToPty = (ptyProcess) => {
158
78
  // Dispose previous handlers
159
79
  dataDisposable?.dispose();
160
80
  exitDisposable?.dispose();
161
81
  // Replay scrollback
162
- for (const chunk of ptySession.scrollback) {
82
+ for (const chunk of session.scrollback) {
163
83
  if (ws.readyState === ws.OPEN)
164
84
  ws.send(chunk);
165
85
  }
@@ -171,53 +91,29 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
171
91
  if (ws.readyState === ws.OPEN)
172
92
  ws.close(1000);
173
93
  });
174
- }
175
- attachToPty(ptySession.pty);
94
+ };
95
+ attachToPty(session.pty);
176
96
  const ptyReplacedHandler = (newPty) => attachToPty(newPty);
177
- ptySession.onPtyReplacedCallbacks.push(ptyReplacedHandler);
97
+ session.onPtyReplacedCallbacks.push(ptyReplacedHandler);
178
98
  ws.on('message', (msg) => {
179
99
  const str = msg.toString();
180
100
  try {
181
101
  const parsed = JSON.parse(str);
182
102
  if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
183
- sessions.resize(ptySession.id, parsed.cols, parsed.rows);
103
+ sessions.resize(session.id, parsed.cols, parsed.rows);
184
104
  return;
185
105
  }
186
106
  }
187
107
  catch (_) { }
188
- // Sideband branch rename: capture first message, pass through unmodified, rename out-of-band
189
- if (ptySession.needsBranchRename && ptySession.agentState !== 'waiting-for-input') {
190
- ptySession.pty.write(str);
191
- return;
192
- }
193
- if (ptySession.needsBranchRename) {
194
- if (!ptySession._renameBuffer)
195
- ptySession._renameBuffer = '';
196
- const enterIndex = str.indexOf('\r');
197
- if (enterIndex === -1) {
198
- ptySession._renameBuffer += str;
199
- ptySession.pty.write(str); // pass through to PTY normally — user sees their typing
200
- return;
201
- }
202
- // Enter detected — pass everything through unmodified
203
- const buffered = ptySession._renameBuffer;
204
- const firstMessage = buffered + str.slice(0, enterIndex);
205
- ptySession.pty.write(str); // pass through the Enter key
206
- ptySession.needsBranchRename = false;
207
- delete ptySession._renameBuffer;
208
- // Sideband: spawn headless claude to generate branch name (async, non-blocking)
209
- spawnBranchRename(ptySession, firstMessage, configPath, broadcastEvent);
210
- return;
211
- }
212
- // Use ptySession.pty dynamically so writes go to current PTY
213
- ptySession.pty.write(str);
108
+ // Use session.pty dynamically so writes go to current PTY
109
+ session.pty.write(str);
214
110
  });
215
111
  ws.on('close', () => {
216
112
  dataDisposable?.dispose();
217
113
  exitDisposable?.dispose();
218
- const idx = ptySession.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
114
+ const idx = session.onPtyReplacedCallbacks.indexOf(ptyReplacedHandler);
219
115
  if (idx !== -1)
220
- ptySession.onPtyReplacedCallbacks.splice(idx, 1);
116
+ session.onPtyReplacedCallbacks.splice(idx, 1);
221
117
  });
222
118
  });
223
119
  sessions.onIdleChange((sessionId, idle) => {
@@ -1,10 +1,12 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { hashPin, verifyPin, isRateLimited, recordFailedAttempt, generateCookieToken, _resetForTesting, } from '../server/auth.js';
4
- test('hashPin returns bcrypt hash starting with $2b$', async () => {
4
+ test('hashPin returns scrypt hash with expected format', async () => {
5
5
  _resetForTesting();
6
6
  const hash = await hashPin('1234');
7
- assert.ok(hash.startsWith('$2b$'), `Expected hash to start with $2b$, got: ${hash}`);
7
+ assert.ok(hash.startsWith('scrypt:'), `Expected hash to start with scrypt:, got: ${hash}`);
8
+ const parts = hash.split(':');
9
+ assert.strictEqual(parts.length, 3, 'Hash should have 3 colon-separated parts');
8
10
  });
9
11
  test('verifyPin returns true for correct PIN', async () => {
10
12
  _resetForTesting();
@@ -34,6 +36,47 @@ test('rate limiter allows under threshold', () => {
34
36
  }
35
37
  assert.strictEqual(isRateLimited(ip), false);
36
38
  });
39
+ test('verifyPin returns false for legacy bcrypt hash (requires PIN reset)', async () => {
40
+ _resetForTesting();
41
+ const legacyHash = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012';
42
+ const result = await verifyPin('1234', legacyHash);
43
+ assert.strictEqual(result, false);
44
+ });
45
+ test('verifyPin returns false for malformed scrypt hash (missing parts)', async () => {
46
+ _resetForTesting();
47
+ const result = await verifyPin('1234', 'scrypt:saltonly');
48
+ assert.strictEqual(result, false);
49
+ });
50
+ test('verifyPin returns false for scrypt hash with empty salt', async () => {
51
+ _resetForTesting();
52
+ const result = await verifyPin('1234', 'scrypt::deadbeef');
53
+ assert.strictEqual(result, false);
54
+ });
55
+ test('verifyPin returns false for scrypt hash with wrong key length', async () => {
56
+ _resetForTesting();
57
+ // Valid hex but wrong length (should be 64 bytes = 128 hex chars)
58
+ const result = await verifyPin('1234', 'scrypt:abcd1234:deadbeef');
59
+ assert.strictEqual(result, false);
60
+ });
61
+ test('verifyPin returns false for completely empty hash', async () => {
62
+ _resetForTesting();
63
+ const result = await verifyPin('1234', '');
64
+ assert.strictEqual(result, false);
65
+ });
66
+ test('verifyPin returns false for garbage input', async () => {
67
+ _resetForTesting();
68
+ const result = await verifyPin('1234', 'not-a-valid-hash-at-all');
69
+ assert.strictEqual(result, false);
70
+ });
71
+ test('hashPin produces unique salts', async () => {
72
+ _resetForTesting();
73
+ const hash1 = await hashPin('1234');
74
+ const hash2 = await hashPin('1234');
75
+ assert.notStrictEqual(hash1, hash2, 'Two hashes of the same PIN should have different salts');
76
+ // But both should verify correctly
77
+ assert.strictEqual(await verifyPin('1234', hash1), true);
78
+ assert.strictEqual(await verifyPin('1234', hash2), true);
79
+ });
37
80
  test('generateCookieToken returns non-empty string', () => {
38
81
  _resetForTesting();
39
82
  const token = generateCookieToken();
@@ -0,0 +1,139 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { stripAnsi, semverLessThan, cleanEnv } from '../server/utils.js';
4
+ import { onStateChange, fireStateChange } from '../server/sessions.js';
5
+ describe('stripAnsi', () => {
6
+ it('strips CSI color sequences', () => {
7
+ assert.equal(stripAnsi('\x1b[32mhello\x1b[0m'), 'hello');
8
+ });
9
+ it('strips CSI bold/reset sequences', () => {
10
+ assert.equal(stripAnsi('\x1b[1mbold\x1b[0m'), 'bold');
11
+ });
12
+ it('strips OSC sequences', () => {
13
+ assert.equal(stripAnsi('\x1b]0;window title\x07plain'), 'plain');
14
+ });
15
+ it('strips cursor movement sequences', () => {
16
+ assert.equal(stripAnsi('\x1b[2Jhello'), 'hello');
17
+ });
18
+ it('preserves plain text', () => {
19
+ assert.equal(stripAnsi('hello world'), 'hello world');
20
+ });
21
+ it('handles empty string', () => {
22
+ assert.equal(stripAnsi(''), '');
23
+ });
24
+ it('strips multiple sequences in one string', () => {
25
+ assert.equal(stripAnsi('\x1b[32mfoo\x1b[0m and \x1b[1mbar\x1b[0m'), 'foo and bar');
26
+ });
27
+ });
28
+ describe('semverLessThan', () => {
29
+ it('returns true when major is lower', () => {
30
+ assert.equal(semverLessThan('1.0.0', '2.0.0'), true);
31
+ });
32
+ it('returns false when major is higher', () => {
33
+ assert.equal(semverLessThan('2.0.0', '1.0.0'), false);
34
+ });
35
+ it('returns true when patch is lower', () => {
36
+ assert.equal(semverLessThan('1.2.3', '1.2.4'), true);
37
+ });
38
+ it('returns false when patch is higher', () => {
39
+ assert.equal(semverLessThan('1.2.4', '1.2.3'), false);
40
+ });
41
+ it('returns false for equal versions', () => {
42
+ assert.equal(semverLessThan('1.0.0', '1.0.0'), false);
43
+ });
44
+ it('strips pre-release tag before comparing — 1.2.3-beta.1 vs 1.2.3 treated as equal', () => {
45
+ assert.equal(semverLessThan('1.2.3-beta.1', '1.2.3'), false);
46
+ });
47
+ it('strips pre-release tag before comparing — 1.2.3-beta.1 vs 1.3.0 treated as less than', () => {
48
+ assert.equal(semverLessThan('1.2.3-beta.1', '1.3.0'), true);
49
+ });
50
+ it('returns true when minor is lower', () => {
51
+ assert.equal(semverLessThan('1.1.0', '1.2.0'), true);
52
+ });
53
+ it('handles major version jumps', () => {
54
+ assert.equal(semverLessThan('1.9.9', '2.0.0'), true);
55
+ });
56
+ });
57
+ describe('cleanEnv', () => {
58
+ it('returns an object that does not contain CLAUDECODE', () => {
59
+ const originalValue = process.env.CLAUDECODE;
60
+ process.env.CLAUDECODE = 'some-value';
61
+ try {
62
+ const env = cleanEnv();
63
+ assert.equal(Object.prototype.hasOwnProperty.call(env, 'CLAUDECODE'), false);
64
+ }
65
+ finally {
66
+ if (originalValue === undefined) {
67
+ delete process.env.CLAUDECODE;
68
+ }
69
+ else {
70
+ process.env.CLAUDECODE = originalValue;
71
+ }
72
+ }
73
+ });
74
+ it('does not modify original process.env', () => {
75
+ const originalValue = process.env.CLAUDECODE;
76
+ process.env.CLAUDECODE = 'test-token';
77
+ try {
78
+ cleanEnv();
79
+ assert.equal(process.env.CLAUDECODE, 'test-token');
80
+ }
81
+ finally {
82
+ if (originalValue === undefined) {
83
+ delete process.env.CLAUDECODE;
84
+ }
85
+ else {
86
+ process.env.CLAUDECODE = originalValue;
87
+ }
88
+ }
89
+ });
90
+ it('returns a copy — mutations do not affect process.env', () => {
91
+ const env = cleanEnv();
92
+ const testKey = '__CRC_TEST_KEY__';
93
+ env[testKey] = 'injected';
94
+ assert.equal(process.env[testKey], undefined);
95
+ });
96
+ it('preserves other environment variables', () => {
97
+ const env = cleanEnv();
98
+ // PATH is virtually always set; verify it round-trips
99
+ if (process.env.PATH !== undefined) {
100
+ assert.equal(env.PATH, process.env.PATH);
101
+ }
102
+ });
103
+ });
104
+ describe('fireStateChange callbacks', () => {
105
+ it('calls a registered onStateChange callback with correct args', () => {
106
+ const received = [];
107
+ onStateChange((sessionId, state) => {
108
+ received.push({ sessionId, state });
109
+ });
110
+ fireStateChange('test-session-id', 'processing');
111
+ const match = received.find(e => e.sessionId === 'test-session-id' && e.state === 'processing');
112
+ assert.ok(match, 'callback should have been called with the expected sessionId and state');
113
+ });
114
+ it('fires multiple registered callbacks', () => {
115
+ let count = 0;
116
+ onStateChange(() => { count++; });
117
+ onStateChange(() => { count++; });
118
+ fireStateChange('multi-cb-session', 'idle');
119
+ assert.ok(count >= 2, 'both callbacks should have been invoked');
120
+ });
121
+ it('passes idle state to callback', () => {
122
+ let received;
123
+ onStateChange((_, state) => { received = state; });
124
+ fireStateChange('some-session', 'idle');
125
+ assert.equal(received, 'idle');
126
+ });
127
+ it('passes permission-prompt state to callback', () => {
128
+ let received;
129
+ onStateChange((_, state) => { received = state; });
130
+ fireStateChange('some-session', 'permission-prompt');
131
+ assert.equal(received, 'permission-prompt');
132
+ });
133
+ it('passes waiting-for-input state to callback', () => {
134
+ let received;
135
+ onStateChange((_, state) => { received = state; });
136
+ fireStateChange('some-session', 'waiting-for-input');
137
+ assert.equal(received, 'waiting-for-input');
138
+ });
139
+ });
@@ -4,7 +4,8 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import * as sessions from '../server/sessions.js';
7
- import { resolveTmuxSpawn, generateTmuxSessionName, serializeAll, restoreFromDisk } from '../server/sessions.js';
7
+ import { resolveTmuxSpawn, generateTmuxSessionName } from '../server/pty-handler.js';
8
+ import { serializeAll, restoreFromDisk } from '../server/sessions.js';
8
9
  // Track created session IDs so we can clean up after each test
9
10
  const createdIds = [];
10
11
  afterEach(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "3.6.0",
3
+ "version": "3.8.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
@@ -45,7 +45,6 @@
45
45
  "@tanstack/svelte-query": "^6.0.18",
46
46
  "@xterm/addon-fit": "^0.11.0",
47
47
  "@xterm/xterm": "^6.0.0",
48
- "bcrypt": "^5.1.1",
49
48
  "better-sqlite3": "^12.8.0",
50
49
  "cookie-parser": "^1.4.7",
51
50
  "express": "^4.21.0",
@@ -58,7 +57,6 @@
58
57
  "devDependencies": {
59
58
  "@playwright/test": "^1.58.2",
60
59
  "@sveltejs/vite-plugin-svelte": "^5.1.1",
61
- "@types/bcrypt": "^5.0.2",
62
60
  "@types/better-sqlite3": "^7.6.13",
63
61
  "@types/cookie-parser": "^1.4.7",
64
62
  "@types/express": "^4.17.21",