Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

ai-cli-online

Package Overview
Dependencies
Maintainers
1
Versions
42
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ai-cli-online - npm Package Compare versions

Comparing version
3.0.13
to
3.0.15
+2
server/dist/routes/git.d.ts
declare const router: import("express-serve-static-core").Router;
export default router;
import { Router } from 'express';
import { execFile as execFileCb } from 'child_process';
import { promisify } from 'util';
import { resolveSession } from '../middleware/auth.js';
import { getCwd } from '../tmux.js';
const execFile = promisify(execFileCb);
const EXEC_TIMEOUT = 10000;
const router = Router();
// Git log with optional file filter
router.get('/api/sessions/:sessionId/git-log', async (req, res) => {
const sessionName = resolveSession(req, res);
if (!sessionName)
return;
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 30));
const file = req.query.file;
if (file && (file.includes('..') || file.startsWith('/'))) {
res.status(400).json({ error: 'Invalid file path' });
return;
}
const all = req.query.all === 'true';
const branch = req.query.branch;
if (branch && !/^[\w\-\/.]+$/.test(branch)) {
res.status(400).json({ error: 'Invalid branch name' });
return;
}
const skip = (page - 1) * limit;
let cwd;
try {
cwd = await getCwd(sessionName);
}
catch {
res.status(404).json({ error: 'Session not found' });
return;
}
try {
// Use a separator that won't appear in commit messages
const SEP = '---GIT-LOG-SEP---';
const format = `${SEP}%n%H%n%h%n%P%n%D%n%s%n%an%n%aI`;
const args = ['log', '--topo-order', `--pretty=format:${format}`, '--numstat', `--skip=${skip}`, `-${limit + 1}`];
if (all)
args.splice(1, 0, '--all');
if (branch && !all)
args.push(branch);
if (file) {
args.push('--', file);
}
const { stdout } = await execFile('git', args, { cwd, timeout: EXEC_TIMEOUT });
const commits = [];
const blocks = stdout.split(SEP).filter((b) => b.trim());
for (const block of blocks) {
// Don't filter empty lines — %P and %D may be empty (root commits, no refs)
// Block starts with \n from format, so drop the leading empty entry
const rawLines = block.split('\n');
// Drop leading empty line from format separator
if (rawLines[0] === '')
rawLines.shift();
// First 7 lines are fixed fields; remaining are numstat file lines
if (rawLines.length < 7)
continue;
const [hash, shortHash, parentLine, refLine, message, author, date, ...fileLines] = rawLines;
const parents = parentLine.trim() ? parentLine.trim().split(' ') : [];
const refs = [];
if (refLine.trim()) {
for (const raw of refLine.split(',')) {
const part = raw.trim();
if (!part)
continue;
if (part.startsWith('HEAD -> ')) {
refs.push({ type: 'head', name: part.slice(8) });
}
else if (part === 'HEAD') {
refs.push({ type: 'head', name: 'HEAD' });
}
else if (part.startsWith('tag: ')) {
refs.push({ type: 'tag', name: part.slice(5) });
}
else if (part.includes('/')) {
refs.push({ type: 'remote', name: part });
}
else {
refs.push({ type: 'branch', name: part });
}
}
}
const files = [];
for (const fl of fileLines) {
const match = fl.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
if (match) {
files.push({
additions: match[1] === '-' ? 0 : parseInt(match[1], 10),
deletions: match[2] === '-' ? 0 : parseInt(match[2], 10),
path: match[3],
});
}
}
commits.push({ hash, shortHash, parents, refs, message, author, date, files });
}
const hasMore = commits.length > limit;
if (hasMore)
commits.pop();
res.json({ commits, hasMore });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('not a git repository')) {
res.json({ commits: [], hasMore: false, error: 'Not a git repository' });
}
else {
console.error('[api:git-log]', msg);
res.status(500).json({ error: 'Failed to get git log' });
}
}
});
// Git diff for a specific commit
router.get('/api/sessions/:sessionId/git-diff', async (req, res) => {
const sessionName = resolveSession(req, res);
if (!sessionName)
return;
const commit = req.query.commit;
if (!commit || !/^[a-f0-9]{7,40}$/.test(commit)) {
res.status(400).json({ error: 'Invalid commit hash' });
return;
}
const file = req.query.file;
if (file && (file.includes('..') || file.startsWith('/'))) {
res.status(400).json({ error: 'Invalid file path' });
return;
}
let cwd;
try {
cwd = await getCwd(sessionName);
}
catch {
res.status(404).json({ error: 'Session not found' });
return;
}
try {
// Check if it's the root commit (no parent)
let args;
try {
await execFile('git', ['rev-parse', `${commit}~1`], { cwd, timeout: EXEC_TIMEOUT });
args = ['diff', `${commit}~1`, commit];
}
catch {
// Root commit
args = ['diff', '--root', commit];
}
if (file) {
args.push('--', file);
}
const { stdout } = await execFile('git', args, { cwd, timeout: EXEC_TIMEOUT });
res.json({ diff: stdout });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[api:git-diff]', msg);
res.status(500).json({ error: 'Failed to get diff' });
}
});
// Git branches list
router.get('/api/sessions/:sessionId/git-branches', async (req, res) => {
const sessionName = resolveSession(req, res);
if (!sessionName)
return;
let cwd;
try {
cwd = await getCwd(sessionName);
}
catch {
res.status(404).json({ error: 'Session not found' });
return;
}
try {
const { stdout } = await execFile('git', ['branch', '-a', '--no-color'], { cwd, timeout: EXEC_TIMEOUT });
const branches = [];
let current = '';
for (const line of stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed)
continue;
if (trimmed.startsWith('* ')) {
current = trimmed.slice(2);
branches.push(current);
}
else {
branches.push(trimmed);
}
}
res.json({ current, branches });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('not a git repository')) {
res.json({ current: '', branches: [] });
}
else {
console.error('[api:git-branches]', msg);
res.status(500).json({ error: 'Failed to get branches' });
}
}
});
export default router;
import { describe, it, expect, vi, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
// ---------------------------------------------------------------------------
// Mocks — vi.mock is hoisted, so use vi.hoisted for shared state
// ---------------------------------------------------------------------------
const { mockExecFile, mockResolveSession, mockGetCwd } = vi.hoisted(() => ({
mockExecFile: vi.fn(),
mockResolveSession: vi.fn(() => 'mock-session'),
mockGetCwd: vi.fn(),
}));
vi.mock('../middleware/auth.js', () => ({
resolveSession: mockResolveSession,
}));
vi.mock('../tmux.js', () => ({
getCwd: mockGetCwd,
}));
vi.mock('util', async () => {
const actual = await vi.importActual('util');
return { ...actual, promisify: () => mockExecFile };
});
import gitRouter from './git.js';
// ---------------------------------------------------------------------------
// App setup
// ---------------------------------------------------------------------------
function createApp() {
const app = express();
app.use(express.json());
app.use(gitRouter);
return app;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GET /api/sessions/:sessionId/git-log', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveSession.mockReturnValue('mock-session');
});
it('returns 401 when resolveSession fails', async () => {
mockResolveSession.mockImplementation((_req, res) => {
res.status(401).json({ error: 'Unauthorized' });
return null;
});
const app = createApp();
const res = await request(app).get('/api/sessions/t1/git-log');
expect(res.status).toBe(401);
});
it('returns 404 when session/cwd not found', async () => {
mockGetCwd.mockRejectedValue(new Error('no session'));
const app = createApp();
const res = await request(app).get('/api/sessions/t1/git-log');
expect(res.status).toBe(404);
expect(res.body.error).toBe('Session not found');
});
it('returns commits on success', async () => {
mockGetCwd.mockResolvedValue('/home/user/project');
const SEP = '---GIT-LOG-SEP---';
const gitOutput = [
`${SEP}`,
'abc1234567890abcdef1234567890abcdef123456',
'abc1234',
'Initial commit',
'TestUser',
'2024-01-01T00:00:00+00:00',
'10\t2\tREADME.md',
].join('\n');
mockExecFile.mockResolvedValue({ stdout: gitOutput, stderr: '' });
const app = createApp();
const res = await request(app).get('/api/sessions/t1/git-log');
expect(res.status).toBe(200);
expect(res.body.commits).toHaveLength(1);
expect(res.body.commits[0]).toMatchObject({
hash: 'abc1234567890abcdef1234567890abcdef123456',
shortHash: 'abc1234',
message: 'Initial commit',
author: 'TestUser',
});
expect(res.body.commits[0].files).toHaveLength(1);
expect(res.body.commits[0].files[0]).toEqual({
path: 'README.md',
additions: 10,
deletions: 2,
});
expect(res.body.hasMore).toBe(false);
});
it('returns empty for non-git repository', async () => {
mockGetCwd.mockResolvedValue('/tmp');
mockExecFile.mockRejectedValue(new Error('fatal: not a git repository'));
const app = createApp();
const res = await request(app).get('/api/sessions/t1/git-log');
expect(res.status).toBe(200);
expect(res.body.commits).toEqual([]);
expect(res.body.hasMore).toBe(false);
expect(res.body.error).toBe('Not a git repository');
});
it('supports file filter query param', async () => {
mockGetCwd.mockResolvedValue('/home/user/project');
mockExecFile.mockResolvedValue({ stdout: '', stderr: '' });
const app = createApp();
await request(app).get('/api/sessions/t1/git-log?file=src/index.ts');
expect(mockExecFile).toHaveBeenCalled();
const callArgs = mockExecFile.mock.calls[0];
const args = callArgs[1];
expect(args).toContain('--');
expect(args).toContain('src/index.ts');
});
it('supports pagination and hasMore', async () => {
mockGetCwd.mockResolvedValue('/home/user/project');
const SEP = '---GIT-LOG-SEP---';
const gitOutput = [
`${SEP}`, 'hash1', 'sh1', 'msg1', 'Author', '2024-01-01T00:00:00+00:00',
`${SEP}`, 'hash2', 'sh2', 'msg2', 'Author', '2024-01-02T00:00:00+00:00',
].join('\n');
mockExecFile.mockResolvedValue({ stdout: gitOutput, stderr: '' });
const app = createApp();
const res = await request(app).get('/api/sessions/t1/git-log?limit=1');
expect(res.status).toBe(200);
expect(res.body.commits).toHaveLength(1);
expect(res.body.hasMore).toBe(true);
});
});
describe('GET /api/sessions/:sessionId/git-diff', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveSession.mockReturnValue('mock-session');
});
it('returns 400 for missing commit', async () => {
const app = createApp();
const res = await request(app).get('/api/sessions/t1/git-diff');
expect(res.status).toBe(400);
expect(res.body.error).toBe('Invalid commit hash');
});
it('returns 400 for invalid commit hash', async () => {
const app = createApp();
const res = await request(app).get('/api/sessions/t1/git-diff?commit=ZZZZ');
expect(res.status).toBe(400);
});
it('returns diff on success', async () => {
mockGetCwd.mockResolvedValue('/home/user/project');
const diffOutput = `diff --git a/file.ts b/file.ts
--- a/file.ts
+++ b/file.ts
@@ -1,3 +1,4 @@
line1
+added line
line2
line3`;
mockExecFile
.mockResolvedValueOnce({ stdout: 'parenthash', stderr: '' })
.mockResolvedValueOnce({ stdout: diffOutput, stderr: '' });
const app = createApp();
const res = await request(app).get('/api/sessions/t1/git-diff?commit=abc1234');
expect(res.status).toBe(200);
expect(res.body.diff).toContain('+added line');
});
it('handles root commit fallback', async () => {
mockGetCwd.mockResolvedValue('/home/user/project');
mockExecFile
.mockRejectedValueOnce(new Error('unknown revision'))
.mockResolvedValueOnce({ stdout: 'root diff output', stderr: '' });
const app = createApp();
const res = await request(app).get('/api/sessions/t1/git-diff?commit=abc1234');
expect(res.status).toBe(200);
expect(res.body.diff).toBe('root diff output');
const secondCall = mockExecFile.mock.calls[1];
const args = secondCall[1];
expect(args).toContain('--root');
});
});
import { describe, it, expect, vi, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import sessionsRouter from './sessions.js';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('../middleware/auth.js', () => ({
extractToken: vi.fn(() => 'test-token'),
checkAuth: vi.fn(() => true),
resolveSession: vi.fn(() => 'mock-session'),
}));
vi.mock('../tmux.js', () => ({
listSessions: vi.fn(),
killSession: vi.fn(),
buildSessionName: vi.fn((_token, sessionId) => `mock_${sessionId}`),
getCwd: vi.fn(),
getPaneCommand: vi.fn(),
isValidSessionId: vi.fn((id) => /^[\w-]+$/.test(id)),
}));
vi.mock('../websocket.js', () => ({
getActiveSessionNames: vi.fn(() => new Set(['mock_t1'])),
}));
vi.mock('../db.js', () => ({
deleteDraft: vi.fn(),
}));
import { checkAuth, resolveSession } from '../middleware/auth.js';
import { listSessions, killSession, getCwd, getPaneCommand } from '../tmux.js';
import { deleteDraft } from '../db.js';
const mockCheckAuth = vi.mocked(checkAuth);
const mockResolveSession = vi.mocked(resolveSession);
const mockListSessions = vi.mocked(listSessions);
const mockKillSession = vi.mocked(killSession);
const mockGetCwd = vi.mocked(getCwd);
const mockGetPaneCommand = vi.mocked(getPaneCommand);
const mockDeleteDraft = vi.mocked(deleteDraft);
// ---------------------------------------------------------------------------
// App setup
// ---------------------------------------------------------------------------
function createApp() {
const app = express();
app.use(express.json());
app.use(sessionsRouter);
return app;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('GET /api/sessions', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCheckAuth.mockReturnValue(true);
});
it('returns 401 when auth fails', async () => {
mockCheckAuth.mockImplementation((_req, res) => {
res.status(401).json({ error: 'Unauthorized' });
return false;
});
const app = createApp();
const res = await request(app).get('/api/sessions');
expect(res.status).toBe(401);
});
it('returns sessions with active status', async () => {
mockListSessions.mockResolvedValue([
{ sessionId: 't1', sessionName: 'mock_t1', createdAt: 1000 },
{ sessionId: 't2', sessionName: 'mock_t2', createdAt: 2000 },
]);
const app = createApp();
const res = await request(app).get('/api/sessions');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0]).toMatchObject({
sessionId: 't1',
sessionName: 'mock_t1',
active: true,
});
expect(res.body[1]).toMatchObject({
sessionId: 't2',
active: false,
});
});
});
describe('DELETE /api/sessions/:sessionId', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCheckAuth.mockReturnValue(true);
mockKillSession.mockResolvedValue(undefined);
});
it('returns 400 for invalid sessionId', async () => {
const app = createApp();
const res = await request(app).delete('/api/sessions/invalid!id');
expect(res.status).toBe(400);
});
it('kills session and deletes draft', async () => {
const app = createApp();
const res = await request(app).delete('/api/sessions/t1');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(mockKillSession).toHaveBeenCalledWith('mock_t1');
expect(mockDeleteDraft).toHaveBeenCalledWith('mock_t1');
});
});
describe('GET /api/sessions/:sessionId/cwd', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveSession.mockReturnValue('mock-session');
});
it('returns cwd on success', async () => {
mockGetCwd.mockResolvedValue('/home/user/project');
const app = createApp();
const res = await request(app).get('/api/sessions/t1/cwd');
expect(res.status).toBe(200);
expect(res.body.cwd).toBe('/home/user/project');
});
it('returns 404 when session not found', async () => {
mockGetCwd.mockRejectedValue(new Error('session not found'));
const app = createApp();
const res = await request(app).get('/api/sessions/t1/cwd');
expect(res.status).toBe(404);
});
});
describe('GET /api/sessions/:sessionId/pane-command', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveSession.mockReturnValue('mock-session');
});
it('returns command on success', async () => {
mockGetPaneCommand.mockResolvedValue('claude');
const app = createApp();
const res = await request(app).get('/api/sessions/t1/pane-command');
expect(res.status).toBe(200);
expect(res.body.command).toBe('claude');
});
it('returns empty string on error', async () => {
mockGetPaneCommand.mockRejectedValue(new Error('fail'));
const app = createApp();
const res = await request(app).get('/api/sessions/t1/pane-command');
expect(res.status).toBe(200);
expect(res.body.command).toBe('');
});
});

