New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

ccgate

Package Overview
Dependencies
Maintainers
1
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ccgate - npm Package Compare versions

Comparing version
1.1.1
to
1.2.0
+212
-51
ccgate.mjs

@@ -18,3 +18,3 @@ #!/usr/bin/env node

import { spawn } from 'child_process';
import { readdir, access, realpath, readFile, writeFile, mkdir } from 'fs/promises';
import { readdir, access, realpath, readFile, writeFile, mkdir, stat as fsStat } from 'fs/promises';
import { homedir } from 'os';

@@ -24,3 +24,3 @@ import { join, resolve, normalize } from 'path';

const VERSION = '1.1.1';
const VERSION = '1.2.0';
const PACKAGE_NAME = 'ccgate';

@@ -100,9 +100,2 @@

// Project discovery - scan from home directory
maxScanDepth: 5,
skipDirs: new Set([
'node_modules', 'vendor', 'venv', '.venv',
'__pycache__', '.git', 'dist', 'build',
]),
// CORS: the single trusted origin (localhost always allowed)

@@ -160,45 +153,49 @@ trustedOrigin: origin,

/**
* Finds directories containing .claude/ folders (Claude Code projects).
* Scans from home directory up to maxScanDepth levels deep.
* Uses realpath to resolve symlinks and handle case-insensitive filesystems.
* Discovers project paths from the Claude session store (~/.claude/projects/).
* Each subdirectory represents a project. We extract the original path by
* reading sessions-index.json or the first JSONL file's metadata.
* This catches projects that are too deep for the filesystem scan.
*/
async function findClaudeProjects(config) {
const home = homedir();
async function discoverProjectsFromSessionStore() {
const claudeProjectsDir = join(homedir(), '.claude', 'projects');
const projects = new Set();
const visitedDirs = new Set(); // Track visited dirs by canonical path
async function scanDir(dir, depth) {
if (depth > config.maxScanDepth) return;
let entries;
try {
entries = await readdir(claudeProjectsDir, { withFileTypes: true });
} catch {
return [];
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === '.' || entry.name === '..') continue;
const dirPath = join(claudeProjectsDir, entry.name);
// Try sessions-index.json first for the original path
const indexPath = join(dirPath, 'sessions-index.json');
try {
// Resolve to canonical path to handle symlinks and case-insensitive FS
const canonicalDir = await realpath(dir);
const content = await readFile(indexPath, 'utf-8');
const data = JSON.parse(content);
if (data.originalPath) {
projects.add(data.originalPath);
continue;
}
} catch {}
// Skip if already visited (handles symlinks and case variations)
if (visitedDirs.has(canonicalDir)) return;
visitedDirs.add(canonicalDir);
const entries = await readdir(canonicalDir, { withFileTypes: true });
// Check if this directory is a Claude project
const hasClaude = entries.some(e => e.isDirectory() && e.name === '.claude');
if (hasClaude) {
projects.add(canonicalDir);
// Fall back to reading first JSONL file for cwd/projectPath
try {
const files = await readdir(dirPath);
const jsonlFile = files.find(f => f.endsWith('.jsonl'));
if (jsonlFile) {
const content = await readFile(join(dirPath, jsonlFile), 'utf-8');
const firstLine = content.split('\n').find(l => l.trim());
if (firstLine) {
const parsed = JSON.parse(firstLine);
if (parsed.cwd) { projects.add(parsed.cwd); continue; }
if (parsed.projectPath) { projects.add(parsed.projectPath); continue; }
}
}
// Scan subdirectories in parallel
const subdirs = entries.filter(e =>
e.isDirectory() &&
!e.name.startsWith('.') &&
!config.skipDirs.has(e.name)
);
await Promise.all(subdirs.map(entry =>
scanDir(join(canonicalDir, entry.name), depth + 1)
));
} catch {
// Ignore permission errors, missing directories, etc.
}
} catch {}
}
await scanDir(home, 0);
return Array.from(projects).sort();

@@ -243,16 +240,85 @@ }

* Encodes a project path to the format used by Claude Code for session storage.
* Format: `-` + path with `/` replaced by `-`
* Example: /Users/foo/project -> -Users-foo-project
* Replaces all non-alphanumeric characters with `-`
* Example: /Users/foo.bar/project -> -Users-foo-bar-project
*/
function encodeProjectPath(projectPath) {
return '-' + projectPath.replace(/\//g, '-');
return projectPath.replace(/[^a-zA-Z0-9]/g, '-');
}
/**
* Builds session metadata by scanning JSONL files in a project directory.
* Used as a fallback when sessions-index.json doesn't exist.
*/
async function buildSessionsFromFiles(projectDirPath) {
let files;
try {
files = await readdir(projectDirPath);
} catch {
return [];
}
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
const sessions = [];
for (const file of jsonlFiles) {
const sessionId = file.replace('.jsonl', '');
const filePath = join(projectDirPath, file);
try {
const stat = await fsStat(filePath);
// Read first few lines to extract metadata
const content = await readFile(filePath, 'utf-8');
const lines = content.split('\n').filter(l => l.trim()).slice(0, 10);
let firstPrompt = '', created = '', modified = '', gitBranch = '', projectPath = '';
let summary = '';
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'summary') summary = entry.summary || '';
if (entry.type === 'user' && !firstPrompt) {
firstPrompt = typeof entry.message?.content === 'string'
? entry.message.content.slice(0, 100)
: '';
created = entry.timestamp || '';
}
if (entry.gitBranch && !gitBranch) gitBranch = entry.gitBranch;
if (entry.cwd && !projectPath) projectPath = entry.cwd;
} catch {}
}
// Skip sessions with no conversation content (e.g. orphaned file-history-snapshot files)
// These can't be resumed by Claude CLI
if (!firstPrompt && !summary) continue;
sessions.push({
sessionId,
fullPath: filePath,
fileMtime: stat.mtimeMs,
firstPrompt: firstPrompt || summary,
created: created || stat.birthtime.toISOString(),
modified: stat.mtime.toISOString(),
gitBranch,
projectPath,
isSidechain: false,
});
} catch {
// Skip files we can't read
}
}
return sessions.sort((a, b) => new Date(b.modified) - new Date(a.modified));
}
/**
* Gets the sessions index for a project.
* Returns array of session entries, or empty array if not found.
* Falls back to scanning JSONL files when sessions-index.json doesn't exist.
*/
async function getSessionsIndex(projectPath) {
const encodedPath = encodeProjectPath(projectPath);
const sessionsIndexPath = join(homedir(), '.claude', 'projects', encodedPath, 'sessions-index.json');
const projectDirPath = join(homedir(), '.claude', 'projects', encodedPath);
const sessionsIndexPath = join(projectDirPath, 'sessions-index.json');

@@ -265,7 +331,52 @@ try {

} catch (err) {
// File doesn't exist or is invalid - return empty array
return [];
// File doesn't exist or is invalid - fall back to scanning JSONL files
return buildSessionsFromFiles(projectDirPath);
}
}
/**
* Finds the correct project directory for a given sessionId.
* Searches the base project dir and all subdirectory project dirs.
* Returns the projectPath (cwd) from the session metadata, or null if not found.
*/
async function resolveSessionProjectPath(baseCwd, sessionId) {
const claudeProjectsDir = join(homedir(), '.claude', 'projects');
const encodedBase = encodeProjectPath(baseCwd);
const sessionFile = `${sessionId}.jsonl`;
// Check the base project dir first
try {
await access(join(claudeProjectsDir, encodedBase, sessionFile), constants.R_OK);
return baseCwd; // Session is in the base project
} catch {}
// Search subdirectory project dirs
try {
const allDirs = await readdir(claudeProjectsDir);
for (const dir of allDirs) {
if (dir.startsWith(encodedBase) && dir !== encodedBase) {
const filePath = join(claudeProjectsDir, dir, sessionFile);
try {
await access(filePath, constants.R_OK);
// Found it — read the first few lines to find cwd
// (first line may be file-history-snapshot without cwd)
const content = await readFile(filePath, 'utf-8');
const lines = content.split('\n').filter(l => l.trim()).slice(0, 10);
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.cwd) return entry.cwd;
if (entry.projectPath) return entry.projectPath;
} catch {}
}
// Couldn't extract path from content, but file exists here
return baseCwd;
} catch {}
}
}
} catch {}
return null;
}
// =============================================================================

