claude-remote-cli 3.5.4 → 3.7.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/frontend/assets/index-BYXQcBQc.js +50 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server/analytics.js +121 -0
- package/dist/server/hooks.js +196 -0
- package/dist/server/index.js +29 -12
- package/dist/server/output-parsers/claude-parser.js +1 -1
- package/dist/server/pty-handler.js +79 -5
- package/dist/server/sessions.js +42 -2
- package/dist/server/utils.js +22 -0
- package/dist/server/workspaces.js +3 -0
- package/dist/server/ws.js +8 -104
- package/dist/test/analytics.test.js +152 -0
- package/dist/test/hooks.test.js +139 -0
- package/package.json +3 -1
- package/dist/frontend/assets/index-QZrLSCSL.js +0 -50
|
@@ -5,6 +5,7 @@ import { execFile } from 'node:child_process';
|
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { Router } from 'express';
|
|
7
7
|
import { loadConfig, saveConfig, getWorkspaceSettings, setWorkspaceSettings, deleteWorkspaceSettingKeys } from './config.js';
|
|
8
|
+
import { trackEvent } from './analytics.js';
|
|
8
9
|
import { listBranches, getActivityFeed, getCiStatus, getPrForBranch, getUnresolvedCommentCount, switchBranch, getCurrentBranch } from './git.js';
|
|
9
10
|
import { MOUNTAIN_NAMES } from './types.js';
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
@@ -135,6 +136,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
135
136
|
};
|
|
136
137
|
}
|
|
137
138
|
saveConfig(configPath, config);
|
|
139
|
+
trackEvent({ category: 'workspace', action: 'added', target: resolved, properties: { name: path.basename(resolved) } });
|
|
138
140
|
const workspace = {
|
|
139
141
|
path: resolved,
|
|
140
142
|
name: path.basename(resolved),
|
|
@@ -163,6 +165,7 @@ export function createWorkspaceRouter(deps) {
|
|
|
163
165
|
}
|
|
164
166
|
config.workspaces = workspaces.filter((p) => p !== resolved);
|
|
165
167
|
saveConfig(configPath, config);
|
|
168
|
+
trackEvent({ category: 'workspace', action: 'removed', target: resolved });
|
|
166
169
|
res.json({ removed: resolved });
|
|
167
170
|
});
|
|
168
171
|
// -------------------------------------------------------------------------
|
package/dist/server/ws.js
CHANGED
|
@@ -1,84 +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 {
|
|
6
|
-
import { branchToDisplayName } from './git.js';
|
|
7
|
-
const execFileAsync = promisify(execFile);
|
|
8
|
-
const BRANCH_POLL_INTERVAL_MS = 3000;
|
|
9
|
-
const BRANCH_POLL_MAX_ATTEMPTS = 10;
|
|
10
|
-
function startBranchWatcher(session, broadcastEvent, cfgPath) {
|
|
11
|
-
const originalBranch = session.branchName;
|
|
12
|
-
let attempts = 0;
|
|
13
|
-
const timer = setInterval(async () => {
|
|
14
|
-
attempts++;
|
|
15
|
-
if (attempts > BRANCH_POLL_MAX_ATTEMPTS) {
|
|
16
|
-
clearInterval(timer);
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
try {
|
|
20
|
-
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: session.cwd });
|
|
21
|
-
const currentBranch = stdout.trim();
|
|
22
|
-
if (currentBranch && currentBranch !== originalBranch) {
|
|
23
|
-
clearInterval(timer);
|
|
24
|
-
const displayName = branchToDisplayName(currentBranch);
|
|
25
|
-
session.branchName = currentBranch;
|
|
26
|
-
session.displayName = displayName;
|
|
27
|
-
broadcastEvent('session-renamed', { sessionId: session.id, branchName: currentBranch, displayName });
|
|
28
|
-
writeMeta(cfgPath, {
|
|
29
|
-
worktreePath: session.repoPath,
|
|
30
|
-
displayName,
|
|
31
|
-
lastActivity: new Date().toISOString(),
|
|
32
|
-
branchName: currentBranch,
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
// git command failed — session cwd may not exist yet, retry
|
|
38
|
-
}
|
|
39
|
-
}, BRANCH_POLL_INTERVAL_MS);
|
|
40
|
-
}
|
|
41
|
-
/** Sideband branch rename: uses headless claude to generate a branch name from the first message */
|
|
42
|
-
async function spawnBranchRename(session, firstMessage, cfgPath, broadcastEvent) {
|
|
43
|
-
try {
|
|
44
|
-
const cleanMessage = firstMessage.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/[\x00-\x1f]/g, ' ').trim();
|
|
45
|
-
if (!cleanMessage)
|
|
46
|
-
return;
|
|
47
|
-
const basePrompt = session.branchRenamePrompt
|
|
48
|
-
?? `Output ONLY a short kebab-case git branch name (no explanation, no backticks, no prefix, just the name) that describes this task:`;
|
|
49
|
-
const prompt = `${basePrompt}\n\n${cleanMessage.slice(0, 500)}`;
|
|
50
|
-
const { stdout } = await execFileAsync('claude', ['-p', '--model', 'haiku', prompt], {
|
|
51
|
-
cwd: session.cwd,
|
|
52
|
-
timeout: 30000,
|
|
53
|
-
});
|
|
54
|
-
const branchName = stdout.trim().replace(/`/g, '').replace(/[^a-z0-9-]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase().slice(0, 60);
|
|
55
|
-
if (!branchName)
|
|
56
|
-
return;
|
|
57
|
-
await execFileAsync('git', ['branch', '-m', branchName], { cwd: session.cwd });
|
|
58
|
-
// Update session state
|
|
59
|
-
const displayName = branchToDisplayName(branchName);
|
|
60
|
-
session.branchName = branchName;
|
|
61
|
-
session.displayName = displayName;
|
|
62
|
-
broadcastEvent('session-renamed', {
|
|
63
|
-
sessionId: session.id,
|
|
64
|
-
branchName,
|
|
65
|
-
displayName,
|
|
66
|
-
});
|
|
67
|
-
if (cfgPath) {
|
|
68
|
-
writeMeta(cfgPath, {
|
|
69
|
-
worktreePath: session.repoPath,
|
|
70
|
-
displayName,
|
|
71
|
-
lastActivity: new Date().toISOString(),
|
|
72
|
-
branchName,
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
// Sideband rename is best-effort — fall back to branch watcher if claude CLI isn't available
|
|
78
|
-
if (cfgPath)
|
|
79
|
-
startBranchWatcher(session, broadcastEvent, cfgPath);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
3
|
+
import { trackEvent } from './analytics.js';
|
|
82
4
|
function parseCookies(cookieHeader) {
|
|
83
5
|
const cookies = {};
|
|
84
6
|
if (!cookieHeader)
|
|
@@ -93,7 +15,7 @@ function parseCookies(cookieHeader) {
|
|
|
93
15
|
});
|
|
94
16
|
return cookies;
|
|
95
17
|
}
|
|
96
|
-
function setupWebSocket(server, authenticatedTokens, watcher,
|
|
18
|
+
function setupWebSocket(server, authenticatedTokens, watcher, _configPath) {
|
|
97
19
|
const wss = new WebSocketServer({ noServer: true });
|
|
98
20
|
const eventClients = new Set();
|
|
99
21
|
function broadcastEvent(type, data) {
|
|
@@ -184,30 +106,6 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
|
|
|
184
106
|
}
|
|
185
107
|
}
|
|
186
108
|
catch (_) { }
|
|
187
|
-
// Sideband branch rename: capture first message, pass through unmodified, rename out-of-band
|
|
188
|
-
if (ptySession.needsBranchRename && ptySession.agentState !== 'waiting-for-input') {
|
|
189
|
-
ptySession.pty.write(str);
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
if (ptySession.needsBranchRename) {
|
|
193
|
-
if (!ptySession._renameBuffer)
|
|
194
|
-
ptySession._renameBuffer = '';
|
|
195
|
-
const enterIndex = str.indexOf('\r');
|
|
196
|
-
if (enterIndex === -1) {
|
|
197
|
-
ptySession._renameBuffer += str;
|
|
198
|
-
ptySession.pty.write(str); // pass through to PTY normally — user sees their typing
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
// Enter detected — pass everything through unmodified
|
|
202
|
-
const buffered = ptySession._renameBuffer;
|
|
203
|
-
const firstMessage = buffered + str.slice(0, enterIndex);
|
|
204
|
-
ptySession.pty.write(str); // pass through the Enter key
|
|
205
|
-
ptySession.needsBranchRename = false;
|
|
206
|
-
delete ptySession._renameBuffer;
|
|
207
|
-
// Sideband: spawn headless claude to generate branch name (async, non-blocking)
|
|
208
|
-
spawnBranchRename(ptySession, firstMessage, configPath, broadcastEvent);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
109
|
// Use ptySession.pty dynamically so writes go to current PTY
|
|
212
110
|
ptySession.pty.write(str);
|
|
213
111
|
});
|
|
@@ -221,9 +119,15 @@ function setupWebSocket(server, authenticatedTokens, watcher, configPath) {
|
|
|
221
119
|
});
|
|
222
120
|
sessions.onIdleChange((sessionId, idle) => {
|
|
223
121
|
broadcastEvent('session-idle-changed', { sessionId, idle });
|
|
122
|
+
if (idle) {
|
|
123
|
+
trackEvent({ category: 'agent', action: 'idle', target: sessionId, session_id: sessionId });
|
|
124
|
+
}
|
|
224
125
|
});
|
|
225
126
|
sessions.onStateChange((sessionId, state) => {
|
|
226
127
|
broadcastEvent('session-state-changed', { sessionId, state });
|
|
128
|
+
if (state === 'waiting-for-input') {
|
|
129
|
+
trackEvent({ category: 'agent', action: 'waiting-for-input', target: sessionId, session_id: sessionId });
|
|
130
|
+
}
|
|
227
131
|
});
|
|
228
132
|
sessions.onSessionEnd((sessionId, repoPath, branchName) => {
|
|
229
133
|
broadcastEvent('session-ended', { sessionId, repoPath, branchName });
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { test, before, after, afterEach } 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 Database from 'better-sqlite3';
|
|
7
|
+
import { initAnalytics, closeAnalytics, trackEvent, getDbSize, getDbPath } from '../server/analytics.js';
|
|
8
|
+
let tmpDir;
|
|
9
|
+
before(() => {
|
|
10
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-remote-cli-analytics-test-'));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
closeAnalytics();
|
|
14
|
+
// Clean up DB files between tests
|
|
15
|
+
for (const entry of fs.readdirSync(tmpDir)) {
|
|
16
|
+
fs.unlinkSync(path.join(tmpDir, entry));
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
after(() => {
|
|
20
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
test('initAnalytics creates database and schema', () => {
|
|
23
|
+
initAnalytics(tmpDir);
|
|
24
|
+
const dbPath = getDbPath(tmpDir);
|
|
25
|
+
assert.ok(fs.existsSync(dbPath), 'DB file should exist');
|
|
26
|
+
const db = new Database(dbPath, { readonly: true });
|
|
27
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
|
|
28
|
+
assert.ok(tables.some(t => t.name === 'events'), 'events table should exist');
|
|
29
|
+
db.close();
|
|
30
|
+
});
|
|
31
|
+
test('trackEvent inserts a row', () => {
|
|
32
|
+
initAnalytics(tmpDir);
|
|
33
|
+
trackEvent({
|
|
34
|
+
category: 'session',
|
|
35
|
+
action: 'created',
|
|
36
|
+
target: 'session-123',
|
|
37
|
+
properties: { workspace: '/proj', agent: 'claude' },
|
|
38
|
+
session_id: 'session-123',
|
|
39
|
+
device: 'desktop',
|
|
40
|
+
});
|
|
41
|
+
const db = new Database(getDbPath(tmpDir), { readonly: true });
|
|
42
|
+
const rows = db.prepare('SELECT * FROM events').all();
|
|
43
|
+
assert.equal(rows.length, 1);
|
|
44
|
+
assert.equal(rows[0].category, 'session');
|
|
45
|
+
assert.equal(rows[0].action, 'created');
|
|
46
|
+
assert.equal(rows[0].target, 'session-123');
|
|
47
|
+
assert.equal(rows[0].device, 'desktop');
|
|
48
|
+
const props = JSON.parse(rows[0].properties);
|
|
49
|
+
assert.equal(props.workspace, '/proj');
|
|
50
|
+
assert.equal(props.agent, 'claude');
|
|
51
|
+
db.close();
|
|
52
|
+
});
|
|
53
|
+
test('trackEvent handles optional fields as null', () => {
|
|
54
|
+
initAnalytics(tmpDir);
|
|
55
|
+
trackEvent({ category: 'ui', action: 'click' });
|
|
56
|
+
const db = new Database(getDbPath(tmpDir), { readonly: true });
|
|
57
|
+
const rows = db.prepare('SELECT * FROM events').all();
|
|
58
|
+
assert.equal(rows.length, 1);
|
|
59
|
+
assert.equal(rows[0].target, null);
|
|
60
|
+
assert.equal(rows[0].properties, null);
|
|
61
|
+
assert.equal(rows[0].session_id, null);
|
|
62
|
+
assert.equal(rows[0].device, null);
|
|
63
|
+
db.close();
|
|
64
|
+
});
|
|
65
|
+
test('trackEvent is no-op before initAnalytics', () => {
|
|
66
|
+
// Should not throw
|
|
67
|
+
trackEvent({ category: 'test', action: 'noop' });
|
|
68
|
+
});
|
|
69
|
+
test('getDbSize returns file size after writes', () => {
|
|
70
|
+
initAnalytics(tmpDir);
|
|
71
|
+
const sizeBefore = getDbSize(tmpDir);
|
|
72
|
+
assert.ok(sizeBefore > 0, 'DB file should have non-zero size after init');
|
|
73
|
+
for (let i = 0; i < 10; i++) {
|
|
74
|
+
trackEvent({ category: 'bulk', action: 'test', properties: { i } });
|
|
75
|
+
}
|
|
76
|
+
const sizeAfter = getDbSize(tmpDir);
|
|
77
|
+
assert.ok(sizeAfter >= sizeBefore, 'Size should grow after writes');
|
|
78
|
+
});
|
|
79
|
+
test('getDbSize returns 0 for non-existent path', () => {
|
|
80
|
+
assert.equal(getDbSize('/nonexistent/path'), 0);
|
|
81
|
+
});
|
|
82
|
+
test('initAnalytics is idempotent (schema already exists)', () => {
|
|
83
|
+
initAnalytics(tmpDir);
|
|
84
|
+
trackEvent({ category: 'test', action: 'first' });
|
|
85
|
+
closeAnalytics();
|
|
86
|
+
// Re-init should not throw or lose data
|
|
87
|
+
initAnalytics(tmpDir);
|
|
88
|
+
const db = new Database(getDbPath(tmpDir), { readonly: true });
|
|
89
|
+
const rows = db.prepare('SELECT * FROM events').all();
|
|
90
|
+
assert.equal(rows.length, 1);
|
|
91
|
+
db.close();
|
|
92
|
+
});
|
|
93
|
+
// ── Router endpoint tests ──────────────────────────────────────────────
|
|
94
|
+
// These test the Express Router in isolation (same pattern as fs-browse.test.ts)
|
|
95
|
+
import express from 'express';
|
|
96
|
+
import http from 'node:http';
|
|
97
|
+
import { createAnalyticsRouter } from '../server/analytics.js';
|
|
98
|
+
test('POST /analytics/events batch inserts events', async () => {
|
|
99
|
+
initAnalytics(tmpDir);
|
|
100
|
+
const app = express();
|
|
101
|
+
app.use(express.json());
|
|
102
|
+
app.use('/analytics', createAnalyticsRouter(tmpDir));
|
|
103
|
+
const server = http.createServer(app);
|
|
104
|
+
await new Promise(resolve => server.listen(0, resolve));
|
|
105
|
+
const port = server.address().port;
|
|
106
|
+
const res = await fetch(`http://localhost:${port}/analytics/events`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ events: [
|
|
110
|
+
{ category: 'ui', action: 'click', target: 'test-btn' },
|
|
111
|
+
{ category: 'session', action: 'created' },
|
|
112
|
+
] }),
|
|
113
|
+
});
|
|
114
|
+
const data = await res.json();
|
|
115
|
+
assert.equal(data.ok, true);
|
|
116
|
+
assert.equal(data.count, 2);
|
|
117
|
+
const db = new Database(getDbPath(tmpDir), { readonly: true });
|
|
118
|
+
const rows = db.prepare('SELECT * FROM events').all();
|
|
119
|
+
assert.equal(rows.length, 2);
|
|
120
|
+
db.close();
|
|
121
|
+
server.close();
|
|
122
|
+
});
|
|
123
|
+
test('GET /analytics/size returns bytes', async () => {
|
|
124
|
+
initAnalytics(tmpDir);
|
|
125
|
+
const app = express();
|
|
126
|
+
app.use('/analytics', createAnalyticsRouter(tmpDir));
|
|
127
|
+
const server = http.createServer(app);
|
|
128
|
+
await new Promise(resolve => server.listen(0, resolve));
|
|
129
|
+
const port = server.address().port;
|
|
130
|
+
const res = await fetch(`http://localhost:${port}/analytics/size`);
|
|
131
|
+
const data = await res.json();
|
|
132
|
+
assert.ok(data.bytes > 0);
|
|
133
|
+
server.close();
|
|
134
|
+
});
|
|
135
|
+
test('DELETE /analytics/events clears all events', async () => {
|
|
136
|
+
initAnalytics(tmpDir);
|
|
137
|
+
trackEvent({ category: 'test', action: 'to-delete' });
|
|
138
|
+
const app = express();
|
|
139
|
+
app.use(express.json());
|
|
140
|
+
app.use('/analytics', createAnalyticsRouter(tmpDir));
|
|
141
|
+
const server = http.createServer(app);
|
|
142
|
+
await new Promise(resolve => server.listen(0, resolve));
|
|
143
|
+
const port = server.address().port;
|
|
144
|
+
const res = await fetch(`http://localhost:${port}/analytics/events`, { method: 'DELETE' });
|
|
145
|
+
const data = await res.json();
|
|
146
|
+
assert.equal(data.ok, true);
|
|
147
|
+
const db = new Database(getDbPath(tmpDir), { readonly: true });
|
|
148
|
+
const rows = db.prepare('SELECT * FROM events').all();
|
|
149
|
+
assert.equal(rows.length, 0);
|
|
150
|
+
db.close();
|
|
151
|
+
server.close();
|
|
152
|
+
});
|
|
@@ -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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-remote-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"description": "Remote web interface for Claude Code CLI sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/server/index.js",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"@xterm/addon-fit": "^0.11.0",
|
|
47
47
|
"@xterm/xterm": "^6.0.0",
|
|
48
48
|
"bcrypt": "^5.1.1",
|
|
49
|
+
"better-sqlite3": "^12.8.0",
|
|
49
50
|
"cookie-parser": "^1.4.7",
|
|
50
51
|
"express": "^4.21.0",
|
|
51
52
|
"node-pty": "^1.0.0",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"@playwright/test": "^1.58.2",
|
|
59
60
|
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
|
60
61
|
"@types/bcrypt": "^5.0.2",
|
|
62
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
61
63
|
"@types/cookie-parser": "^1.4.7",
|
|
62
64
|
"@types/express": "^4.17.21",
|
|
63
65
|
"@types/node": "^22.0.0",
|