claude-remote-cli 2.2.2 → 2.3.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/server/index.js +52 -1
- package/dist/test/auth.test.js +41 -0
- package/dist/test/clipboard.test.js +12 -0
- package/dist/test/config.test.js +99 -0
- package/dist/test/paths.test.js +32 -0
- package/dist/test/service.test.js +43 -0
- package/dist/test/sessions.test.js +217 -0
- package/dist/test/version.test.js +34 -0
- package/dist/test/worktrees.test.js +57 -0
- package/package.json +14 -8
- package/config.example.json +0 -11
- package/public/app.js +0 -1953
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +0 -6
- package/public/index.html +0 -221
- package/public/manifest.json +0 -25
- package/public/style.css +0 -1266
- package/public/sw.js +0 -5
- package/public/vendor/addon-fit.js +0 -2
- package/public/vendor/xterm.css +0 -218
- package/public/vendor/xterm.js +0 -2
package/dist/server/index.js
CHANGED
|
@@ -155,7 +155,7 @@ async function main() {
|
|
|
155
155
|
const app = express();
|
|
156
156
|
app.use(express.json({ limit: '15mb' }));
|
|
157
157
|
app.use(cookieParser());
|
|
158
|
-
app.use(express.static(path.join(__dirname, '..', '
|
|
158
|
+
app.use(express.static(path.join(__dirname, '..', 'frontend')));
|
|
159
159
|
const requireAuth = (req, res, next) => {
|
|
160
160
|
const token = req.cookies && req.cookies.token;
|
|
161
161
|
if (!token || !authenticatedTokens.has(token)) {
|
|
@@ -236,6 +236,57 @@ async function main() {
|
|
|
236
236
|
res.json([]);
|
|
237
237
|
}
|
|
238
238
|
});
|
|
239
|
+
// GET /git-status?repo=<path>&branch=<name>
|
|
240
|
+
app.get('/git-status', requireAuth, async (req, res) => {
|
|
241
|
+
const repoPath = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
242
|
+
const branch = typeof req.query.branch === 'string' ? req.query.branch : undefined;
|
|
243
|
+
if (!repoPath || !branch) {
|
|
244
|
+
res.status(400).json({ error: 'repo and branch query parameters are required' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
let prState = null;
|
|
248
|
+
let additions = 0;
|
|
249
|
+
let deletions = 0;
|
|
250
|
+
// Try gh CLI for PR status
|
|
251
|
+
try {
|
|
252
|
+
const { stdout } = await execFileAsync('gh', [
|
|
253
|
+
'pr', 'view', branch,
|
|
254
|
+
'--json', 'state,additions,deletions',
|
|
255
|
+
], { cwd: repoPath });
|
|
256
|
+
const data = JSON.parse(stdout);
|
|
257
|
+
if (data.state)
|
|
258
|
+
prState = data.state.toLowerCase();
|
|
259
|
+
if (typeof data.additions === 'number')
|
|
260
|
+
additions = data.additions;
|
|
261
|
+
if (typeof data.deletions === 'number')
|
|
262
|
+
deletions = data.deletions;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// No PR or gh not available — fall back to git diff against default branch
|
|
266
|
+
try {
|
|
267
|
+
// Detect default branch (main, master, etc.)
|
|
268
|
+
let baseBranch = 'main';
|
|
269
|
+
try {
|
|
270
|
+
const { stdout: headRef } = await execFileAsync('git', [
|
|
271
|
+
'symbolic-ref', 'refs/remotes/origin/HEAD', '--short',
|
|
272
|
+
], { cwd: repoPath });
|
|
273
|
+
baseBranch = headRef.trim().replace(/^origin\//, '');
|
|
274
|
+
}
|
|
275
|
+
catch { /* use main as fallback */ }
|
|
276
|
+
const { stdout } = await execFileAsync('git', [
|
|
277
|
+
'diff', '--shortstat', baseBranch + '...' + branch,
|
|
278
|
+
], { cwd: repoPath });
|
|
279
|
+
const addMatch = stdout.match(/(\d+) insertion/);
|
|
280
|
+
const delMatch = stdout.match(/(\d+) deletion/);
|
|
281
|
+
if (addMatch)
|
|
282
|
+
additions = parseInt(addMatch[1], 10);
|
|
283
|
+
if (delMatch)
|
|
284
|
+
deletions = parseInt(delMatch[1], 10);
|
|
285
|
+
}
|
|
286
|
+
catch { /* no diff data */ }
|
|
287
|
+
}
|
|
288
|
+
res.json({ prState, additions, deletions });
|
|
289
|
+
});
|
|
239
290
|
// GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
|
|
240
291
|
app.get('/worktrees', requireAuth, (req, res) => {
|
|
241
292
|
const repoParam = typeof req.query.repo === 'string' ? req.query.repo : undefined;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { hashPin, verifyPin, isRateLimited, recordFailedAttempt, generateCookieToken, _resetForTesting, } from '../server/auth.js';
|
|
4
|
+
test('hashPin returns bcrypt hash starting with $2b$', async () => {
|
|
5
|
+
_resetForTesting();
|
|
6
|
+
const hash = await hashPin('1234');
|
|
7
|
+
assert.ok(hash.startsWith('$2b$'), `Expected hash to start with $2b$, got: ${hash}`);
|
|
8
|
+
});
|
|
9
|
+
test('verifyPin returns true for correct PIN', async () => {
|
|
10
|
+
_resetForTesting();
|
|
11
|
+
const hash = await hashPin('1234');
|
|
12
|
+
const result = await verifyPin('1234', hash);
|
|
13
|
+
assert.strictEqual(result, true);
|
|
14
|
+
});
|
|
15
|
+
test('verifyPin returns false for wrong PIN', async () => {
|
|
16
|
+
_resetForTesting();
|
|
17
|
+
const hash = await hashPin('1234');
|
|
18
|
+
const result = await verifyPin('9999', hash);
|
|
19
|
+
assert.strictEqual(result, false);
|
|
20
|
+
});
|
|
21
|
+
test('rate limiter blocks after 5 failures', () => {
|
|
22
|
+
_resetForTesting();
|
|
23
|
+
const ip = '127.0.0.1';
|
|
24
|
+
for (let i = 0; i < 5; i++) {
|
|
25
|
+
recordFailedAttempt(ip);
|
|
26
|
+
}
|
|
27
|
+
assert.strictEqual(isRateLimited(ip), true);
|
|
28
|
+
});
|
|
29
|
+
test('rate limiter allows under threshold', () => {
|
|
30
|
+
_resetForTesting();
|
|
31
|
+
const ip = '127.0.0.1';
|
|
32
|
+
for (let i = 0; i < 4; i++) {
|
|
33
|
+
recordFailedAttempt(ip);
|
|
34
|
+
}
|
|
35
|
+
assert.strictEqual(isRateLimited(ip), false);
|
|
36
|
+
});
|
|
37
|
+
test('generateCookieToken returns non-empty string', () => {
|
|
38
|
+
_resetForTesting();
|
|
39
|
+
const token = generateCookieToken();
|
|
40
|
+
assert.ok(typeof token === 'string' && token.length > 0);
|
|
41
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { detectClipboardTool, setClipboardImage } from '../server/clipboard.js';
|
|
4
|
+
describe('clipboard', () => {
|
|
5
|
+
it('detectClipboardTool returns a string or null', () => {
|
|
6
|
+
const result = detectClipboardTool();
|
|
7
|
+
assert.ok(result === null || typeof result === 'string');
|
|
8
|
+
});
|
|
9
|
+
it('setClipboardImage rejects unsupported mime types', async () => {
|
|
10
|
+
await assert.rejects(() => setClipboardImage('/tmp/test.txt', 'text/plain'), /Unsupported/);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { DEFAULTS, loadConfig, saveConfig, ensureMetaDir, readMeta, writeMeta, deleteMeta } from '../server/config.js';
|
|
7
|
+
let tmpDir;
|
|
8
|
+
before(() => {
|
|
9
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-remote-cli-config-test-'));
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
for (const entry of fs.readdirSync(tmpDir, { withFileTypes: true })) {
|
|
13
|
+
const fullPath = path.join(tmpDir, entry.name);
|
|
14
|
+
if (entry.isDirectory()) {
|
|
15
|
+
fs.rmSync(fullPath, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
fs.unlinkSync(fullPath);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
after(() => {
|
|
23
|
+
fs.rmdirSync(tmpDir);
|
|
24
|
+
});
|
|
25
|
+
test('loadConfig loads a JSON config file', () => {
|
|
26
|
+
const configPath = path.join(tmpDir, 'config.json');
|
|
27
|
+
const data = { port: 4000, host: '127.0.0.1' };
|
|
28
|
+
fs.writeFileSync(configPath, JSON.stringify(data), 'utf8');
|
|
29
|
+
const config = loadConfig(configPath);
|
|
30
|
+
assert.equal(config.port, 4000);
|
|
31
|
+
assert.equal(config.host, '127.0.0.1');
|
|
32
|
+
});
|
|
33
|
+
test('loadConfig merges with defaults for missing fields', () => {
|
|
34
|
+
const configPath = path.join(tmpDir, 'config.json');
|
|
35
|
+
fs.writeFileSync(configPath, JSON.stringify({ port: 9000 }), 'utf8');
|
|
36
|
+
const config = loadConfig(configPath);
|
|
37
|
+
assert.equal(config.port, 9000);
|
|
38
|
+
assert.equal(config.host, DEFAULTS.host);
|
|
39
|
+
assert.equal(config.cookieTTL, DEFAULTS.cookieTTL);
|
|
40
|
+
assert.deepEqual(config.repos, DEFAULTS.repos);
|
|
41
|
+
assert.equal(config.claudeCommand, DEFAULTS.claudeCommand);
|
|
42
|
+
assert.deepEqual(config.claudeArgs, DEFAULTS.claudeArgs);
|
|
43
|
+
});
|
|
44
|
+
test('loadConfig throws if config file not found', () => {
|
|
45
|
+
const configPath = path.join(tmpDir, 'nonexistent.json');
|
|
46
|
+
assert.throws(() => loadConfig(configPath), /Config file not found/);
|
|
47
|
+
});
|
|
48
|
+
test('saveConfig writes JSON with 2-space indent', () => {
|
|
49
|
+
const configPath = path.join(tmpDir, 'output.json');
|
|
50
|
+
const config = { port: 3456, host: '0.0.0.0' };
|
|
51
|
+
saveConfig(configPath, config);
|
|
52
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
53
|
+
assert.equal(raw, JSON.stringify(config, null, 2));
|
|
54
|
+
});
|
|
55
|
+
test('DEFAULTS has expected keys and values', () => {
|
|
56
|
+
assert.equal(DEFAULTS.host, '0.0.0.0');
|
|
57
|
+
assert.equal(DEFAULTS.port, 3456);
|
|
58
|
+
assert.equal(DEFAULTS.cookieTTL, '24h');
|
|
59
|
+
assert.deepEqual(DEFAULTS.repos, []);
|
|
60
|
+
assert.equal(DEFAULTS.claudeCommand, 'claude');
|
|
61
|
+
assert.deepEqual(DEFAULTS.claudeArgs, []);
|
|
62
|
+
});
|
|
63
|
+
test('ensureMetaDir creates worktree-meta directory', () => {
|
|
64
|
+
const configPath = path.join(tmpDir, 'config.json');
|
|
65
|
+
ensureMetaDir(configPath);
|
|
66
|
+
const metaPath = path.join(tmpDir, 'worktree-meta');
|
|
67
|
+
assert.ok(fs.existsSync(metaPath));
|
|
68
|
+
});
|
|
69
|
+
test('writeMeta creates and readMeta reads metadata file', () => {
|
|
70
|
+
const configPath = path.join(tmpDir, 'config.json');
|
|
71
|
+
const meta = { worktreePath: '/tmp/test-worktree', displayName: 'My Feature', lastActivity: '2026-02-22T00:00:00.000Z' };
|
|
72
|
+
writeMeta(configPath, meta);
|
|
73
|
+
const read = readMeta(configPath, '/tmp/test-worktree');
|
|
74
|
+
assert.deepEqual(read, meta);
|
|
75
|
+
});
|
|
76
|
+
test('readMeta returns null for non-existent metadata', () => {
|
|
77
|
+
const configPath = path.join(tmpDir, 'config.json');
|
|
78
|
+
const result = readMeta(configPath, '/no/such/worktree');
|
|
79
|
+
assert.equal(result, null);
|
|
80
|
+
});
|
|
81
|
+
test('writeMeta overwrites existing metadata', () => {
|
|
82
|
+
const configPath = path.join(tmpDir, 'config.json');
|
|
83
|
+
writeMeta(configPath, { worktreePath: '/tmp/wt', displayName: 'Old Name', lastActivity: '2026-01-01T00:00:00.000Z' });
|
|
84
|
+
writeMeta(configPath, { worktreePath: '/tmp/wt', displayName: 'New Name', lastActivity: '2026-02-22T00:00:00.000Z' });
|
|
85
|
+
const read = readMeta(configPath, '/tmp/wt');
|
|
86
|
+
assert.equal(read.displayName, 'New Name');
|
|
87
|
+
assert.equal(read.lastActivity, '2026-02-22T00:00:00.000Z');
|
|
88
|
+
});
|
|
89
|
+
test('deleteMeta removes metadata file', () => {
|
|
90
|
+
const configPath = path.join(tmpDir, 'config.json');
|
|
91
|
+
writeMeta(configPath, { worktreePath: '/tmp/del-test', displayName: 'To Delete', lastActivity: '2026-02-22T00:00:00.000Z' });
|
|
92
|
+
assert.ok(readMeta(configPath, '/tmp/del-test'));
|
|
93
|
+
deleteMeta(configPath, '/tmp/del-test');
|
|
94
|
+
assert.equal(readMeta(configPath, '/tmp/del-test'), null);
|
|
95
|
+
});
|
|
96
|
+
test('deleteMeta is a no-op for non-existent metadata', () => {
|
|
97
|
+
const configPath = path.join(tmpDir, 'config.json');
|
|
98
|
+
assert.doesNotThrow(() => deleteMeta(configPath, '/no/such/path'));
|
|
99
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
// At runtime, __dirname resolves to dist/test/.
|
|
7
|
+
// The server runs from dist/server/, which uses path.join(__dirname, '..', '..', ...)
|
|
8
|
+
// to reach the project root. This test verifies that relationship is correct.
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
// This test file is at dist/test/, server is at dist/server/ — same depth
|
|
12
|
+
const projectRoot = path.resolve(__dirname, '..', '..');
|
|
13
|
+
test('project root from dist/ contains frontend/ directory', () => {
|
|
14
|
+
const frontendDir = path.join(projectRoot, 'frontend');
|
|
15
|
+
assert.ok(fs.existsSync(frontendDir), `Expected frontend/ at ${frontendDir}`);
|
|
16
|
+
});
|
|
17
|
+
test('project root from dist/ contains frontend/index.html', () => {
|
|
18
|
+
const indexHtml = path.join(projectRoot, 'frontend', 'index.html');
|
|
19
|
+
assert.ok(fs.existsSync(indexHtml), `Expected frontend/index.html at ${indexHtml}`);
|
|
20
|
+
});
|
|
21
|
+
test('dist/server/ exists after compilation', () => {
|
|
22
|
+
const serverDir = path.join(projectRoot, 'dist', 'server');
|
|
23
|
+
assert.ok(fs.existsSync(serverDir), `Expected dist/server/ at ${serverDir}`);
|
|
24
|
+
});
|
|
25
|
+
test('server index.ts uses correct path depth to reach dist/frontend/', async () => {
|
|
26
|
+
// Read the source file and verify the path pattern
|
|
27
|
+
const indexSource = fs.readFileSync(path.join(projectRoot, 'server', 'index.ts'), 'utf8');
|
|
28
|
+
// Static serving must go up one level from dist/server/ to dist/, then into frontend/
|
|
29
|
+
assert.ok(indexSource.includes("path.join(__dirname, '..', 'frontend')"), "express.static must resolve dist/frontend/ one level up from dist/server/");
|
|
30
|
+
// Config fallback must also go up two levels
|
|
31
|
+
assert.ok(indexSource.includes("path.join(__dirname, '..', '..', 'config.json')"), 'CONFIG_PATH must resolve config.json two levels up from dist/server/');
|
|
32
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as service from '../server/service.js';
|
|
4
|
+
test('getPlatform returns macos or linux', () => {
|
|
5
|
+
const platform = service.getPlatform();
|
|
6
|
+
assert.ok(platform === 'macos' || platform === 'linux', 'Expected macos or linux, got ' + platform);
|
|
7
|
+
});
|
|
8
|
+
test('getServicePaths returns expected keys', () => {
|
|
9
|
+
const paths = service.getServicePaths();
|
|
10
|
+
assert.ok(paths.servicePath, 'missing servicePath');
|
|
11
|
+
assert.equal(typeof paths.label, 'string', 'label should be a string');
|
|
12
|
+
assert.ok('logDir' in paths, 'missing logDir key');
|
|
13
|
+
});
|
|
14
|
+
test('generateServiceFile for macos contains plist XML', () => {
|
|
15
|
+
const content = service.generateServiceFile('macos', {
|
|
16
|
+
nodePath: '/usr/local/bin/node',
|
|
17
|
+
scriptPath: '/usr/local/lib/node_modules/claude-remote-cli/bin/claude-remote-cli.js',
|
|
18
|
+
configPath: '/Users/test/.config/claude-remote-cli/config.json',
|
|
19
|
+
port: '3456',
|
|
20
|
+
host: '0.0.0.0',
|
|
21
|
+
logDir: '/Users/test/.config/claude-remote-cli/logs',
|
|
22
|
+
});
|
|
23
|
+
assert.match(content, /<!DOCTYPE plist/, 'should be plist XML');
|
|
24
|
+
assert.match(content, /com\.claude-remote-cli/, 'should have label');
|
|
25
|
+
assert.match(content, /RunAtLoad/, 'should have RunAtLoad');
|
|
26
|
+
assert.match(content, /KeepAlive/, 'should have KeepAlive');
|
|
27
|
+
assert.match(content, /3456/, 'should include port');
|
|
28
|
+
});
|
|
29
|
+
test('generateServiceFile for linux contains systemd unit', () => {
|
|
30
|
+
const content = service.generateServiceFile('linux', {
|
|
31
|
+
nodePath: '/usr/bin/node',
|
|
32
|
+
scriptPath: '/usr/lib/node_modules/claude-remote-cli/bin/claude-remote-cli.js',
|
|
33
|
+
configPath: '/home/test/.config/claude-remote-cli/config.json',
|
|
34
|
+
port: '3456',
|
|
35
|
+
host: '0.0.0.0',
|
|
36
|
+
logDir: null,
|
|
37
|
+
});
|
|
38
|
+
assert.match(content, /\[Unit\]/, 'should have Unit section');
|
|
39
|
+
assert.match(content, /\[Service\]/, 'should have Service section');
|
|
40
|
+
assert.match(content, /\[Install\]/, 'should have Install section');
|
|
41
|
+
assert.match(content, /Restart=on-failure/, 'should restart on failure');
|
|
42
|
+
assert.match(content, /3456/, 'should include port');
|
|
43
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, it, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import * as sessions from '../server/sessions.js';
|
|
4
|
+
// Track created session IDs so we can clean up after each test
|
|
5
|
+
const createdIds = [];
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
// Kill any remaining sessions created during tests
|
|
8
|
+
for (const id of createdIds) {
|
|
9
|
+
try {
|
|
10
|
+
const session = sessions.get(id);
|
|
11
|
+
if (session) {
|
|
12
|
+
sessions.kill(id);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Already killed or exited, ignore
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
createdIds.length = 0;
|
|
20
|
+
});
|
|
21
|
+
describe('sessions', () => {
|
|
22
|
+
it('list returns empty array initially', () => {
|
|
23
|
+
const result = sessions.list();
|
|
24
|
+
assert.ok(Array.isArray(result));
|
|
25
|
+
assert.strictEqual(result.length, 0);
|
|
26
|
+
});
|
|
27
|
+
it('create spawns PTY and adds session to registry', () => {
|
|
28
|
+
const result = sessions.create({
|
|
29
|
+
repoName: 'test-repo',
|
|
30
|
+
repoPath: '/tmp',
|
|
31
|
+
command: '/bin/echo',
|
|
32
|
+
args: ['hello'],
|
|
33
|
+
cols: 80,
|
|
34
|
+
rows: 24,
|
|
35
|
+
});
|
|
36
|
+
createdIds.push(result.id);
|
|
37
|
+
assert.ok(result.id, 'should have an id');
|
|
38
|
+
assert.strictEqual(result.repoName, 'test-repo');
|
|
39
|
+
assert.strictEqual(result.repoPath, '/tmp');
|
|
40
|
+
assert.ok(typeof result.pid === 'number', 'should have a numeric pid');
|
|
41
|
+
assert.ok(result.createdAt, 'should have a createdAt timestamp');
|
|
42
|
+
assert.strictEqual('pty' in result, false, 'should not expose pty object');
|
|
43
|
+
const list = sessions.list();
|
|
44
|
+
assert.strictEqual(list.length, 1);
|
|
45
|
+
assert.strictEqual(list[0]?.id, result.id);
|
|
46
|
+
});
|
|
47
|
+
it('get returns session by id', () => {
|
|
48
|
+
const result = sessions.create({
|
|
49
|
+
repoName: 'test-repo',
|
|
50
|
+
repoPath: '/tmp',
|
|
51
|
+
command: '/bin/echo',
|
|
52
|
+
args: ['hello'],
|
|
53
|
+
});
|
|
54
|
+
createdIds.push(result.id);
|
|
55
|
+
const session = sessions.get(result.id);
|
|
56
|
+
assert.ok(session, 'should return the session');
|
|
57
|
+
assert.strictEqual(session.id, result.id);
|
|
58
|
+
assert.strictEqual(session.repoName, 'test-repo');
|
|
59
|
+
assert.ok(session.pty, 'get should include the pty object');
|
|
60
|
+
});
|
|
61
|
+
it('get returns undefined for nonexistent id', () => {
|
|
62
|
+
const session = sessions.get('nonexistent-id-12345');
|
|
63
|
+
assert.strictEqual(session, undefined);
|
|
64
|
+
});
|
|
65
|
+
it('kill removes session from registry', () => {
|
|
66
|
+
const result = sessions.create({
|
|
67
|
+
repoName: 'test-repo',
|
|
68
|
+
repoPath: '/tmp',
|
|
69
|
+
command: '/bin/echo',
|
|
70
|
+
args: ['hello'],
|
|
71
|
+
});
|
|
72
|
+
createdIds.push(result.id);
|
|
73
|
+
sessions.kill(result.id);
|
|
74
|
+
// Remove from tracking since it's already killed
|
|
75
|
+
createdIds.splice(createdIds.indexOf(result.id), 1);
|
|
76
|
+
const session = sessions.get(result.id);
|
|
77
|
+
assert.strictEqual(session, undefined, 'session should be removed after kill');
|
|
78
|
+
const list = sessions.list();
|
|
79
|
+
assert.ok(!list.some((s) => s.id === result.id), 'killed session should not appear in list');
|
|
80
|
+
});
|
|
81
|
+
it('kill throws for nonexistent session', () => {
|
|
82
|
+
assert.throws(() => sessions.kill('nonexistent-id'), /Session not found/);
|
|
83
|
+
});
|
|
84
|
+
it('resize throws for nonexistent session', () => {
|
|
85
|
+
assert.throws(() => sessions.resize('nonexistent-id', 100, 40), /Session not found/);
|
|
86
|
+
});
|
|
87
|
+
it('write sends data to PTY stdin', (_, done) => {
|
|
88
|
+
const result = sessions.create({
|
|
89
|
+
repoName: 'test-repo',
|
|
90
|
+
repoPath: '/tmp',
|
|
91
|
+
command: '/bin/cat',
|
|
92
|
+
args: [],
|
|
93
|
+
cols: 80,
|
|
94
|
+
rows: 24,
|
|
95
|
+
});
|
|
96
|
+
createdIds.push(result.id);
|
|
97
|
+
const session = sessions.get(result.id);
|
|
98
|
+
assert.ok(session);
|
|
99
|
+
let output = '';
|
|
100
|
+
session.pty.onData((data) => {
|
|
101
|
+
output += data;
|
|
102
|
+
if (output.includes('hello')) {
|
|
103
|
+
done();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
sessions.write(result.id, 'hello');
|
|
107
|
+
});
|
|
108
|
+
it('write throws for nonexistent session', () => {
|
|
109
|
+
assert.throws(() => sessions.write('nonexistent-id', 'data'), /Session not found/);
|
|
110
|
+
});
|
|
111
|
+
it('session starts as not idle', () => {
|
|
112
|
+
const result = sessions.create({
|
|
113
|
+
repoName: 'test-repo',
|
|
114
|
+
repoPath: '/tmp',
|
|
115
|
+
command: '/bin/cat',
|
|
116
|
+
args: [],
|
|
117
|
+
});
|
|
118
|
+
createdIds.push(result.id);
|
|
119
|
+
const session = sessions.get(result.id);
|
|
120
|
+
assert.ok(session);
|
|
121
|
+
assert.strictEqual(session.idle, false);
|
|
122
|
+
});
|
|
123
|
+
it('list includes idle field', () => {
|
|
124
|
+
const result = sessions.create({
|
|
125
|
+
repoName: 'test-repo',
|
|
126
|
+
repoPath: '/tmp',
|
|
127
|
+
command: '/bin/cat',
|
|
128
|
+
args: [],
|
|
129
|
+
});
|
|
130
|
+
createdIds.push(result.id);
|
|
131
|
+
const list = sessions.list();
|
|
132
|
+
assert.strictEqual(list.length, 1);
|
|
133
|
+
assert.strictEqual(list[0]?.idle, false);
|
|
134
|
+
});
|
|
135
|
+
it('type defaults to worktree when not specified', () => {
|
|
136
|
+
const result = sessions.create({
|
|
137
|
+
repoName: 'test-repo',
|
|
138
|
+
repoPath: '/tmp',
|
|
139
|
+
command: '/bin/echo',
|
|
140
|
+
args: ['hello'],
|
|
141
|
+
});
|
|
142
|
+
createdIds.push(result.id);
|
|
143
|
+
assert.strictEqual(result.type, 'worktree');
|
|
144
|
+
const session = sessions.get(result.id);
|
|
145
|
+
assert.ok(session);
|
|
146
|
+
assert.strictEqual(session.type, 'worktree');
|
|
147
|
+
});
|
|
148
|
+
it('type is set to repo when specified', () => {
|
|
149
|
+
const result = sessions.create({
|
|
150
|
+
type: 'repo',
|
|
151
|
+
repoName: 'test-repo',
|
|
152
|
+
repoPath: '/tmp',
|
|
153
|
+
command: '/bin/echo',
|
|
154
|
+
args: ['hello'],
|
|
155
|
+
});
|
|
156
|
+
createdIds.push(result.id);
|
|
157
|
+
assert.strictEqual(result.type, 'repo');
|
|
158
|
+
const session = sessions.get(result.id);
|
|
159
|
+
assert.ok(session);
|
|
160
|
+
assert.strictEqual(session.type, 'repo');
|
|
161
|
+
});
|
|
162
|
+
it('list includes type field', () => {
|
|
163
|
+
const r1 = sessions.create({
|
|
164
|
+
type: 'repo',
|
|
165
|
+
repoName: 'repo-a',
|
|
166
|
+
repoPath: '/tmp/a',
|
|
167
|
+
command: '/bin/echo',
|
|
168
|
+
args: ['hello'],
|
|
169
|
+
});
|
|
170
|
+
createdIds.push(r1.id);
|
|
171
|
+
const r2 = sessions.create({
|
|
172
|
+
type: 'worktree',
|
|
173
|
+
repoName: 'repo-b',
|
|
174
|
+
repoPath: '/tmp/b',
|
|
175
|
+
command: '/bin/echo',
|
|
176
|
+
args: ['hello'],
|
|
177
|
+
});
|
|
178
|
+
createdIds.push(r2.id);
|
|
179
|
+
const list = sessions.list();
|
|
180
|
+
const repoSession = list.find(function (s) { return s.id === r1.id; });
|
|
181
|
+
const wtSession = list.find(function (s) { return s.id === r2.id; });
|
|
182
|
+
assert.ok(repoSession);
|
|
183
|
+
assert.strictEqual(repoSession.type, 'repo');
|
|
184
|
+
assert.ok(wtSession);
|
|
185
|
+
assert.strictEqual(wtSession.type, 'worktree');
|
|
186
|
+
});
|
|
187
|
+
it('findRepoSession returns undefined when no repo sessions exist', () => {
|
|
188
|
+
const result = sessions.findRepoSession('/tmp');
|
|
189
|
+
assert.strictEqual(result, undefined);
|
|
190
|
+
});
|
|
191
|
+
it('findRepoSession returns repo session matching repoPath', () => {
|
|
192
|
+
const created = sessions.create({
|
|
193
|
+
type: 'repo',
|
|
194
|
+
repoName: 'test-repo',
|
|
195
|
+
repoPath: '/tmp/my-repo',
|
|
196
|
+
command: '/bin/echo',
|
|
197
|
+
args: ['hello'],
|
|
198
|
+
});
|
|
199
|
+
createdIds.push(created.id);
|
|
200
|
+
const found = sessions.findRepoSession('/tmp/my-repo');
|
|
201
|
+
assert.ok(found, 'should find the repo session');
|
|
202
|
+
assert.strictEqual(found.id, created.id);
|
|
203
|
+
assert.strictEqual(found.type, 'repo');
|
|
204
|
+
});
|
|
205
|
+
it('findRepoSession ignores worktree sessions at same path', () => {
|
|
206
|
+
const created = sessions.create({
|
|
207
|
+
type: 'worktree',
|
|
208
|
+
repoName: 'test-repo',
|
|
209
|
+
repoPath: '/tmp/my-repo',
|
|
210
|
+
command: '/bin/echo',
|
|
211
|
+
args: ['hello'],
|
|
212
|
+
});
|
|
213
|
+
createdIds.push(created.id);
|
|
214
|
+
const found = sessions.findRepoSession('/tmp/my-repo');
|
|
215
|
+
assert.strictEqual(found, undefined, 'should not match worktree sessions');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
function semverLessThan(a, b) {
|
|
4
|
+
const pa = a.split('.').map(Number);
|
|
5
|
+
const pb = b.split('.').map(Number);
|
|
6
|
+
for (let i = 0; i < 3; i++) {
|
|
7
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0))
|
|
8
|
+
return true;
|
|
9
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0))
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
test('semverLessThan returns true when major is lower', () => {
|
|
15
|
+
assert.equal(semverLessThan('1.0.0', '2.0.0'), true);
|
|
16
|
+
});
|
|
17
|
+
test('semverLessThan returns true when minor is lower', () => {
|
|
18
|
+
assert.equal(semverLessThan('1.1.0', '1.2.0'), true);
|
|
19
|
+
});
|
|
20
|
+
test('semverLessThan returns true when patch is lower', () => {
|
|
21
|
+
assert.equal(semverLessThan('1.1.1', '1.1.2'), true);
|
|
22
|
+
});
|
|
23
|
+
test('semverLessThan returns false for equal versions', () => {
|
|
24
|
+
assert.equal(semverLessThan('1.1.1', '1.1.1'), false);
|
|
25
|
+
});
|
|
26
|
+
test('semverLessThan returns false when current is greater', () => {
|
|
27
|
+
assert.equal(semverLessThan('2.0.0', '1.9.9'), false);
|
|
28
|
+
});
|
|
29
|
+
test('semverLessThan handles major version jumps', () => {
|
|
30
|
+
assert.equal(semverLessThan('1.9.9', '2.0.0'), true);
|
|
31
|
+
});
|
|
32
|
+
test('semverLessThan handles two-segment versions gracefully', () => {
|
|
33
|
+
assert.equal(semverLessThan('1.0', '1.1'), true);
|
|
34
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { WORKTREE_DIRS, isValidWorktreePath } from '../server/watcher.js';
|
|
4
|
+
describe('worktree directories constant', () => {
|
|
5
|
+
it('should include both .worktrees and .claude/worktrees', () => {
|
|
6
|
+
assert.deepEqual(WORKTREE_DIRS, ['.worktrees', '.claude/worktrees']);
|
|
7
|
+
});
|
|
8
|
+
});
|
|
9
|
+
describe('isValidWorktreePath', () => {
|
|
10
|
+
it('should reject paths not inside any worktree directory', () => {
|
|
11
|
+
assert.equal(isValidWorktreePath('/some/random/path'), false);
|
|
12
|
+
});
|
|
13
|
+
it('should accept paths inside .worktrees/', () => {
|
|
14
|
+
assert.equal(isValidWorktreePath('/Users/me/code/repo/.worktrees/my-worktree'), true);
|
|
15
|
+
});
|
|
16
|
+
it('should accept paths inside .claude/worktrees/', () => {
|
|
17
|
+
assert.equal(isValidWorktreePath('/Users/me/code/repo/.claude/worktrees/my-worktree'), true);
|
|
18
|
+
});
|
|
19
|
+
it('should not match partial .worktrees paths', () => {
|
|
20
|
+
assert.equal(isValidWorktreePath('/Users/me/.worktrees-fake/foo'), false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe('branch name to directory name', () => {
|
|
24
|
+
it('should replace slashes with dashes', () => {
|
|
25
|
+
const branchName = 'dy/feat/my-feature';
|
|
26
|
+
const dirName = branchName.replace(/\//g, '-');
|
|
27
|
+
assert.equal(dirName, 'dy-feat-my-feature');
|
|
28
|
+
});
|
|
29
|
+
it('should leave flat branch names unchanged', () => {
|
|
30
|
+
const branchName = 'my-feature';
|
|
31
|
+
const dirName = branchName.replace(/\//g, '-');
|
|
32
|
+
assert.equal(dirName, 'my-feature');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('CLI worktree arg parsing', () => {
|
|
36
|
+
it('should extract --yolo and leave other args intact', () => {
|
|
37
|
+
const args = ['add', './.worktrees/my-feature', '-b', 'my-feature', '--yolo'];
|
|
38
|
+
const hasYolo = args.includes('--yolo');
|
|
39
|
+
const gitArgs = args.filter(a => a !== '--yolo');
|
|
40
|
+
assert.equal(hasYolo, true);
|
|
41
|
+
assert.deepEqual(gitArgs, ['add', './.worktrees/my-feature', '-b', 'my-feature']);
|
|
42
|
+
});
|
|
43
|
+
it('should detect missing path for add and use default', () => {
|
|
44
|
+
// args: ['add', '-b', 'my-feature'] — no positional path (first arg after 'add' starts with '-')
|
|
45
|
+
const args = ['add', '-b', 'my-feature'];
|
|
46
|
+
const subArgs = args.slice(1); // after 'add'
|
|
47
|
+
const hasPositionalPath = subArgs.length > 0 && !subArgs[0].startsWith('-');
|
|
48
|
+
assert.equal(hasPositionalPath, false);
|
|
49
|
+
});
|
|
50
|
+
it('should detect path when provided for add', () => {
|
|
51
|
+
const args = ['add', './my-path', '-b', 'my-feature'];
|
|
52
|
+
const subArgs = args.slice(1);
|
|
53
|
+
const hasPositionalPath = subArgs.length > 0 && !subArgs[0].startsWith('-');
|
|
54
|
+
assert.equal(hasPositionalPath, true);
|
|
55
|
+
assert.equal(subArgs[0], './my-path');
|
|
56
|
+
});
|
|
57
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-remote-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Remote web interface for Claude Code CLI sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/server/index.js",
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"claude-remote-cli": "dist/bin/claude-remote-cli.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"dist/
|
|
12
|
-
"dist/server/",
|
|
13
|
-
"public/",
|
|
14
|
-
"config.example.json"
|
|
11
|
+
"dist/"
|
|
15
12
|
],
|
|
16
13
|
"scripts": {
|
|
17
|
-
"build": "tsc",
|
|
18
|
-
"
|
|
14
|
+
"build": "tsc && vite build --config frontend/vite.config.ts",
|
|
15
|
+
"build:server": "tsc",
|
|
16
|
+
"build:frontend": "vite build --config frontend/vite.config.ts",
|
|
17
|
+
"dev": "vite --config frontend/vite.config.ts",
|
|
18
|
+
"start": "tsc && vite build --config frontend/vite.config.ts && node dist/server/index.js",
|
|
19
19
|
"test": "tsc -p tsconfig.test.json && node --test dist/test/*.test.js",
|
|
20
20
|
"postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper 2>/dev/null || true"
|
|
21
21
|
},
|
|
@@ -39,20 +39,26 @@
|
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"author": "Donovan Yohan",
|
|
41
41
|
"dependencies": {
|
|
42
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
43
|
+
"@xterm/xterm": "^6.0.0",
|
|
42
44
|
"bcrypt": "^5.1.1",
|
|
43
45
|
"cookie-parser": "^1.4.7",
|
|
44
46
|
"express": "^4.21.0",
|
|
45
47
|
"node-pty": "^1.0.0",
|
|
48
|
+
"svelte": "^5.53.3",
|
|
46
49
|
"ws": "^8.18.0"
|
|
47
50
|
},
|
|
48
51
|
"devDependencies": {
|
|
49
52
|
"@playwright/test": "^1.58.2",
|
|
53
|
+
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
|
50
54
|
"@types/bcrypt": "^5.0.2",
|
|
51
55
|
"@types/cookie-parser": "^1.4.7",
|
|
52
56
|
"@types/express": "^4.17.21",
|
|
53
57
|
"@types/node": "^22.0.0",
|
|
54
58
|
"@types/ws": "^8.5.13",
|
|
55
59
|
"playwright": "^1.58.2",
|
|
56
|
-
"
|
|
60
|
+
"svelte-check": "^4.4.3",
|
|
61
|
+
"typescript": "^5.7.0",
|
|
62
|
+
"vite": "^6.4.1"
|
|
57
63
|
}
|
|
58
64
|
}
|