@@ -295,3 +406,6 @@ // SSE Helpers

try {
const projects = await findClaudeProjects(config);
// Discover projects only from the session store (~/.claude/projects/)
// This avoids scanning the entire home directory for security
const projects = await discoverProjectsFromSessionStore();
res.writeHead(200, { 'Content-Type': 'application/json' });

@@ -316,3 +430,30 @@ res.end(JSON.stringify({ projects }));

// Get sessions for the requested project
const sessions = await getSessionsIndex(projectPath);
// Also include sessions from subdirectory projects
// e.g. for web-ui, also find web-ui/static-apps/claude-remote sessions
const encodedPrefix = encodeProjectPath(projectPath);
const claudeProjectsDir = join(homedir(), '.claude', 'projects');
try {
const allProjectDirs = await readdir(claudeProjectsDir);
for (const dir of allProjectDirs) {
if (dir.startsWith(encodedPrefix) && dir !== encodedPrefix) {
// This is a subdirectory project - include its sessions too
const subDirPath = join(claudeProjectsDir, dir);
const subSessions = await buildSessionsFromFiles(subDirPath);
sessions.push(...subSessions);
}
}
} catch {
// Ignore errors reading project dirs
}
// Sort all sessions by modified date (most recent first)
sessions.sort((a, b) => {
const aTime = a.fileMtime || new Date(a.modified || 0).getTime();
const bTime = b.fileMtime || new Date(b.modified || 0).getTime();
return bTime - aTime;
});
res.writeHead(200, { 'Content-Type': 'application/json' });

@@ -355,5 +496,25 @@ res.end(JSON.stringify({ sessions }));

// Validate sessionId if provided (must be UUID format)
if (sessionId !== undefined) {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (typeof sessionId !== 'string' || !uuidPattern.test(sessionId)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid sessionId format (must be UUID)' }));
return;
}
}
// Validate and resolve working directory
const workingDir = await validateProjectPath(cwd) || process.cwd();
let workingDir = await validateProjectPath(cwd) || process.cwd();
// If resuming a specific session, resolve the correct cwd for it.
// Sessions from subdirectory projects need their own cwd, not the parent's.
if (sessionId && cwd) {
const resolvedPath = await resolveSessionProjectPath(workingDir, sessionId);
if (resolvedPath) {
const validated = await validateProjectPath(resolvedPath);
if (validated) workingDir = validated;
}
}
// Start SSE response

@@ -360,0 +521,0 @@ res.writeHead(200, {

+1
-1
{
"name": "ccgate",
"version": "1.1.1",
"version": "1.2.0",
"description": "Local gateway for controlling Claude Code from a webpage",

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

@@ -28,3 +28,3 @@ # ccgate

The gateway spawns Claude Code with `--print --continue`, so conversation history persists per project directory (stored in each project's `.claude/` folder).
The gateway spawns Claude Code with `--print --continue`, so conversation history persists per project directory (stored in `~/.claude/projects/`).

@@ -43,3 +43,3 @@ ## API

Lists directories containing Claude Code projects (`.claude/` folder). Scans from home directory up to 5 levels deep.
Lists Claude Code projects discovered from the session store (`~/.claude/projects/`).

@@ -58,3 +58,3 @@ ```bash

Returns session metadata from `~/.claude/projects/<encoded-path>/sessions-index.json`, including `sessionId`, `firstPrompt`, `created`, `modified`, `messageCount`, and `gitBranch`.
Returns session metadata including `sessionId`, `firstPrompt`, `created`, `modified`, `gitBranch`, and `projectPath`. Reads from `sessions-index.json` when available, falling back to scanning JSONL session files. Also includes sessions from subdirectory projects (e.g., sessions in child paths under the requested project).

@@ -112,3 +112,3 @@ ### `POST /prompt`

| **HTTP server** | Accepts requests from your browser/webapp |
| **Filesystem access** | Scans for project directories under home |
| **Filesystem access** | Reads session data from `~/.claude/projects/` |

@@ -115,0 +115,0 @@ ### Security Measures