+212
-51
@@ -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", |
+4
-4
@@ -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 |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
34229
21.8%720
25%7
-12.5%