Sorry, the diff of this file is too big to display

+6
-3
{
"name": "ai-cli-online",
"version": "3.0.13",
"version": "3.0.15",
"description": "AI-Cli Online - Web Terminal for Claude Code via xterm.js + tmux",

@@ -51,8 +51,11 @@ "license": "MIT",

"build": "npm run build --workspace=shared && npm run build --workspace=server && npm run build --workspace=web",
"start": "npm run start --workspace=server"
"start": "npm run start --workspace=server",
"test": "npm run test --workspace=server && npm run test --workspace=web",
"test:watch": "npm run test:watch --workspace=server"
},
"devDependencies": {
"concurrently": "^8.2.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.9"
}
}

@@ -21,2 +21,3 @@ import express from 'express';

import settingsRouter from './routes/settings.js';
import gitRouter from './routes/git.js';
const __dirname = dirname(fileURLToPath(import.meta.url));

@@ -103,2 +104,3 @@ config();

app.use(settingsRouter);
app.use(gitRouter);
// --- Static files ---

@@ -105,0 +107,0 @@ const webDistPath = join(__dirname, '../../web/dist');

{
"name": "ai-cli-online-server",
"version": "3.0.13",
"version": "3.0.15",
"description": "CLI-Online Backend Server",

@@ -10,3 +10,4 @@ "main": "dist/index.js",

"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"test": "npx vitest run"
},

