- Modified loadChatHistory() to check for active project before fetching all sessions - When active project exists, use project.sessions instead of fetching from API - Added detailed console logging to debug session filtering - This prevents ALL sessions from appearing in every project's sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2883 lines
86 KiB
Plaintext
2883 lines
86 KiB
Plaintext
const express = require('express');
|
|
const session = require('express-session');
|
|
const bcrypt = require('bcryptjs');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
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();
|
|
|
|
// Store recent browser errors for debugging
|
|
const recentErrors = [];
|
|
|
|
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);
|
|
|
|
// ============================================================
|
|
// Pending Approvals Manager
|
|
// ============================================================
|
|
|
|
class PendingApprovalsManager {
|
|
constructor() {
|
|
this.approvals = new Map();
|
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60000); // Cleanup every minute
|
|
}
|
|
|
|
/**
|
|
* Create a new pending approval
|
|
* @param {string} sessionId - Session ID requesting approval
|
|
* @param {string} command - Command awaiting approval
|
|
* @param {string} explanation - Human-readable explanation
|
|
* @returns {string} Approval ID
|
|
*/
|
|
createApproval(sessionId, command, explanation) {
|
|
const id = `approval-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
const approval = {
|
|
id,
|
|
sessionId,
|
|
command,
|
|
explanation,
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + (5 * 60 * 1000) // 5 minutes
|
|
};
|
|
|
|
this.approvals.set(id, approval);
|
|
|
|
console.log(`[ApprovalManager] Created approval ${id} for session ${sessionId}`);
|
|
|
|
// Auto-expire after 5 minutes
|
|
setTimeout(() => {
|
|
this.expire(id);
|
|
}, 5 * 60 * 1000);
|
|
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Get approval by ID
|
|
* @param {string} id - Approval ID
|
|
* @returns {object|null} Approval object or null if not found/expired
|
|
*/
|
|
getApproval(id) {
|
|
const approval = this.approvals.get(id);
|
|
|
|
if (!approval) {
|
|
return null;
|
|
}
|
|
|
|
// Check if expired
|
|
if (Date.now() > approval.expiresAt) {
|
|
this.approvals.delete(id);
|
|
return null;
|
|
}
|
|
|
|
return approval;
|
|
}
|
|
|
|
/**
|
|
* Remove approval (approved/rejected/expired)
|
|
* @param {string} id - Approval ID
|
|
*/
|
|
removeApproval(id) {
|
|
const removed = this.approvals.delete(id);
|
|
if (removed) {
|
|
console.log(`[ApprovalManager] Removed approval ${id}`);
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Mark approval as expired
|
|
* @param {string} id - Approval ID
|
|
*/
|
|
expire(id) {
|
|
const approval = this.approvals.get(id);
|
|
if (approval && Date.now() < approval.expiresAt) {
|
|
// Not actually expired yet, don't remove
|
|
return;
|
|
}
|
|
|
|
this.removeApproval(id);
|
|
console.log(`[ApprovalManager] Approval ${id} expired`);
|
|
|
|
// Notify session about expiration
|
|
claudeService.emit('approval-expired', { id, sessionId: approval?.sessionId });
|
|
}
|
|
|
|
/**
|
|
* Clean up expired approvals
|
|
*/
|
|
cleanup() {
|
|
const now = Date.now();
|
|
let expiredCount = 0;
|
|
|
|
for (const [id, approval] of this.approvals.entries()) {
|
|
if (now > approval.expiresAt) {
|
|
this.approvals.delete(id);
|
|
expiredCount++;
|
|
}
|
|
}
|
|
|
|
if (expiredCount > 0) {
|
|
console.log(`[ApprovalManager] Cleaned up ${expiredCount} expired approvals`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all pending approvals for a session
|
|
* @param {string} sessionId - Session ID
|
|
* @returns {Array} Array of approval objects
|
|
*/
|
|
getApprovalsForSession(sessionId) {
|
|
const sessionApprovals = [];
|
|
|
|
for (const [id, approval] of this.approvals.entries()) {
|
|
if (approval.sessionId === sessionId && Date.now() < approval.expiresAt) {
|
|
sessionApprovals.push(approval);
|
|
}
|
|
}
|
|
|
|
return sessionApprovals;
|
|
}
|
|
|
|
/**
|
|
* Check if session has any pending approvals
|
|
* @param {string} sessionId - Session ID
|
|
* @returns {boolean} True if session has pending approvals
|
|
*/
|
|
hasPendingApproval(sessionId) {
|
|
for (const [id, approval] of this.approvals.entries()) {
|
|
if (approval.sessionId === sessionId && Date.now() < approval.expiresAt) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get statistics
|
|
*/
|
|
getStats() {
|
|
return {
|
|
total: this.approvals.size,
|
|
pending: Array.from(this.approvals.values()).filter(a => Date.now() < a.expiresAt).length
|
|
};
|
|
}
|
|
}
|
|
|
|
// Initialize approval manager
|
|
const approvalManager = new PendingApprovalsManager();
|
|
|
|
// 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
|
|
|
|
// Project URL route - decode base64 path and serve file
|
|
app.get('/p/:base64Path/', (req, res) => {
|
|
try {
|
|
const base64Path = req.params.base64Path;
|
|
|
|
// Decode base64 path
|
|
let decodedPath;
|
|
try {
|
|
decodedPath = Buffer.from(base64Path, 'base64').toString('utf-8');
|
|
} catch (error) {
|
|
console.error('Error decoding base64 path:', error);
|
|
return res.status(400).send('Invalid base64 path');
|
|
}
|
|
|
|
// Resolve the full path
|
|
const fullPath = path.resolve(decodedPath);
|
|
|
|
// Security check: ensure path is within allowed directories
|
|
// Allow access to home directory and obsidian vault
|
|
const allowedPaths = [
|
|
'/home/uroma',
|
|
VAULT_PATH
|
|
];
|
|
|
|
const isAllowed = allowedPaths.some(allowedPath => {
|
|
return fullPath.startsWith(allowedPath);
|
|
});
|
|
|
|
if (!isAllowed) {
|
|
console.error('Path outside allowed directories:', fullPath);
|
|
return res.status(403).send('Access denied');
|
|
}
|
|
|
|
// Check if file exists
|
|
if (!fs.existsSync(fullPath)) {
|
|
console.error('File not found:', fullPath);
|
|
return res.status(404).send('File not found');
|
|
}
|
|
|
|
// Check if it's a file (not a directory)
|
|
const stats = fs.statSync(fullPath);
|
|
if (stats.isDirectory()) {
|
|
// If it's a directory, try to serve index.html or list files
|
|
const indexPath = path.join(fullPath, 'index.html');
|
|
if (fs.existsSync(indexPath)) {
|
|
return res.sendFile(indexPath);
|
|
} else {
|
|
return res.status(403).send('Directory access not allowed');
|
|
}
|
|
}
|
|
|
|
// Determine content type
|
|
const ext = path.extname(fullPath).toLowerCase();
|
|
const contentTypes = {
|
|
'.html': 'text/html',
|
|
'.htm': 'text/html',
|
|
'.css': 'text/css',
|
|
'.js': 'application/javascript',
|
|
'.json': 'application/json',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
'.txt': 'text/plain',
|
|
'.md': 'text/markdown'
|
|
};
|
|
|
|
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
|
|
// Serve the file
|
|
res.setHeader('Content-Type', contentType);
|
|
res.sendFile(fullPath);
|
|
} catch (error) {
|
|
console.error('Error serving file:', error);
|
|
res.status(500).send('Internal server error');
|
|
}
|
|
});
|
|
|
|
// 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)
|
|
// Disable caching for JS files to prevent browser from serving stale code
|
|
app.use('/claude', express.static(path.join(__dirname, 'public'), {
|
|
etag: false,
|
|
lastModified: false,
|
|
setHeaders: (res, filePath) => {
|
|
if (filePath.endsWith('.js')) {
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
}
|
|
}
|
|
}));
|
|
|
|
// Serve node_modules for CodeMirror 6 import map
|
|
app.use('/claude/node_modules', express.static(path.join(__dirname, 'node_modules')));
|
|
|
|
// 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
|
|
// GET /claude/api/claude/sessions?project=/encoded/path
|
|
app.get('/claude/api/claude/sessions', requireAuth, (req, res) => {
|
|
try {
|
|
const { project } = req.query;
|
|
|
|
let activeSessions = claudeService.listSessions();
|
|
let historicalSessions = claudeService.loadHistoricalSessions();
|
|
|
|
// PROJECT FILTERING
|
|
if (project) {
|
|
const projectPath = decodeURIComponent(project);
|
|
console.log('[SESSIONS] Filtering by project path:', projectPath);
|
|
|
|
activeSessions = activeSessions.filter(s => {
|
|
const sessionPath = s.workingDir || '';
|
|
return sessionPath.startsWith(projectPath) || sessionPath === projectPath;
|
|
});
|
|
|
|
historicalSessions = historicalSessions.filter(s => {
|
|
const sessionPath = s.workingDir || '';
|
|
return sessionPath.startsWith(projectPath) || sessionPath === projectPath;
|
|
});
|
|
|
|
console.log('[SESSIONS] Filtered to', activeSessions.length, 'active,', historicalSessions.length, 'historical');
|
|
}
|
|
|
|
res.json({
|
|
active: activeSessions,
|
|
historical: historicalSessions
|
|
});
|
|
} catch (error) {
|
|
console.error('[SESSIONS] Error:', 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' });
|
|
}
|
|
}
|
|
|
|
// ===== Validate and create working directory =====
|
|
let validatedWorkingDir = workingDir || VAULT_PATH;
|
|
|
|
// Resolve to absolute path
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const resolvedPath = path.resolve(validatedWorkingDir);
|
|
|
|
// Security check: ensure path is within allowed boundaries
|
|
const allowedPaths = [
|
|
VAULT_PATH,
|
|
process.env.HOME || '/home/uroma',
|
|
'/home/uroma'
|
|
];
|
|
|
|
const isAllowed = allowedPaths.some(allowedPath => {
|
|
return resolvedPath.startsWith(allowedPath);
|
|
});
|
|
|
|
if (!isAllowed) {
|
|
console.error('[SESSIONS] Working directory outside allowed paths:', resolvedPath);
|
|
return res.status(403).json({ error: 'Working directory outside allowed paths' });
|
|
}
|
|
|
|
// Create directory if it doesn't exist
|
|
if (!fs.existsSync(resolvedPath)) {
|
|
console.log('[SESSIONS] Creating working directory:', resolvedPath);
|
|
try {
|
|
fs.mkdirSync(resolvedPath, { recursive: true });
|
|
} catch (mkdirError) {
|
|
console.error('[SESSIONS] Failed to create directory:', mkdirError);
|
|
return res.status(400).json({
|
|
error: `Failed to create working directory: ${mkdirError.message}`
|
|
});
|
|
}
|
|
}
|
|
|
|
// Verify it's actually a directory
|
|
try {
|
|
const stats = fs.statSync(resolvedPath);
|
|
if (!stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Working directory is not a directory' });
|
|
}
|
|
} catch (statError) {
|
|
console.error('[SESSIONS] Failed to stat directory:', statError);
|
|
return res.status(500).json({ error: 'Failed to validate working directory' });
|
|
}
|
|
|
|
console.log('[SESSIONS] Using working directory:', resolvedPath);
|
|
// ===== END directory validation =====
|
|
|
|
// Create session with validated path
|
|
const sessionMetadata = {
|
|
...metadata,
|
|
...(validatedProjectId ? { projectId: validatedProjectId } : {})
|
|
};
|
|
const session = claudeService.createSession({ workingDir: resolvedPath, 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' });
|
|
}
|
|
});
|
|
|
|
// Fork session from a specific message index
|
|
app.post('/claude/api/claude/sessions/:id/fork', requireAuth, (req, res) => {
|
|
try {
|
|
const sessionId = req.params.id;
|
|
const messageIndex = parseInt(req.query.messageIndex) || -1; // -1 means all messages
|
|
|
|
// 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 messages to fork (1..messageIndex)
|
|
let messagesToFork = [];
|
|
if (sourceSession.outputBuffer && sourceSession.outputBuffer.length > 0) {
|
|
if (messageIndex === -1) {
|
|
// Fork all messages
|
|
messagesToFork = [...sourceSession.outputBuffer];
|
|
} else {
|
|
// Fork messages up to messageIndex
|
|
messagesToFork = sourceSession.outputBuffer.slice(0, messageIndex + 1);
|
|
}
|
|
}
|
|
|
|
// Get projectId from source session (if exists)
|
|
const sourceProjectId = sourceSession.metadata.projectId || null;
|
|
|
|
// Create new session with forked context
|
|
const newSession = claudeService.createSession({
|
|
workingDir: sourceSession.workingDir,
|
|
metadata: {
|
|
...sourceSession.metadata,
|
|
forkedFrom: sessionId,
|
|
forkedAtMessageIndex: messageIndex,
|
|
forkedAt: new Date().toISOString(),
|
|
source: 'web-ide'
|
|
}
|
|
});
|
|
|
|
// Initialize output buffer with forked messages
|
|
if (messagesToFork.length > 0) {
|
|
newSession.outputBuffer = messagesToFork;
|
|
}
|
|
|
|
// Store forked 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);
|
|
}
|
|
|
|
console.log(`[FORK] Forked session ${sessionId} at message ${messageIndex} -> ${newSession.id}`);
|
|
console.log(`[FORK] Copied ${messagesToFork.length} messages`);
|
|
|
|
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,
|
|
messageCount: messagesToFork.length
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error forking session:', error);
|
|
res.status(500).json({ error: 'Failed to fork 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/debug/errors - View recent browser errors (debug only)
|
|
app.get('/api/debug/errors', (req, res) => {
|
|
const errors = recentErrors.slice(-20); // Last 20 errors
|
|
res.json({
|
|
total: recentErrors.length,
|
|
recent: errors
|
|
});
|
|
});
|
|
|
|
// 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 for each project
|
|
const projectsWithSessionCount = projects.map(project => {
|
|
const sessionCount = db.prepare(`
|
|
SELECT COUNT(*) as count
|
|
FROM sessions
|
|
WHERE projectId = ? AND deletedAt IS NULL
|
|
`).get(project.id);
|
|
|
|
return {
|
|
...project,
|
|
sessionCount: sessionCount?.count || 0,
|
|
sources: ['web'] // TODO: Track CLI vs Web sources
|
|
};
|
|
});
|
|
|
|
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/projects/:id/sessions - Get all sessions for a project
|
|
app.get('/api/projects/:id/sessions', 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 project = db.prepare(`
|
|
SELECT id, name FROM projects
|
|
WHERE id = ? AND deletedAt IS NULL
|
|
`).get(validatedId);
|
|
|
|
if (!project) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Get sessions from in-memory Claude service and historical
|
|
const allSessions = [];
|
|
|
|
// Get active sessions
|
|
const activeSessions = Array.from(claudeService.sessions.values())
|
|
.filter(session => session.projectId == validatedId)
|
|
.map(session => ({
|
|
id: session.id,
|
|
title: session.title || session.metadata?.project || 'Untitled Session',
|
|
agent: session.metadata?.agent || 'claude',
|
|
createdAt: new Date(session.createdAt).toISOString(),
|
|
updatedAt: new Date(session.lastActivity || session.createdAt).toISOString(),
|
|
status: 'active',
|
|
metadata: session.metadata
|
|
}));
|
|
|
|
// Get historical sessions from DB
|
|
const historicalSessions = db.prepare(`
|
|
SELECT id, createdAt, metadata
|
|
FROM sessions
|
|
WHERE projectId = ? AND deletedAt IS NULL
|
|
ORDER BY createdAt DESC
|
|
`).all(validatedId).map(session => ({
|
|
id: session.id,
|
|
title: session.metadata?.project || 'Untitled Session',
|
|
agent: session.metadata?.agent || 'claude',
|
|
createdAt: session.createdAt,
|
|
updatedAt: session.createdAt,
|
|
status: 'historical',
|
|
metadata: typeof session.metadata === 'string' ? JSON.parse(session.metadata) : session.metadata
|
|
}));
|
|
|
|
// Merge and deduplicate
|
|
const sessionMap = new Map();
|
|
[...activeSessions, ...historicalSessions].forEach(session => {
|
|
if (!sessionMap.has(session.id)) {
|
|
sessionMap.set(session.id, session);
|
|
}
|
|
});
|
|
|
|
const sessions = Array.from(sessionMap.values())
|
|
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
|
|
res.json({
|
|
success: true,
|
|
sessions
|
|
});
|
|
} catch (error) {
|
|
console.error('Error listing project sessions:', error);
|
|
res.status(500).json({ error: 'Failed to list sessions' });
|
|
}
|
|
});
|
|
|
|
// POST /api/projects/:id/sessions - Create new session in project
|
|
app.post('/api/projects/:id/sessions', requireAuth, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { metadata = {} } = req.body;
|
|
|
|
// Validate ID
|
|
const validatedId = validateProjectId(id);
|
|
if (!validatedId) {
|
|
return res.status(400).json({ error: 'Invalid project ID' });
|
|
}
|
|
|
|
// Check if project exists
|
|
const project = db.prepare(`
|
|
SELECT id, name, path FROM projects
|
|
WHERE id = ? AND deletedAt IS NULL
|
|
`).get(validatedId);
|
|
|
|
if (!project) {
|
|
return res.status(404).json({ error: 'Project not found' });
|
|
}
|
|
|
|
// Create session using Claude service
|
|
const session = await claudeService.createSession(project.path, {
|
|
...metadata,
|
|
project: project.name,
|
|
projectId: validatedId
|
|
});
|
|
|
|
// Update project lastActivity
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET lastActivity = ?
|
|
WHERE id = ?
|
|
`).run(new Date().toISOString(), validatedId);
|
|
|
|
res.json({
|
|
success: true,
|
|
session: {
|
|
id: session.id,
|
|
title: session.title || session.metadata?.project || 'New Session',
|
|
agent: session.metadata?.agent || 'claude',
|
|
createdAt: session.createdAt,
|
|
updatedAt: session.lastActivity || session.createdAt,
|
|
metadata: session.metadata
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating session:', error);
|
|
res.status(500).json({ error: 'Failed to create session' });
|
|
}
|
|
});
|
|
|
|
// 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 terminal output via HTTP polling (bypasses WebSocket issue)
|
|
app.get('/claude/api/terminals/:id/output', requireAuth, (req, res) => {
|
|
try {
|
|
const sinceIndex = parseInt(req.query.since) || 0;
|
|
const result = terminalService.getTerminalOutput(req.params.id, sinceIndex);
|
|
|
|
if (result.success) {
|
|
res.json(result);
|
|
} else {
|
|
res.status(404).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error getting terminal output:', error);
|
|
res.status(500).json({ error: 'Failed to get output' });
|
|
}
|
|
});
|
|
|
|
// Resize terminal via HTTP
|
|
app.post('/claude/api/terminals/:id/resize', requireAuth, (req, res) => {
|
|
try {
|
|
const { cols, rows } = req.body;
|
|
|
|
if (!cols || !rows) {
|
|
res.status(400).json({ error: 'Missing cols or rows parameter' });
|
|
return;
|
|
}
|
|
|
|
const result = terminalService.resizeTerminal(req.params.id, cols, rows);
|
|
|
|
if (result.success) {
|
|
res.json({ success: true });
|
|
} else {
|
|
res.status(404).json({ error: result.error });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error resizing terminal:', error);
|
|
res.status(500).json({ error: 'Failed to resize terminal' });
|
|
}
|
|
});
|
|
|
|
// 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:', {
|
|
type: data.type,
|
|
sessionId: data.sessionId?.substring(0, 20) || 'none',
|
|
hasCommand: !!data.command,
|
|
commandLength: data.command?.length || 0
|
|
});
|
|
|
|
if (data.type === 'command') {
|
|
const { sessionId, command } = data;
|
|
|
|
console.log(`[WebSocket] Sending command to session ${sessionId}: ${command.substring(0, 50)}...`);
|
|
|
|
// Send command to Claude Code
|
|
try {
|
|
claudeService.sendCommand(sessionId, command);
|
|
console.log(`[WebSocket] ✓ Command sent successfully to session ${sessionId}`);
|
|
} catch (error) {
|
|
console.error(`[WebSocket] ✗ Error sending command:`, error.message);
|
|
ws.send(JSON.stringify({
|
|
type: 'error',
|
|
error: error.message
|
|
}));
|
|
}
|
|
} else if (data.type === 'approval-request') {
|
|
// AI agent requesting approval for a command
|
|
const { sessionId, command, explanation } = data;
|
|
|
|
console.log(`[WebSocket] Approval request for session ${sessionId}: ${command}`);
|
|
|
|
// Create pending approval
|
|
const approvalId = approvalManager.createApproval(sessionId, command, explanation);
|
|
|
|
// Forward approval request to all clients subscribed to this session
|
|
clients.forEach((client) => {
|
|
if (client.sessionId === sessionId && client.ws.readyState === WebSocket.OPEN) {
|
|
client.ws.send(JSON.stringify({
|
|
type: 'approval-request',
|
|
id: approvalId,
|
|
sessionId,
|
|
command,
|
|
explanation
|
|
}));
|
|
}
|
|
});
|
|
|
|
} else if (data.type === 'approval-response') {
|
|
// User responded to approval request
|
|
const { id, approved, customCommand } = data;
|
|
|
|
console.log(`[WebSocket] Approval response for ${id}: approved=${approved}`);
|
|
|
|
// Get the approval
|
|
const approval = approvalManager.getApproval(id);
|
|
|
|
if (!approval) {
|
|
console.error(`[WebSocket] Approval ${id} not found or expired`);
|
|
ws.send(JSON.stringify({
|
|
type: 'approval-expired',
|
|
id
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Remove from pending
|
|
approvalManager.removeApproval(id);
|
|
|
|
// Send approval as a message to the AI agent
|
|
const approvalMessage = approved
|
|
? (customCommand
|
|
? `[USER APPROVED WITH MODIFICATION: ${customCommand}]`
|
|
: `[USER APPROVED: ${approval.command}]`)
|
|
: `[USER REJECTED: ${approval.command}]`;
|
|
|
|
// Add to session context
|
|
claudeService.sendCommand(approval.sessionId, approvalMessage);
|
|
|
|
// Forward response confirmation to clients
|
|
clients.forEach((client) => {
|
|
if (client.sessionId === approval.sessionId && client.ws.readyState === WebSocket.OPEN) {
|
|
client.ws.send(JSON.stringify({
|
|
type: 'approval-confirmed',
|
|
id,
|
|
approved,
|
|
customCommand: customCommand || null
|
|
}));
|
|
}
|
|
});
|
|
|
|
} 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(`[WebSocket] 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);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Forward approval-request event
|
|
claudeService.on('approval-request', (data) => {
|
|
console.log(`Approval request for session ${data.sessionId}:`, data.command);
|
|
|
|
// Create pending approval
|
|
const approvalId = approvalManager.createApproval(data.sessionId, data.command, data.explanation);
|
|
|
|
// Forward to all clients subscribed to this session
|
|
clients.forEach((client, clientId) => {
|
|
if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
client.ws.send(JSON.stringify({
|
|
type: 'approval-request',
|
|
id: approvalId,
|
|
sessionId: data.sessionId,
|
|
command: data.command,
|
|
explanation: data.explanation
|
|
}));
|
|
} catch (error) {
|
|
console.error(`Error sending approval request to client ${clientId}:`, error);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Forward approval-expired event
|
|
claudeService.on('approval-expired', (data) => {
|
|
console.log(`Approval expired for session ${data.sessionId}:`, data.id);
|
|
|
|
// Forward to all clients subscribed to this session
|
|
clients.forEach((client, clientId) => {
|
|
if (client.sessionId === data.sessionId && client.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
client.ws.send(JSON.stringify({
|
|
type: 'approval-expired',
|
|
id: data.id
|
|
}));
|
|
} catch (error) {
|
|
console.error(`Error sending approval-expired to client ${clientId}:`, error);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
console.log(`WebSocket server running on ws://localhost:${PORT}/claude/api/claude/chat`);
|
|
|
|
// Real-time error monitoring endpoint
|
|
app.post('/claude/api/log-error', express.json(), (req, res) => {
|
|
const error = req.body;
|
|
const timestamp = new Date().toISOString();
|
|
|
|
// Log with visual marker for Claude to notice
|
|
console.error('\n🚨 [BROWSER_ERROR] ' + '='.repeat(60));
|
|
console.error('🚨 [BROWSER_ERROR] Time:', timestamp);
|
|
console.error('🚨 [BROWSER_ERROR] Type:', error.type);
|
|
console.error('🚨 [BROWSER_ERROR] Message:', error.message);
|
|
if (error.url) console.error('🚨 [BROWSER_ERROR] URL:', error.url);
|
|
if (error.line) console.error('🚨 [BROWSER_ERROR] Line:', error.line);
|
|
if (error.stack) console.error('🚨 [BROWSER_ERROR] Stack:', error.stack);
|
|
console.error('🚨 [BROWSER_ERROR] ' + '='.repeat(60) + '\n');
|
|
|
|
// Also write to error log file
|
|
const fs = require('fs');
|
|
const errorLogPath = '/tmp/browser-errors.log';
|
|
const errorEntry = JSON.stringify({ ...error, loggedAt: timestamp }) + '\n';
|
|
fs.appendFileSync(errorLogPath, errorEntry);
|
|
|
|
// Store in recent errors array for debug endpoint
|
|
recentErrors.push({ ...error, loggedAt: timestamp });
|
|
// Keep only last 100 errors
|
|
if (recentErrors.length > 100) {
|
|
recentErrors.shift();
|
|
}
|
|
|
|
// 🤖 AUTO-TRIGGER: Spawn auto-fix agent in background
|
|
const { spawn } = require('child_process');
|
|
const agentPath = '/home/uroma/obsidian-web-interface/scripts/auto-fix-agent.js';
|
|
|
|
console.log('🤖 [AUTO_FIX] Triggering agent to fix error...');
|
|
|
|
const agent = spawn('node', [agentPath, 'process'], {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env, ERROR_DATA: JSON.stringify(error) }
|
|
});
|
|
|
|
agent.stdin.write(JSON.stringify(error));
|
|
agent.stdin.end();
|
|
|
|
agent.stdout.on('data', (data) => {
|
|
console.log('🤖 [AUTO_FIX]', data.toString());
|
|
});
|
|
|
|
agent.stderr.on('data', (data) => {
|
|
console.error('🤖 [AUTO_FIX] ERROR:', data.toString());
|
|
});
|
|
|
|
agent.on('close', (code) => {
|
|
console.log(`🤖 [AUTO_FIX] Agent exited with code ${code}`);
|
|
});
|
|
|
|
res.json({ received: true, autoFixTriggered: true });
|
|
});
|
|
|
|
// ============================================================
|
|
// FILESYSTEM API - Folder Explorer
|
|
// ============================================================
|
|
|
|
/**
|
|
* Expand ~ to home directory and validate path
|
|
*/
|
|
function expandPath(userPath) {
|
|
const homeDir = os.homedir();
|
|
|
|
// Expand ~ or ~user
|
|
let expanded = userPath.replace(/^~\/?/, homeDir + '/');
|
|
|
|
// Resolve to absolute path
|
|
expanded = path.resolve(expanded);
|
|
|
|
// Security: Must be under home directory
|
|
if (!expanded.startsWith(homeDir)) {
|
|
throw new Error('Access denied: Path outside home directory');
|
|
}
|
|
|
|
return expanded;
|
|
}
|
|
|
|
/**
|
|
* Get directory contents for folder tree
|
|
*/
|
|
app.get('/api/filesystem/list', requireAuth, async (req, res) => {
|
|
try {
|
|
const { path: userPath = '~' } = req.query;
|
|
|
|
// Expand and validate path
|
|
const expandedPath = expandPath(userPath);
|
|
|
|
// Check if path exists
|
|
try {
|
|
await fs.promises.access(expandedPath, fs.constants.R_OK);
|
|
} catch {
|
|
return res.json({
|
|
success: false,
|
|
error: 'Directory not found or inaccessible',
|
|
path: expandedPath
|
|
});
|
|
}
|
|
|
|
// Read directory
|
|
const entries = await fs.promises.readdir(expandedPath, { withFileTypes: true });
|
|
|
|
// Filter directories only, sort, and get metadata
|
|
const items = [];
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
const fullPath = path.join(expandedPath, entry.name);
|
|
|
|
// Skip hidden directories (except .git)
|
|
if (entry.name.startsWith('.') && entry.name !== '.git') {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const subEntries = await fs.promises.readdir(fullPath, { withFileTypes: true });
|
|
const subDirs = subEntries.filter(e => e.isDirectory()).length;
|
|
|
|
items.push({
|
|
name: entry.name,
|
|
type: 'directory',
|
|
path: fullPath,
|
|
children: subDirs,
|
|
hasChildren: subDirs > 0
|
|
});
|
|
} catch {
|
|
// Can't read subdirectory (permissions)
|
|
items.push({
|
|
name: entry.name,
|
|
type: 'directory',
|
|
path: fullPath,
|
|
children: 0,
|
|
hasChildren: false,
|
|
locked: true
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort alphabetically
|
|
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
res.json({
|
|
success: true,
|
|
path: expandedPath,
|
|
displayPath: expandedPath.replace(os.homedir(), '~'),
|
|
items
|
|
});
|
|
} catch (error) {
|
|
console.error('Error listing directory:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Validate if path exists and is accessible
|
|
*/
|
|
app.get('/api/filesystem/validate', requireAuth, async (req, res) => {
|
|
try {
|
|
const { path: userPath } = req.query;
|
|
|
|
if (!userPath) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Path parameter is required'
|
|
});
|
|
}
|
|
|
|
// Expand and validate path
|
|
const expandedPath = expandPath(userPath);
|
|
|
|
// Check if path exists
|
|
try {
|
|
await fs.promises.access(expandedPath, fs.constants.F_OK);
|
|
} catch {
|
|
return res.json({
|
|
success: true,
|
|
valid: false,
|
|
path: expandedPath,
|
|
displayPath: expandedPath.replace(os.homedir(), '~'),
|
|
exists: false
|
|
});
|
|
}
|
|
|
|
// Check read access
|
|
let readable = false;
|
|
try {
|
|
await fs.promises.access(expandedPath, fs.constants.R_OK);
|
|
readable = true;
|
|
} catch {}
|
|
|
|
// Check write access
|
|
let writable = false;
|
|
try {
|
|
await fs.promises.access(expandedPath, fs.constants.W_OK);
|
|
writable = true;
|
|
} catch {}
|
|
|
|
// Check if it's a directory
|
|
const stats = await fs.promises.stat(expandedPath);
|
|
const isDirectory = stats.isDirectory();
|
|
|
|
res.json({
|
|
success: true,
|
|
valid: true,
|
|
path: expandedPath,
|
|
displayPath: expandedPath.replace(os.homedir(), '~'),
|
|
exists: true,
|
|
readable,
|
|
writable,
|
|
isDirectory
|
|
});
|
|
} catch (error) {
|
|
// Path validation error
|
|
res.json({
|
|
success: true,
|
|
valid: false,
|
|
error: error.message,
|
|
path: userPath
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Create new directory
|
|
*/
|
|
app.post('/api/filesystem/mkdir', requireAuth, async (req, res) => {
|
|
try {
|
|
const { path: userPath } = req.body;
|
|
|
|
if (!userPath) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Path parameter is required'
|
|
});
|
|
}
|
|
|
|
// Expand and validate path
|
|
const expandedPath = expandPath(userPath);
|
|
|
|
// Validate folder name (no special characters)
|
|
const folderName = path.basename(expandedPath);
|
|
if (!/^[a-zA-Z0-9._-]+$/.test(folderName)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Invalid folder name. Use only letters, numbers, dots, dashes, and underscores.'
|
|
});
|
|
}
|
|
|
|
// Check if already exists
|
|
try {
|
|
await fs.access(expandedPath, fs.constants.F_OK);
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Directory already exists'
|
|
});
|
|
} catch {
|
|
// Doesn't exist, good to create
|
|
}
|
|
|
|
// Create directory
|
|
await fs.promises.mkdir(expandedPath, { mode: 0o755 });
|
|
|
|
res.json({
|
|
success: true,
|
|
path: expandedPath,
|
|
displayPath: expandedPath.replace(os.homedir(), '~')
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating directory:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get quick access locations
|
|
*/
|
|
app.get('/api/filesystem/quick-access', requireAuth, async (req, res) => {
|
|
try {
|
|
const homeDir = os.homedir();
|
|
const locations = [
|
|
{ path: '~', name: 'Home', icon: '🏠' },
|
|
{ path: '~/projects', name: 'Projects', icon: '📂' },
|
|
{ path: '~/code', name: 'Code', icon: '💻' },
|
|
{ path: '~/Documents', name: 'Documents', icon: '📄' },
|
|
{ path: '~/Downloads', name: 'Downloads', icon: '📥' }
|
|
];
|
|
|
|
// Check which locations exist and get item counts
|
|
const enrichedLocations = await Promise.all(locations.map(async (loc) => {
|
|
try {
|
|
const expandedPath = expandPath(loc.path);
|
|
await fs.promises.access(expandedPath, fs.constants.R_OK);
|
|
|
|
// Get item count
|
|
const entries = await fs.promises.readdir(expandedPath, { withFileTypes: true });
|
|
const count = entries.filter(e => e.isDirectory()).length;
|
|
|
|
return {
|
|
...loc,
|
|
path: expandedPath,
|
|
displayPath: expandedPath.replace(homeDir, '~'),
|
|
exists: true,
|
|
count
|
|
};
|
|
} catch {
|
|
return {
|
|
...loc,
|
|
exists: false,
|
|
count: 0
|
|
};
|
|
}
|
|
}));
|
|
|
|
res.json({
|
|
success: true,
|
|
locations: enrichedLocations
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting quick access locations:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
});
|