The WebSocket send mechanism fails with close code 1006 when client tries to send data to server. Server never receives the message, indicating a network/proxy layer issue that couldn't be fixed through code changes or nginx configuration. Solution: Bypass WebSocket send entirely by using HTTP POST to send commands directly to the PTY. Changes: - Added sendTerminalInput() method to terminal-service.js that writes directly to PTY, bypassing WebSocket - Added POST endpoint /claude/api/terminals/:id/input to server.js - Modified launchCommand() in terminal.js to use fetch() with HTTP POST instead of WebSocket.send() The WebSocket receive direction still works (server→client for output display), only send direction (client→server for commands) is bypassed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1833 lines
54 KiB
JavaScript
1833 lines
54 KiB
JavaScript
const express = require('express');
|
|
const session = require('express-session');
|
|
const bcrypt = require('bcryptjs');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const MarkdownIt = require('markdown-it');
|
|
const hljs = require('highlight.js');
|
|
const WebSocket = require('ws');
|
|
const ClaudeCodeService = require('./services/claude-service');
|
|
const terminalService = require('./services/terminal-service');
|
|
const { db } = require('./services/database');
|
|
|
|
const app = express();
|
|
const md = new MarkdownIt({
|
|
html: true,
|
|
linkify: true,
|
|
typographer: true,
|
|
highlight: function (str, lang) {
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
try {
|
|
return hljs.highlight(str, { language: lang }).value;
|
|
} catch (__) {}
|
|
}
|
|
return '';
|
|
}
|
|
});
|
|
|
|
// Configuration
|
|
const VAULT_PATH = '/home/uroma/obsidian-vault';
|
|
const PORT = 3010;
|
|
const SESSION_SECRET = 'obsidian-web-secret-' + Math.random().toString(36).substring(2);
|
|
|
|
// Users database (in production, use a real database)
|
|
const users = {
|
|
admin: {
|
|
passwordHash: bcrypt.hashSync('!@#$q1w2e3r4!A', 10)
|
|
}
|
|
};
|
|
|
|
// Initialize Claude Code Service
|
|
const claudeService = new ClaudeCodeService(VAULT_PATH);
|
|
|
|
// Cleanup old sessions every hour
|
|
setInterval(() => {
|
|
claudeService.cleanup();
|
|
}, 60 * 60 * 1000);
|
|
|
|
// Trust proxy for proper session handling behind nginx
|
|
app.set('trust proxy', 1);
|
|
|
|
// Middleware
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
app.use(session({
|
|
secret: SESSION_SECRET,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: false, // Will work with both HTTP and HTTPS behind proxy
|
|
sameSite: 'lax',
|
|
httpOnly: true,
|
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
|
},
|
|
name: 'connect.sid'
|
|
}));
|
|
|
|
// Authentication middleware
|
|
function requireAuth(req, res, next) {
|
|
if (req.session.userId) {
|
|
next();
|
|
} else {
|
|
res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
}
|
|
|
|
// Routes
|
|
|
|
// Sessions landing page (root of /claude/)
|
|
app.get('/claude/', (req, res) => {
|
|
if (req.session.userId) {
|
|
// Authenticated - show landing page
|
|
res.sendFile(path.join(__dirname, 'public', 'claude-landing.html'));
|
|
} else {
|
|
// Not authenticated - show login page
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
}
|
|
});
|
|
|
|
// IDE routes
|
|
app.get('/claude/ide', requireAuth, (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'claude-ide', 'index.html'));
|
|
});
|
|
|
|
app.get('/claude/ide/*', requireAuth, (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'claude-ide', 'index.html'));
|
|
});
|
|
|
|
// Projects page route
|
|
app.get('/projects', (req, res) => {
|
|
if (req.session.userId) {
|
|
// Authenticated - serve projects page
|
|
res.sendFile(path.join(__dirname, 'public', 'projects.html'));
|
|
} else {
|
|
// Not authenticated - redirect to login
|
|
res.redirect('/claude/');
|
|
}
|
|
});
|
|
|
|
// Serve static files (must come after specific routes)
|
|
app.use('/claude', express.static(path.join(__dirname, 'public')));
|
|
|
|
// Login route
|
|
app.post('/claude/api/login', (req, res) => {
|
|
const { username, password } = req.body;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password required' });
|
|
}
|
|
|
|
const user = users[username];
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
const isValid = bcrypt.compareSync(password, user.passwordHash);
|
|
if (!isValid) {
|
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
}
|
|
|
|
req.session.userId = username;
|
|
req.session.username = username;
|
|
res.json({ success: true, username });
|
|
});
|
|
|
|
// Logout route
|
|
app.post('/claude/api/logout', (req, res) => {
|
|
req.session.destroy((err) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'Logout failed' });
|
|
}
|
|
res.json({ success: true });
|
|
});
|
|
});
|
|
|
|
// Check auth status
|
|
app.get('/claude/api/auth/status', (req, res) => {
|
|
if (req.session.userId) {
|
|
res.json({ authenticated: true, username: req.session.username });
|
|
} else {
|
|
res.json({ authenticated: false });
|
|
}
|
|
});
|
|
|
|
// Get file tree
|
|
app.get('/claude/api/files', requireAuth, (req, res) => {
|
|
try {
|
|
const getFileTree = (dir, relativePath = '') => {
|
|
const items = [];
|
|
const files = fs.readdirSync(dir);
|
|
|
|
files.forEach(file => {
|
|
if (file === '.obsidian') return; // Skip Obsidian config
|
|
|
|
const fullPath = path.join(dir, file);
|
|
const stat = fs.statSync(fullPath);
|
|
const relPath = path.join(relativePath, file);
|
|
|
|
if (stat.isDirectory()) {
|
|
items.push({
|
|
name: file,
|
|
type: 'folder',
|
|
path: relPath,
|
|
children: getFileTree(fullPath, relPath)
|
|
});
|
|
} else if (file.endsWith('.md')) {
|
|
items.push({
|
|
name: file,
|
|
type: 'file',
|
|
path: relPath
|
|
});
|
|
}
|
|
});
|
|
|
|
return items.sort((a, b) => {
|
|
if (a.type === b.type) return a.name.localeCompare(b.name);
|
|
return a.type === 'folder' ? -1 : 1;
|
|
});
|
|
};
|
|
|
|
const tree = getFileTree(VAULT_PATH);
|
|
res.json({ tree });
|
|
} catch (error) {
|
|
console.error('Error reading files:', error);
|
|
res.status(500).json({ error: 'Failed to read files' });
|
|
}
|
|
});
|
|
|
|
// Get file content
|
|
app.get('/claude/api/file/*', requireAuth, (req, res) => {
|
|
try {
|
|
const filePath = req.path.replace('/claude/api/file/', '');
|
|
const fullPath = path.join(VAULT_PATH, filePath);
|
|
|
|
// Security check
|
|
if (!fullPath.startsWith(VAULT_PATH)) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
if (!fs.existsSync(fullPath)) {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
|
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
const stats = fs.statSync(fullPath);
|
|
|
|
// Check if file is HTML
|
|
const isHtmlFile = filePath.toLowerCase().endsWith('.html') || filePath.toLowerCase().endsWith('.htm');
|
|
|
|
if (isHtmlFile) {
|
|
// Return raw HTML content without processing
|
|
res.json({
|
|
path: filePath,
|
|
content: content, // Raw content
|
|
html: `<pre><code>${content.replace(/</g, '<').replace(/>/g, '>')}</code></pre>`,
|
|
frontmatter: {},
|
|
modified: stats.mtime,
|
|
created: stats.birthtime
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Parse frontmatter for non-HTML files
|
|
let frontmatter = {};
|
|
let markdownContent = content;
|
|
|
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
if (frontmatterMatch) {
|
|
try {
|
|
frontmatterMatch[1].split('\n').forEach(line => {
|
|
const [key, ...valueParts] = line.split(':');
|
|
if (key && valueParts.length > 0) {
|
|
frontmatter[key.trim()] = valueParts.join(':').trim();
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('Error parsing frontmatter:', e);
|
|
}
|
|
markdownContent = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
}
|
|
|
|
// Convert markdown to HTML
|
|
const htmlContent = md.render(markdownContent);
|
|
|
|
res.json({
|
|
path: filePath,
|
|
content: markdownContent,
|
|
html: htmlContent,
|
|
frontmatter,
|
|
modified: stats.mtime,
|
|
created: stats.birthtime
|
|
});
|
|
} catch (error) {
|
|
console.error('Error reading file:', error);
|
|
res.status(500).json({ error: 'Failed to read file' });
|
|
}
|
|
});
|
|
|
|
// Save file content
|
|
app.put('/claude/api/file/*', requireAuth, (req, res) => {
|
|
try {
|
|
const filePath = req.path.replace('/claude/api/file/', '');
|
|
const fullPath = path.join(VAULT_PATH, filePath);
|
|
const { content } = req.body;
|
|
|
|
// Security check
|
|
if (!fullPath.startsWith(VAULT_PATH)) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
// Create directory if it doesn't exist
|
|
const dir = path.dirname(fullPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error saving file:', error);
|
|
res.status(500).json({ error: 'Failed to save file' });
|
|
}
|
|
});
|
|
|
|
// Create new file
|
|
app.post('/claude/api/file', requireAuth, (req, res) => {
|
|
try {
|
|
const { path: filePath, content = '' } = req.body;
|
|
const fullPath = path.join(VAULT_PATH, filePath);
|
|
|
|
// Security check
|
|
if (!fullPath.startsWith(VAULT_PATH)) {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
|
|
// Create directory if it doesn't exist
|
|
const dir = path.dirname(fullPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
if (fs.existsSync(fullPath)) {
|
|
return res.status(409).json({ error: 'File already exists' });
|
|
}
|
|
|
|
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
res.json({ success: true, path: filePath });
|
|
} catch (error) {
|
|
console.error('Error creating file:', error);
|
|
res.status(500).json({ error: 'Failed to create file' });
|
|
}
|
|
});
|
|
|
|
// Search files
|
|
app.get('/claude/api/search', requireAuth, (req, res) => {
|
|
try {
|
|
const { q } = req.query;
|
|
if (!q) {
|
|
return res.json({ results: [] });
|
|
}
|
|
|
|
const searchInDir = (dir, relativePath = '') => {
|
|
const results = [];
|
|
const files = fs.readdirSync(dir);
|
|
|
|
files.forEach(file => {
|
|
if (file === '.obsidian') return;
|
|
|
|
const fullPath = path.join(dir, file);
|
|
const stat = fs.statSync(fullPath);
|
|
const relPath = path.join(relativePath, file);
|
|
|
|
if (stat.isDirectory()) {
|
|
results.push(...searchInDir(fullPath, relPath));
|
|
} else if (file.endsWith('.md')) {
|
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
const lowerContent = content.toLowerCase();
|
|
const lowerQ = q.toLowerCase();
|
|
|
|
if (lowerContent.includes(lowerQ) || file.toLowerCase().includes(lowerQ)) {
|
|
// Extract a preview
|
|
const lines = content.split('\n');
|
|
const previewLines = [];
|
|
for (const line of lines) {
|
|
if (line.toLowerCase().includes(lowerQ)) {
|
|
previewLines.push(line.trim());
|
|
if (previewLines.length >= 3) break;
|
|
}
|
|
}
|
|
|
|
results.push({
|
|
path: relPath,
|
|
name: file,
|
|
preview: previewLines.join('...').substring(0, 200)
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return results;
|
|
};
|
|
|
|
const results = searchInDir(VAULT_PATH);
|
|
res.json({ results });
|
|
} catch (error) {
|
|
console.error('Error searching:', error);
|
|
res.status(500).json({ error: 'Search failed' });
|
|
}
|
|
});
|
|
|
|
// Get recent files
|
|
app.get('/claude/api/recent', requireAuth, (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit) || 10;
|
|
const files = [];
|
|
|
|
const getFilesRecursively = (dir, relativePath = '') => {
|
|
const items = fs.readdirSync(dir);
|
|
|
|
items.forEach(file => {
|
|
if (file === '.obsidian') return;
|
|
|
|
const fullPath = path.join(dir, file);
|
|
const stat = fs.statSync(fullPath);
|
|
const relPath = path.join(relativePath, file);
|
|
|
|
if (stat.isDirectory()) {
|
|
getFilesRecursively(fullPath, relPath);
|
|
} else if (file.endsWith('.md')) {
|
|
files.push({
|
|
path: relPath,
|
|
name: file,
|
|
modified: stat.mtime
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
getFilesRecursively(VAULT_PATH);
|
|
files.sort((a, b) => b.modified - a.modified);
|
|
|
|
res.json({ files: files.slice(0, limit) });
|
|
} catch (error) {
|
|
console.error('Error getting recent files:', error);
|
|
res.status(500).json({ error: 'Failed to get recent files' });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// Claude Code IDE API Routes
|
|
// ============================================
|
|
|
|
// Session Management
|
|
app.get('/claude/api/claude/sessions', requireAuth, (req, res) => {
|
|
try {
|
|
const activeSessions = claudeService.listSessions();
|
|
const historicalSessions = claudeService.loadHistoricalSessions();
|
|
|
|
res.json({
|
|
active: activeSessions,
|
|
historical: historicalSessions
|
|
});
|
|
} catch (error) {
|
|
console.error('Error listing sessions:', error);
|
|
res.status(500).json({ error: 'Failed to list sessions' });
|
|
}
|
|
});
|
|
|
|
app.post('/claude/api/claude/sessions', requireAuth, (req, res) => {
|
|
try {
|
|
const { workingDir, metadata, projectId } = req.body;
|
|
|
|
// Validate projectId if provided
|
|
let validatedProjectId = null;
|
|
if (projectId !== null && projectId !== undefined) {
|
|
validatedProjectId = validateProjectId(projectId);
|
|
if (!validatedProjectId) {
|
|
return res.status(400).json({ error: 'Invalid project ID' });
|
|
}
|
|
|
|
// Verify project exists and is not deleted
|
|
const project = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE id = ? AND deletedAt IS NULL
|
|
`).get(validatedProjectId);
|
|
|
|
if (!project) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
}
|
|
|
|
// Create session with projectId in metadata
|
|
const sessionMetadata = {
|
|
...metadata,
|
|
...(validatedProjectId ? { projectId: validatedProjectId } : {})
|
|
};
|
|
const session = claudeService.createSession({ workingDir, metadata: sessionMetadata });
|
|
|
|
// Store session in database with projectId
|
|
db.prepare(`
|
|
INSERT INTO sessions (id, projectId, deletedAt)
|
|
VALUES (?, ?, NULL)
|
|
`).run(session.id, validatedProjectId);
|
|
|
|
// Update project's lastActivity if session was assigned to a project
|
|
if (validatedProjectId) {
|
|
const now = new Date().toISOString();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET lastActivity = ?
|
|
WHERE id = ?
|
|
`).run(now, validatedProjectId);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
session: {
|
|
id: session.id,
|
|
pid: session.pid,
|
|
workingDir: session.workingDir,
|
|
status: session.status,
|
|
createdAt: session.createdAt,
|
|
projectId: validatedProjectId
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating session:', error);
|
|
res.status(500).json({ error: 'Failed to create session' });
|
|
}
|
|
});
|
|
|
|
app.get('/claude/api/claude/sessions/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const session = claudeService.getSession(req.params.id);
|
|
res.json({ session });
|
|
} catch (error) {
|
|
res.status(404).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/claude/api/claude/sessions/:id/command', requireAuth, (req, res) => {
|
|
try {
|
|
const { command } = req.body;
|
|
const result = claudeService.sendCommand(req.params.id, command);
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Context Management
|
|
app.get('/claude/api/claude/sessions/:id/context', requireAuth, (req, res) => {
|
|
try {
|
|
const stats = claudeService.getContextStats(req.params.id);
|
|
res.json({ stats });
|
|
} catch (error) {
|
|
res.status(404).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Operations Management - Preview operations before executing
|
|
app.post('/claude/api/claude/sessions/:id/operations/preview', requireAuth, async (req, res) => {
|
|
try {
|
|
const { response } = req.body;
|
|
const preview = await claudeService.previewOperations(req.params.id, response);
|
|
res.json({ preview });
|
|
} catch (error) {
|
|
console.error('Error previewing operations:', error);
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Operations Management - Execute approved operations
|
|
app.post('/claude/api/claude/sessions/:id/operations/execute', requireAuth, async (req, res) => {
|
|
try {
|
|
const { response } = req.body;
|
|
|
|
// Set up progress callback
|
|
const onProgress = (progress) => {
|
|
// Emit progress via WebSocket to all subscribed clients
|
|
wss.clients.forEach(client => {
|
|
if (client.readyState === WebSocket.OPEN) {
|
|
client.send(JSON.stringify({
|
|
type: 'operation-progress',
|
|
sessionId: req.params.id,
|
|
progress
|
|
}));
|
|
}
|
|
});
|
|
};
|
|
|
|
const results = await claudeService.executeOperations(req.params.id, response, onProgress);
|
|
res.json({ results });
|
|
} catch (error) {
|
|
console.error('Error executing operations:', error);
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Update session metadata
|
|
app.patch('/claude/api/claude/sessions/:id', requireAuth, async (req, res) => {
|
|
try {
|
|
const { metadata } = req.body;
|
|
const sessionId = req.params.id;
|
|
|
|
// Get session
|
|
let session = claudeService.sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
// Try to load from historical sessions
|
|
const historicalSessions = claudeService.loadHistoricalSessions();
|
|
const historical = historicalSessions.find(s => s.id === sessionId);
|
|
|
|
if (!historical) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
// For historical sessions, we can't update metadata directly
|
|
// Return error for now
|
|
return res.status(400).json({ error: 'Cannot update historical session metadata' });
|
|
}
|
|
|
|
// Update metadata
|
|
if (metadata && typeof metadata === 'object') {
|
|
session.metadata = { ...session.metadata, ...metadata };
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
session: {
|
|
id: session.id,
|
|
metadata: session.metadata,
|
|
workingDir: session.workingDir
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating session:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Duplicate session
|
|
app.post('/claude/api/claude/sessions/:id/duplicate', requireAuth, (req, res) => {
|
|
try {
|
|
const sessionId = req.params.id;
|
|
|
|
// Get source session
|
|
let sourceSession = claudeService.sessions.get(sessionId);
|
|
|
|
if (!sourceSession) {
|
|
return res.status(404).json({ error: 'Source session not found' });
|
|
}
|
|
|
|
// Security check: validate workingDir is within VAULT_PATH
|
|
const fullPath = path.resolve(sourceSession.workingDir);
|
|
if (!fullPath.startsWith(VAULT_PATH)) {
|
|
return res.status(400).json({ error: 'Invalid working directory' });
|
|
}
|
|
|
|
// Get projectId from source session (if exists)
|
|
const sourceProjectId = sourceSession.metadata.projectId || null;
|
|
|
|
// Create new session with same settings
|
|
const newSession = claudeService.createSession({
|
|
workingDir: sourceSession.workingDir,
|
|
metadata: {
|
|
...sourceSession.metadata,
|
|
duplicatedFrom: sessionId,
|
|
source: 'web-ide'
|
|
}
|
|
});
|
|
|
|
// Store duplicated session in database with same projectId as source
|
|
db.prepare(`
|
|
INSERT INTO sessions (id, projectId, deletedAt)
|
|
VALUES (?, ?, NULL)
|
|
`).run(newSession.id, sourceProjectId);
|
|
|
|
// Update project's lastActivity if session was assigned to a project
|
|
if (sourceProjectId) {
|
|
const now = new Date().toISOString();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET lastActivity = ?
|
|
WHERE id = ?
|
|
`).run(now, sourceProjectId);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
session: {
|
|
id: newSession.id,
|
|
pid: newSession.pid,
|
|
workingDir: newSession.workingDir,
|
|
status: newSession.status,
|
|
createdAt: newSession.createdAt,
|
|
metadata: newSession.metadata,
|
|
projectId: sourceProjectId
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error duplicating session:', error);
|
|
res.status(500).json({ error: 'Failed to duplicate session' });
|
|
}
|
|
});
|
|
|
|
// Delete session
|
|
app.delete('/claude/api/claude/sessions/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const sessionId = req.params.id;
|
|
|
|
// Check if session exists
|
|
const session = claudeService.sessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
// Kill the claude process if running using service method
|
|
if (session.status === 'running' && session.process) {
|
|
try {
|
|
claudeService.terminateSession(sessionId);
|
|
} catch (error) {
|
|
// If termination fails, log but continue with deletion
|
|
console.error(`Error terminating session ${sessionId}:`, error.message);
|
|
}
|
|
}
|
|
|
|
// Remove from sessions map
|
|
claudeService.sessions.delete(sessionId);
|
|
|
|
// Delete session file if exists
|
|
const sessionFile = path.join(claudeService.claudeSessionsDir, `${sessionId}.md`);
|
|
|
|
if (fs.existsSync(sessionFile)) {
|
|
fs.unlinkSync(sessionFile);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error deleting session:', error);
|
|
res.status(500).json({ error: 'Failed to delete session' });
|
|
}
|
|
});
|
|
|
|
// Move session to different project
|
|
app.post('/claude/api/claude/sessions/:id/move', requireAuth, (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { projectId } = req.body;
|
|
|
|
// Check if session exists (in-memory or historical) - fetch once
|
|
let session = claudeService.sessions.get(id);
|
|
let isActiveSession = !!session;
|
|
|
|
if (!session) {
|
|
// Check historical sessions
|
|
const historicalSessions = claudeService.loadHistoricalSessions();
|
|
session = historicalSessions.find(s => s.id === id);
|
|
}
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
// If projectId is provided, validate it exists and is not deleted
|
|
if (projectId !== null && projectId !== undefined) {
|
|
const validatedId = validateProjectId(projectId);
|
|
if (!validatedId) {
|
|
return res.status(400).json({ error: 'Invalid project ID' });
|
|
}
|
|
|
|
const project = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE id = ? AND deletedAt IS NULL
|
|
`).get(validatedId);
|
|
|
|
if (!project) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Update session's metadata with projectId
|
|
if (isActiveSession) {
|
|
// Active session - update metadata and persist to database
|
|
session.metadata.projectId = validatedId;
|
|
db.prepare(`
|
|
INSERT OR REPLACE INTO sessions (id, projectId, deletedAt)
|
|
VALUES (?, ?, NULL)
|
|
`).run(id, validatedId);
|
|
} else {
|
|
// Historical session - update in database
|
|
db.prepare(`
|
|
INSERT OR REPLACE INTO sessions (id, projectId, deletedAt)
|
|
VALUES (?, ?, NULL)
|
|
`).run(id, validatedId);
|
|
}
|
|
} else {
|
|
// Move to unassigned (projectId = null)
|
|
if (isActiveSession) {
|
|
// Active session - remove projectId from metadata and persist to database
|
|
delete session.metadata.projectId;
|
|
db.prepare(`
|
|
INSERT OR REPLACE INTO sessions (id, projectId, deletedAt)
|
|
VALUES (?, NULL, NULL)
|
|
`).run(id);
|
|
} else {
|
|
// Historical session - update in database
|
|
db.prepare(`
|
|
INSERT OR REPLACE INTO sessions (id, projectId, deletedAt)
|
|
VALUES (?, NULL, NULL)
|
|
`).run(id);
|
|
}
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error moving session:', error);
|
|
res.status(500).json({ error: 'Failed to move session' });
|
|
}
|
|
});
|
|
|
|
// Preview Management - Start preview server
|
|
app.post('/claude/api/claude/sessions/:id/preview/start', requireAuth, async (req, res) => {
|
|
try {
|
|
const { workingDir } = req.body;
|
|
|
|
// For now, return a simple response
|
|
// In a full implementation, this would integrate with the workflow service
|
|
// to detect project type and start appropriate dev server
|
|
res.json({
|
|
success: true,
|
|
url: null,
|
|
port: null,
|
|
processId: null,
|
|
message: 'Preview functionality requires workflow service integration'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error starting preview:', error);
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Preview Management - Stop preview server
|
|
app.post('/claude/api/claude/sessions/:id/preview/stop', requireAuth, async (req, res) => {
|
|
try {
|
|
// For now, return a simple response
|
|
// In a full implementation, this would stop the preview server
|
|
res.json({
|
|
success: true,
|
|
message: 'Preview stopped'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error stopping preview:', error);
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Project Management
|
|
app.get('/claude/api/claude/projects', requireAuth, (req, res) => {
|
|
try {
|
|
const projectsDir = path.join(VAULT_PATH, 'Claude Projects');
|
|
let projects = [];
|
|
|
|
if (fs.existsSync(projectsDir)) {
|
|
const files = fs.readdirSync(projectsDir);
|
|
const projectFiles = files.filter(f =>
|
|
f.endsWith('.md') && !f.includes('active-projects') && !f.includes('templates')
|
|
);
|
|
|
|
projects = projectFiles.map(file => {
|
|
const filepath = path.join(projectsDir, file);
|
|
const stat = fs.statSync(filepath);
|
|
|
|
return {
|
|
name: file.replace('.md', ''),
|
|
path: `Claude Projects/${file}`,
|
|
modified: stat.mtime
|
|
};
|
|
});
|
|
}
|
|
|
|
res.json({ projects });
|
|
} catch (error) {
|
|
console.error('Error listing projects:', error);
|
|
res.status(500).json({ error: 'Failed to list projects' });
|
|
}
|
|
});
|
|
|
|
app.post('/claude/api/claude/projects', requireAuth, (req, res) => {
|
|
try {
|
|
const { name, description, type } = req.body;
|
|
const projectsDir = path.join(VAULT_PATH, 'Claude Projects');
|
|
|
|
if (!fs.existsSync(projectsDir)) {
|
|
fs.mkdirSync(projectsDir, { recursive: true });
|
|
}
|
|
|
|
const filename = `${name}.md`;
|
|
const filepath = path.join(projectsDir, filename);
|
|
|
|
if (fs.existsSync(filepath)) {
|
|
return res.status(409).json({ error: 'Project already exists' });
|
|
}
|
|
|
|
const content = `---
|
|
type: project
|
|
name: ${name}
|
|
created: ${new Date().toISOString()}
|
|
status: planning
|
|
---
|
|
|
|
# ${name}
|
|
|
|
${description || ''}
|
|
|
|
## Planning
|
|
|
|
\`\`\`tasks
|
|
path includes ${name}
|
|
not done
|
|
sort by created
|
|
\`\`\`
|
|
|
|
## Tasks
|
|
|
|
## Notes
|
|
|
|
---
|
|
|
|
Created via Claude Code Web IDE
|
|
`;
|
|
|
|
fs.writeFileSync(filepath, content, 'utf-8');
|
|
|
|
res.json({
|
|
success: true,
|
|
project: {
|
|
name,
|
|
path: `Claude Projects/${filename}`
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating project:', error);
|
|
res.status(500).json({ error: 'Failed to create project' });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// Project CRUD API Endpoints (SQLite)
|
|
// ============================================
|
|
|
|
// Helper function to validate project ID
|
|
function validateProjectId(id) {
|
|
const idNum = parseInt(id, 10);
|
|
if (isNaN(idNum) || idNum <= 0 || !Number.isInteger(idNum)) {
|
|
return null;
|
|
}
|
|
return idNum;
|
|
}
|
|
|
|
// Helper function to validate project path is within allowed scope
|
|
function validateProjectPath(projectPath) {
|
|
// Resolve the absolute path
|
|
const resolvedPath = path.resolve(projectPath);
|
|
|
|
// For now, we'll allow any absolute path
|
|
// In production, you might want to restrict to specific directories
|
|
// Check if it's an absolute path
|
|
if (!path.isAbsolute(resolvedPath)) {
|
|
return { valid: false, error: 'Path must be absolute' };
|
|
}
|
|
|
|
// Check for path traversal attempts
|
|
const normalizedPath = path.normalize(projectPath);
|
|
if (normalizedPath !== projectPath && !projectPath.startsWith('..')) {
|
|
// Path contained relative components that were normalized
|
|
return { valid: false, error: 'Path contains invalid components' };
|
|
}
|
|
|
|
return { valid: true, path: resolvedPath };
|
|
}
|
|
|
|
// GET /api/projects - List all active projects
|
|
app.get('/api/projects', requireAuth, (req, res) => {
|
|
try {
|
|
const projects = db.prepare(`
|
|
SELECT id, name, description, icon, color, path, createdAt, lastActivity
|
|
FROM projects
|
|
WHERE deletedAt IS NULL
|
|
ORDER BY lastActivity DESC
|
|
`).all();
|
|
|
|
// Add sessionCount (0 for now, will be implemented in Task 3)
|
|
const projectsWithSessionCount = projects.map(project => ({
|
|
...project,
|
|
sessionCount: 0
|
|
}));
|
|
|
|
res.json({
|
|
success: true,
|
|
projects: projectsWithSessionCount
|
|
});
|
|
} catch (error) {
|
|
console.error('Error listing projects:', error);
|
|
res.status(500).json({ error: 'Failed to list projects' });
|
|
}
|
|
});
|
|
|
|
// POST /api/projects - Create new project
|
|
app.post('/api/projects', requireAuth, (req, res) => {
|
|
try {
|
|
const { name, path: projectPath, description, icon, color } = req.body;
|
|
|
|
// Validate required fields
|
|
if (!name || !projectPath) {
|
|
return res.status(400).json({ error: 'Name and path are required' });
|
|
}
|
|
|
|
// Validate path
|
|
const pathValidation = validateProjectPath(projectPath);
|
|
if (!pathValidation.valid) {
|
|
return res.status(400).json({ error: pathValidation.error });
|
|
}
|
|
|
|
// Check for duplicate names (only among non-deleted projects)
|
|
const existing = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE name = ? AND deletedAt IS NULL
|
|
`).get(name);
|
|
|
|
if (existing) {
|
|
return res.status(409).json({ error: 'Project with this name already exists' });
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
// Set defaults
|
|
const projectIcon = icon || '📁';
|
|
const projectColor = color || '#4a9eff';
|
|
|
|
const result = db.prepare(`
|
|
INSERT INTO projects (name, description, icon, color, path, createdAt, lastActivity)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(name, description || null, projectIcon, projectColor, pathValidation.path, now, now);
|
|
|
|
// Get the inserted project
|
|
const project = db.prepare(`
|
|
SELECT id, name, description, icon, color, path, createdAt, lastActivity
|
|
FROM projects
|
|
WHERE id = ?
|
|
`).get(result.lastInsertRowid);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
project: {
|
|
...project,
|
|
sessionCount: 0
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating project:', error);
|
|
res.status(500).json({ error: 'Failed to create project' });
|
|
}
|
|
});
|
|
|
|
// PUT /api/projects/:id - Update project
|
|
app.put('/api/projects/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { name, description, icon, color, path: projectPath } = req.body;
|
|
|
|
// Validate ID
|
|
const validatedId = validateProjectId(id);
|
|
if (!validatedId) {
|
|
return res.status(400).json({ error: 'Invalid project ID' });
|
|
}
|
|
|
|
// Validate path if provided
|
|
if (projectPath !== undefined) {
|
|
const pathValidation = validateProjectPath(projectPath);
|
|
if (!pathValidation.valid) {
|
|
return res.status(400).json({ error: pathValidation.error });
|
|
}
|
|
}
|
|
|
|
// Check if project exists and is not deleted
|
|
const existing = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE id = ? AND deletedAt IS NULL
|
|
`).get(validatedId);
|
|
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// If updating name, check for duplicates
|
|
if (name) {
|
|
const duplicate = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE name = ? AND id != ? AND deletedAt IS NULL
|
|
`).get(name, validatedId);
|
|
|
|
if (duplicate) {
|
|
return res.status(409).json({ error: 'Project with this name already exists' });
|
|
}
|
|
}
|
|
|
|
// Build update query dynamically based on provided fields
|
|
const updates = [];
|
|
const values = [];
|
|
|
|
if (name !== undefined) {
|
|
updates.push('name = ?');
|
|
values.push(name);
|
|
}
|
|
if (description !== undefined) {
|
|
updates.push('description = ?');
|
|
values.push(description);
|
|
}
|
|
if (icon !== undefined) {
|
|
updates.push('icon = ?');
|
|
values.push(icon);
|
|
}
|
|
if (color !== undefined) {
|
|
updates.push('color = ?');
|
|
values.push(color);
|
|
}
|
|
if (projectPath !== undefined) {
|
|
updates.push('path = ?');
|
|
const pathValidation = validateProjectPath(projectPath);
|
|
values.push(pathValidation.path);
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
return res.status(400).json({ error: 'No fields to update' });
|
|
}
|
|
|
|
values.push(validatedId);
|
|
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET ${updates.join(', ')}
|
|
WHERE id = ? AND deletedAt IS NULL
|
|
`).run(...values);
|
|
|
|
// Get the updated project
|
|
const project = db.prepare(`
|
|
SELECT id, name, description, icon, color, path, createdAt, lastActivity
|
|
FROM projects
|
|
WHERE id = ?
|
|
`).get(validatedId);
|
|
|
|
res.json({
|
|
success: true,
|
|
project: {
|
|
...project,
|
|
sessionCount: 0
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating project:', error);
|
|
res.status(500).json({ error: 'Failed to update project' });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/projects/:id - Soft delete project
|
|
app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Validate ID
|
|
const validatedId = validateProjectId(id);
|
|
if (!validatedId) {
|
|
return res.status(400).json({ error: 'Invalid project ID' });
|
|
}
|
|
|
|
// Check if project exists and is not already deleted
|
|
const existing = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE id = ? AND deletedAt IS NULL
|
|
`).get(validatedId);
|
|
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Soft delete by setting deletedAt
|
|
const now = new Date().toISOString();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET deletedAt = ?
|
|
WHERE id = ?
|
|
`).run(now, validatedId);
|
|
|
|
// Soft delete all sessions associated with this project
|
|
db.prepare(`
|
|
UPDATE sessions
|
|
SET deletedAt = ?
|
|
WHERE projectId = ?
|
|
`).run(now, validatedId);
|
|
|
|
// Also update in-memory sessions
|
|
for (const [sessionId, session] of claudeService.sessions.entries()) {
|
|
if (session.metadata.projectId === validatedId) {
|
|
// Mark as deleted in metadata
|
|
session.metadata.deletedAt = now;
|
|
}
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error soft deleting project:', error);
|
|
res.status(500).json({ error: 'Failed to delete project' });
|
|
}
|
|
});
|
|
|
|
// POST /api/projects/:id/restore - Restore project from recycle bin
|
|
app.post('/api/projects/:id/restore', requireAuth, (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Validate ID
|
|
const validatedId = validateProjectId(id);
|
|
if (!validatedId) {
|
|
return res.status(400).json({ error: 'Invalid project ID' });
|
|
}
|
|
|
|
// Check if project exists and is in recycle bin
|
|
const existing = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE id = ? AND deletedAt IS NOT NULL
|
|
`).get(validatedId);
|
|
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'Project not found in recycle bin' });
|
|
}
|
|
|
|
// Restore by setting deletedAt to NULL
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET deletedAt = NULL
|
|
WHERE id = ?
|
|
`).run(validatedId);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error restoring project:', error);
|
|
res.status(500).json({ error: 'Failed to restore project' });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/projects/:id/permanent - Permanently delete project
|
|
app.delete('/api/projects/:id/permanent', requireAuth, (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Validate ID
|
|
const validatedId = validateProjectId(id);
|
|
if (!validatedId) {
|
|
return res.status(400).json({ error: 'Invalid project ID' });
|
|
}
|
|
|
|
// Check if project exists
|
|
const existing = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE id = ?
|
|
`).get(validatedId);
|
|
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Permanently delete the project
|
|
db.prepare(`
|
|
DELETE FROM projects
|
|
WHERE id = ?
|
|
`).run(validatedId);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error permanently deleting project:', error);
|
|
res.status(500).json({ error: 'Failed to permanently delete project' });
|
|
}
|
|
});
|
|
|
|
// GET /api/recycle-bin - List deleted items
|
|
app.get('/api/recycle-bin', requireAuth, (req, res) => {
|
|
try {
|
|
const items = db.prepare(`
|
|
SELECT id, name, description, icon, color, path, deletedAt
|
|
FROM projects
|
|
WHERE deletedAt IS NOT NULL
|
|
ORDER BY deletedAt DESC
|
|
`).all();
|
|
|
|
// Add sessionCount (0 for now, will be implemented in Task 4)
|
|
const itemsWithSessionCount = items.map(item => ({
|
|
...item,
|
|
sessionCount: 0
|
|
}));
|
|
|
|
res.json({
|
|
success: true,
|
|
items: itemsWithSessionCount
|
|
});
|
|
} catch (error) {
|
|
console.error('Error listing recycle bin:', error);
|
|
res.status(500).json({ error: 'Failed to list recycle bin' });
|
|
}
|
|
});
|
|
|
|
// GET /api/projects/suggestions - Get smart project suggestions for a session
|
|
app.get('/api/projects/suggestions', requireAuth, (req, res) => {
|
|
try {
|
|
const { sessionId } = req.query;
|
|
|
|
// Validate sessionId parameter
|
|
if (!sessionId) {
|
|
return res.status(400).json({ error: 'sessionId parameter is required' });
|
|
}
|
|
|
|
// Get the session from in-memory or historical
|
|
let session = claudeService.sessions.get(sessionId);
|
|
if (!session) {
|
|
// Try historical sessions
|
|
const historicalSessions = claudeService.loadHistoricalSessions();
|
|
session = historicalSessions.find(s => s.id === sessionId);
|
|
}
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
// Get all active projects
|
|
const projects = db.prepare(`
|
|
SELECT id, name, icon, color, path, lastActivity
|
|
FROM projects
|
|
WHERE deletedAt IS NULL
|
|
`).all();
|
|
|
|
const sessionWorkingDir = session.workingDir || '';
|
|
const now = new Date();
|
|
|
|
// Calculate scores for each project
|
|
const scoredProjects = projects.map(project => {
|
|
let score = 0;
|
|
const reasons = [];
|
|
|
|
// 1. Directory match: session workingDir === project path (90 points)
|
|
if (sessionWorkingDir === project.path) {
|
|
score += 90;
|
|
reasons.push('Same directory');
|
|
}
|
|
|
|
// 2. Subdirectory: session workingDir startsWith project path (50 points)
|
|
// Only if not already an exact match
|
|
else if (sessionWorkingDir.startsWith(project.path + '/')) {
|
|
score += 50;
|
|
reasons.push('Subdirectory');
|
|
}
|
|
|
|
// 3. Recent use: project used today (<1 day ago) (20 points)
|
|
if (project.lastActivity) {
|
|
const lastActivityDate = new Date(project.lastActivity);
|
|
const daysSinceActivity = Math.floor((now - lastActivityDate) / (1000 * 60 * 60 * 24));
|
|
|
|
if (daysSinceActivity < 1) {
|
|
score += 20;
|
|
reasons.push('Used today');
|
|
}
|
|
// 4. Week-old use: project used this week (<7 days ago) (10 points)
|
|
else if (daysSinceActivity < 7) {
|
|
score += 10;
|
|
reasons.push(`Used ${daysSinceActivity} days ago`);
|
|
}
|
|
}
|
|
|
|
// 5. Name overlap: check if session workingDir contains project name or vice versa (15 points)
|
|
const sessionDirName = path.basename(sessionWorkingDir).toLowerCase();
|
|
const projectNameLower = project.name.toLowerCase();
|
|
|
|
if (sessionDirName && projectNameLower &&
|
|
(sessionDirName.includes(projectNameLower) || projectNameLower.includes(sessionDirName))) {
|
|
score += 15;
|
|
reasons.push('Similar name');
|
|
}
|
|
|
|
return {
|
|
id: project.id,
|
|
name: project.name,
|
|
icon: project.icon,
|
|
color: project.color,
|
|
score,
|
|
reasons
|
|
};
|
|
});
|
|
|
|
// Sort by score descending
|
|
scoredProjects.sort((a, b) => b.score - a.score);
|
|
|
|
// Get top 3 suggestions
|
|
const suggestions = scoredProjects
|
|
.filter(p => p.score > 0) // Only include projects with positive scores
|
|
.slice(0, 3);
|
|
|
|
// Get all projects sorted by name
|
|
const allProjects = projects
|
|
.map(p => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
icon: p.icon,
|
|
color: p.color
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
res.json({
|
|
success: true,
|
|
suggestions,
|
|
allProjects
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting project suggestions:', error);
|
|
res.status(500).json({ error: 'Failed to get project suggestions' });
|
|
}
|
|
});
|
|
|
|
// ===== Terminal API Endpoints =====
|
|
|
|
// Create a new terminal
|
|
app.post('/claude/api/terminals', requireAuth, (req, res) => {
|
|
try {
|
|
const { workingDir, sessionId, mode } = req.body;
|
|
|
|
const result = terminalService.createTerminal({
|
|
workingDir,
|
|
sessionId,
|
|
mode
|
|
});
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
terminalId: result.terminalId,
|
|
terminal: result.terminal
|
|
});
|
|
} else {
|
|
res.status(500).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating terminal:', error);
|
|
res.status(500).json({ error: 'Failed to create terminal' });
|
|
}
|
|
});
|
|
|
|
// Get list of active terminals
|
|
app.get('/claude/api/terminals', requireAuth, (req, res) => {
|
|
try {
|
|
const terminals = terminalService.listTerminals();
|
|
res.json({ success: true, terminals });
|
|
} catch (error) {
|
|
console.error('Error listing terminals:', error);
|
|
res.status(500).json({ error: 'Failed to list terminals' });
|
|
}
|
|
});
|
|
|
|
// Get specific terminal info
|
|
app.get('/claude/api/terminals/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const result = terminalService.getTerminal(req.params.id);
|
|
|
|
if (result.success) {
|
|
res.json(result);
|
|
} else {
|
|
res.status(404).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting terminal:', error);
|
|
res.status(500).json({ error: 'Failed to get terminal' });
|
|
}
|
|
});
|
|
|
|
// Set terminal mode
|
|
app.post('/claude/api/terminals/:id/mode', requireAuth, (req, res) => {
|
|
try {
|
|
const { mode } = req.body;
|
|
const result = terminalService.setTerminalMode(req.params.id, mode);
|
|
|
|
if (result.success) {
|
|
res.json({ success: true, mode: result.mode });
|
|
} else {
|
|
res.status(404).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error setting terminal mode:', error);
|
|
res.status(500).json({ error: 'Failed to set terminal mode' });
|
|
}
|
|
});
|
|
|
|
// Attach terminal to session
|
|
app.post('/claude/api/terminals/:id/attach', requireAuth, async (req, res) => {
|
|
try {
|
|
const { sessionId } = req.body;
|
|
|
|
// Get the session
|
|
const session = claudeService.getSession(sessionId);
|
|
|
|
if (!session) {
|
|
return res.status(404).json({ error: 'Session not found' });
|
|
}
|
|
|
|
const result = terminalService.attachToSession(req.params.id, session);
|
|
|
|
if (result.success) {
|
|
res.json({ success: true });
|
|
} else {
|
|
res.status(500).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error attaching terminal to session:', error);
|
|
res.status(500).json({ error: 'Failed to attach terminal' });
|
|
}
|
|
});
|
|
|
|
// Close terminal
|
|
app.delete('/claude/api/terminals/:id', requireAuth, (req, res) => {
|
|
try {
|
|
const result = terminalService.closeTerminal(req.params.id);
|
|
|
|
if (result.success) {
|
|
res.json({ success: true });
|
|
} else {
|
|
res.status(404).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error closing terminal:', error);
|
|
res.status(500).json({ error: 'Failed to close terminal' });
|
|
}
|
|
});
|
|
|
|
// Send input to terminal via HTTP (WebSocket workaround)
|
|
app.post('/claude/api/terminals/:id/input', requireAuth, (req, res) => {
|
|
try {
|
|
const { data } = req.body;
|
|
|
|
if (!data) {
|
|
res.status(400).json({ error: 'Missing data parameter' });
|
|
return;
|
|
}
|
|
|
|
const result = terminalService.sendTerminalInput(req.params.id, data);
|
|
|
|
if (result.success) {
|
|
res.json({ success: true });
|
|
} else {
|
|
res.status(404).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error sending terminal input:', error);
|
|
res.status(500).json({ error: 'Failed to send input' });
|
|
}
|
|
});
|
|
|
|
// Get recent directories for terminal picker
|
|
app.get('/claude/api/files/recent-dirs', requireAuth, (req, res) => {
|
|
try {
|
|
// Get recent directories from terminal state or provide defaults
|
|
const recentDirs = [
|
|
process.env.HOME,
|
|
process.env.HOME + '/obsidian-vault',
|
|
process.cwd()
|
|
];
|
|
|
|
// Add directories from active terminals
|
|
const terminals = terminalService.listTerminals();
|
|
const terminalDirs = terminals.map(t => t.workingDir);
|
|
|
|
// Merge and deduplicate
|
|
const allDirs = [...new Set([...recentDirs, ...terminalDirs])];
|
|
|
|
res.json({ success: true, directories: allDirs });
|
|
} catch (error) {
|
|
console.error('Error getting recent directories:', error);
|
|
res.status(500).json({ error: 'Failed to get recent directories' });
|
|
}
|
|
});
|
|
|
|
// Get saved terminal state for restoration
|
|
app.get('/claude/api/claude/terminal-restore', requireAuth, async (req, res) => {
|
|
try {
|
|
const statePath = path.join(VAULT_PATH, '.claude-ide', 'terminal-state.json');
|
|
|
|
try {
|
|
const stateData = await fs.promises.readFile(statePath, 'utf-8');
|
|
const state = JSON.parse(stateData);
|
|
|
|
res.json({ success: true, state });
|
|
} catch (error) {
|
|
// No saved state exists
|
|
res.json({ success: true, state: null });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting terminal state:', error);
|
|
res.status(500).json({ error: 'Failed to get terminal state' });
|
|
}
|
|
});
|
|
|
|
// Get local Claude CLI sessions
|
|
app.get('/claude/api/claude/local-sessions', requireAuth, async (req, res) => {
|
|
try {
|
|
const claudeDir = path.join(process.env.HOME, '.claude');
|
|
const sessionsDir = path.join(claudeDir, 'sessions');
|
|
|
|
const localSessions = [];
|
|
|
|
// Check if sessions directory exists
|
|
try {
|
|
const sessionDirs = await fs.promises.readdir(sessionsDir);
|
|
|
|
for (const sessionDir of sessionDirs) {
|
|
const sessionPath = path.join(sessionsDir, sessionDir);
|
|
const stats = await fs.promises.stat(sessionPath);
|
|
|
|
if (!stats.isDirectory()) continue;
|
|
|
|
// Read session info
|
|
const infoPath = path.join(sessionPath, 'info.json');
|
|
const infoData = await fs.promises.readFile(infoPath, 'utf-8');
|
|
const info = JSON.parse(infoData);
|
|
|
|
// Check if session file exists
|
|
const sessionFilePath = path.join(sessionPath, 'session.json');
|
|
let hasSessionFile = false;
|
|
try {
|
|
await fs.promises.access(sessionFilePath);
|
|
hasSessionFile = true;
|
|
} catch {
|
|
// Session file doesn't exist
|
|
}
|
|
|
|
localSessions.push({
|
|
id: sessionDir,
|
|
type: 'local',
|
|
createdAt: info.createdAt || new Date(stats.mtime).toISOString(),
|
|
lastActivity: info.lastMessageAt || new Date(stats.mtime).toISOString(),
|
|
projectName: info.projectName || 'Local Session',
|
|
status: 'local', // Local sessions don't have running status
|
|
hasSessionFile
|
|
});
|
|
}
|
|
|
|
// Sort by last activity
|
|
localSessions.sort((a, b) => {
|
|
const dateA = new Date(a.lastActivity);
|
|
const dateB = new Date(b.lastActivity);
|
|
return dateB - dateA;
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error reading local sessions:', error);
|
|
}
|
|
|
|
res.json({ success: true, sessions: localSessions });
|
|
} catch (error) {
|
|
console.error('Error getting local sessions:', error);
|
|
res.status(500).json({ error: 'Failed to get local sessions' });
|
|
}
|
|
});
|
|
|
|
// Start server and WebSocket server
|
|
const server = app.listen(PORT, () => {
|
|
console.log(`Obsidian Web Interface running on port ${PORT}`);
|
|
console.log(`Access at: http://localhost:${PORT}/claude`);
|
|
});
|
|
|
|
// Initialize terminal service WebSocket server
|
|
terminalService.createServer(server);
|
|
|
|
// WebSocket server for real-time chat
|
|
const wss = new WebSocket.Server({
|
|
server,
|
|
path: '/claude/api/claude/chat',
|
|
verifyClient: (info, cb) => {
|
|
// Parse session cookie
|
|
const sessionCookie = info.req.headers.cookie?.match(/connect.sid=([^;]+)/);
|
|
if (!sessionCookie) {
|
|
console.log('WebSocket rejected: No session cookie');
|
|
return cb(false, 401, 'Unauthorized: No session cookie');
|
|
}
|
|
|
|
// Store session ID for later use
|
|
info.req.sessionId = sessionCookie[1];
|
|
|
|
// Accept the connection (we'll validate session fully after connection)
|
|
console.log('WebSocket connection accepted for session:', sessionCookie[1].substring(0, 10) + '...');
|
|
cb(true);
|
|
}
|
|
});
|
|
|
|
// Store connected clients with their session IDs
|
|
const clients = new Map();
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
const clientId = Math.random().toString(36).substr(2, 9);
|
|
const sessionId = req.sessionId;
|
|
|
|
console.log(`WebSocket client connected: ${clientId}, session: ${sessionId ? sessionId.substring(0, 10) : 'none'}...`);
|
|
|
|
// Store client info
|
|
clients.set(clientId, { ws, sessionId, userId: null });
|
|
|
|
ws.on('message', async (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
console.log('WebSocket message received:', data.type);
|
|
|
|
if (data.type === 'command') {
|
|
const { sessionId, command } = data;
|
|
|
|
console.log(`Sending command to session ${sessionId}: ${command.substring(0, 50)}...`);
|
|
|
|
// Send command to Claude Code
|
|
try {
|
|
claudeService.sendCommand(sessionId, command);
|
|
console.log(`Command sent successfully to session ${sessionId}`);
|
|
} catch (error) {
|
|
console.error('Error sending command:', error);
|
|
ws.send(JSON.stringify({
|
|
type: 'error',
|
|
error: error.message
|
|
}));
|
|
}
|
|
} else if (data.type === 'subscribe') {
|
|
// Subscribe to a specific session's output
|
|
const { sessionId } = data;
|
|
const client = clients.get(clientId);
|
|
if (client) {
|
|
client.sessionId = sessionId;
|
|
console.log(`Client ${clientId} subscribed to session ${sessionId}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('WebSocket error:', error);
|
|
ws.send(JSON.stringify({
|
|
type: 'error',
|
|
error: error.message
|
|
}));
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
console.log(`WebSocket client disconnected: ${clientId}`);
|
|
clients.delete(clientId);
|
|
});
|
|
|
|
// Send connected message
|
|
ws.send(JSON.stringify({
|
|
type: 'connected',
|
|
message: 'Connected to Claude Code chat',
|
|
clientId
|
|
}));
|
|
});
|
|
|
|
// Forward Claude Code output to all subscribed WebSocket clients
|
|
claudeService.on('session-output', (output) => {
|
|
console.log(`Session output for ${output.sessionId}:`, output.type);
|
|
console.log(`Content preview:`, output.content.substring(0, 100));
|
|
|
|
// Send to all clients subscribed to this session
|
|
let clientsSent = 0;
|
|
clients.forEach((client, clientId) => {
|
|
if (client.sessionId === output.sessionId && client.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
client.ws.send(JSON.stringify({
|
|
type: 'output',
|
|
sessionId: output.sessionId,
|
|
data: output
|
|
}));
|
|
clientsSent++;
|
|
console.log(`✓ Sent output to client ${clientId}`);
|
|
} catch (error) {
|
|
console.error(`Error sending to client ${clientId}:`, error);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (clientsSent === 0) {
|
|
console.log(`⚠ No clients subscribed to session ${output.sessionId}`);
|
|
}
|
|
});
|
|
|
|
// Forward operations-detected event
|
|
claudeService.on('operations-detected', (data) => {
|
|
console.log(`Operations detected for session ${data.sessionId}:`, data.operations.length, 'operations');
|
|
|
|
clients.forEach((client, clientId) => {
|
|
if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
client.ws.send(JSON.stringify({
|
|
type: 'operations-detected',
|
|
sessionId: data.sessionId,
|
|
response: data.response,
|
|
tags: data.tags,
|
|
operations: data.operations
|
|
}));
|
|
} catch (error) {
|
|
console.error(`Error sending operations to client ${clientId}:`, error);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Forward operations-executed event
|
|
claudeService.on('operations-executed', (data) => {
|
|
console.log(`Operations executed for session ${data.sessionId}`);
|
|
|
|
clients.forEach((client, clientId) => {
|
|
if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
client.ws.send(JSON.stringify({
|
|
type: 'operations-executed',
|
|
sessionId: data.sessionId,
|
|
results: data.results
|
|
}));
|
|
} catch (error) {
|
|
console.error(`Error sending execution results to client ${clientId}:`, error);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Forward operations-error event
|
|
claudeService.on('operations-error', (data) => {
|
|
console.error(`Operations error for session ${data.sessionId}:`, data.error);
|
|
|
|
clients.forEach((client, clientId) => {
|
|
if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
client.ws.send(JSON.stringify({
|
|
type: 'operations-error',
|
|
sessionId: data.sessionId,
|
|
error: data.error
|
|
}));
|
|
} catch (error) {
|
|
console.error(`Error sending error to client ${clientId}:`, error);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
console.log(`WebSocket server running on ws://localhost:${PORT}/claude/api/claude/chat`);
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGINT', async () => {
|
|
console.log('\nShutting down gracefully...');
|
|
await terminalService.cleanup();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGTERM', async () => {
|
|
console.log('\nReceived SIGTERM, shutting down gracefully...');
|
|
await terminalService.cleanup();
|
|
process.exit(0);
|
|
});
|