@@ -31,6 +32,9 @@ "dependencies": {

"@types/node": "^20.19.33",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.5.10",
"supertest": "^7.2.2",
"tsx": "^4.7.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.9"
}
}
{
"name": "ai-cli-online-shared",
"version": "3.0.13",
"version": "3.0.15",
"description": "Shared types for CLI-Online",

@@ -5,0 +5,0 @@ "type": "module",

@@ -13,3 +13,3 @@ <!DOCTYPE html>

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-bold.css" />
<script type="module" crossorigin src="/assets/index-DtWYEMhe.js"></script>
<script type="module" crossorigin src="/assets/index-BxwAIZoA.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-vendor-BCIvbQoU.js">

@@ -16,0 +16,0 @@ <link rel="modulepreload" crossorigin href="/assets/terminal-DnNpv9tw.js">

{
"name": "ai-cli-online-web",
"version": "3.0.13",
"version": "3.0.15",
"description": "CLI-Online Web Frontend",

@@ -9,3 +9,4 @@ "type": "module",

"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "npx vitest run"
},

@@ -28,2 +29,4 @@ "dependencies": {

"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.2.43",

@@ -33,5 +36,7 @@ "@types/react-dom": "^18.3.7",

"@vitejs/plugin-react": "^4.2.1",
"jsdom": "^28.1.0",
"typescript": "^5.9.3",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vitest": "^2.1.9"
}
}

Sorry, the diff of this file is too big to display