Implement terminal approval UI system
Phase 1: Backend approval tracking - Add PendingApprovalsManager class to track pending approvals - Add approval-request, approval-response, approval-expired WebSocket handlers - Add requestApproval() method to ClaudeCodeService - Add event forwarding for approval requests Phase 2: Frontend approval card component - Create approval-card.js with interactive UI - Create approval-card.css with styled component - Add Approve, Custom Instructions, Reject buttons - Add expandable custom command input Phase 3: Wire up approval flow end-to-end - Add handleApprovalRequest, handleApprovalConfirmed, handleApprovalExpired handlers - Add detectApprovalRequest() to parse AI approval request patterns - Integrate approval card into WebSocket message flow - Route approval responses based on source (server vs AI conversational) This allows the AI agent to request command approval through a clean UI instead of confusing conversational text responses. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
710
server.js
710
server.js
@@ -3,6 +3,7 @@ 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');
|
||||
@@ -11,6 +12,10 @@ 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,
|
||||
@@ -40,6 +45,162 @@ const users = {
|
||||
// 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();
|
||||
@@ -1193,6 +1354,15 @@ function validateProjectPath(projectPath) {
|
||||
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 {
|
||||
@@ -1203,11 +1373,20 @@ app.get('/api/projects', requireAuth, (req, res) => {
|
||||
ORDER BY lastActivity DESC
|
||||
`).all();
|
||||
|
||||
// Add sessionCount (0 for now, will be implemented in Task 3)
|
||||
const projectsWithSessionCount = projects.map(project => ({
|
||||
...project,
|
||||
sessionCount: 0
|
||||
}));
|
||||
// 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,
|
||||
@@ -1496,6 +1675,133 @@ app.delete('/api/projects/:id/permanent', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 {
|
||||
@@ -1987,6 +2293,71 @@ wss.on('connection', (ws, req) => {
|
||||
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;
|
||||
@@ -2105,6 +2476,50 @@ claudeService.on('operations-error', (data) => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -2128,6 +2543,13 @@ app.post('/claude/api/log-error', express.json(), (req, res) => {
|
||||
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';
|
||||
@@ -2157,6 +2579,284 @@ app.post('/claude/api/log-error', express.json(), (req, res) => {
|
||||
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...');
|
||||
|
||||
Reference in New Issue
Block a user