1046 lines
36 KiB
JavaScript
1046 lines
36 KiB
JavaScript
const express = require('express');
|
|
const path = require('path');
|
|
const cors = require('cors');
|
|
const fs = require('fs').promises;
|
|
const crypto = require('crypto');
|
|
const os = require('os');
|
|
const { exec } = require('child_process');
|
|
const OpenCodeBackend = require('./backend-integration');
|
|
const { QwenOAuth } = require('./qwen-oauth');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || (process.argv[2] ? parseInt(process.argv[2]) : 15044);
|
|
const WORKSPACE_ROOT = path.resolve(process.env.OPENQODE_WORKSPACE_ROOT || __dirname);
|
|
const MAX_CMD_OUTPUT = 200 * 1024; // 200KB
|
|
|
|
// Initialize backends
|
|
const openCodeBackend = new OpenCodeBackend();
|
|
const qwenOAuth = new QwenOAuth();
|
|
|
|
// Initialize backend and start server
|
|
async function startServer() {
|
|
try {
|
|
console.log('Initializing OpenCode backend...');
|
|
await openCodeBackend.initialize();
|
|
|
|
// Load existing tokens
|
|
await loadTokens();
|
|
|
|
// Load existing sessions
|
|
await loadSessionsFromDisk();
|
|
|
|
// Check Qwen authentication status
|
|
const qwenAuth = await openCodeBackend.checkAuth('qwen');
|
|
console.log(`🔑 Qwen authentication status: ${qwenAuth.authenticated ? '✅ Authenticated' : '❌ Not authenticated'}`);
|
|
|
|
// Start server
|
|
console.log(`Attempting to start server on port ${PORT}...`);
|
|
const server = app.listen(PORT, '0.0.0.0', async () => {
|
|
console.log(`🚀 OpenQode Web Server running on http://localhost:${PORT}`);
|
|
console.log(`📁 Serving files from: ${path.join(__dirname, 'web')}`);
|
|
console.log(`🔐 Auth endpoints available at /api/auth/*`);
|
|
console.log(`💬 Chat endpoint available at /api/chat`);
|
|
console.log(`📂 Session management at /api/sessions/*`);
|
|
console.log(`🔧 Server is listening and ready for connections`);
|
|
|
|
// Warm up the qwen CLI to prevent "first message fails" issue
|
|
console.log('🔥 Warming up Qwen CLI...');
|
|
try {
|
|
const warmupResult = await qwenOAuth.sendMessage('ping', 'qwen-coder-plus');
|
|
if (warmupResult.success) {
|
|
console.log('✅ Qwen CLI warmed up and ready!');
|
|
} else {
|
|
console.log('⚠️ Qwen CLI warmup returned error (may still work):', warmupResult.error);
|
|
}
|
|
} catch (warmupError) {
|
|
console.log('⚠️ Qwen CLI warmup failed (may still work):', warmupError.message);
|
|
}
|
|
});
|
|
|
|
server.on('error', (err) => {
|
|
console.error('Server error:', err);
|
|
console.error('Error code:', err.code);
|
|
console.error('Error address:', err.address);
|
|
console.error('Error port:', err.port);
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Middleware - CORS configuration for cross-origin requests
|
|
app.use(cors({
|
|
origin: true, // Allow all origins (for development)
|
|
credentials: true
|
|
}));
|
|
app.use(express.json({ limit: '50mb' }));
|
|
app.use(express.static(path.join(__dirname, 'web')));
|
|
app.use('/assist', express.static(path.join(__dirname, 'web-assist')));
|
|
|
|
// Storage with persistence
|
|
const sessions = new Map();
|
|
const authTokens = new Map();
|
|
const TOKEN_FILE = path.join(__dirname, 'tokens.json');
|
|
|
|
// Helper functions
|
|
function generateToken() {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
function validateToken(token) {
|
|
return authTokens.has(token);
|
|
}
|
|
|
|
function resolveWorkspacePath(relPath = '') {
|
|
const safeRel = relPath.replace(/^[\\/]+/, '');
|
|
const full = path.resolve(WORKSPACE_ROOT, safeRel);
|
|
if (!full.startsWith(WORKSPACE_ROOT)) {
|
|
throw new Error('Path outside workspace');
|
|
}
|
|
return full;
|
|
}
|
|
|
|
async function buildTree(dir, baseRel = '') {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
const nodes = [];
|
|
for (const entry of entries) {
|
|
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
const rel = path.join(baseRel, entry.name);
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
nodes.push({
|
|
type: 'dir',
|
|
name: entry.name,
|
|
path: rel.replace(/\\/g, '/'),
|
|
children: await buildTree(full, rel)
|
|
});
|
|
} else {
|
|
nodes.push({
|
|
type: 'file',
|
|
name: entry.name,
|
|
path: rel.replace(/\\/g, '/')
|
|
});
|
|
}
|
|
}
|
|
nodes.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
return nodes;
|
|
}
|
|
|
|
// Load existing tokens from file
|
|
async function loadTokens() {
|
|
try {
|
|
const data = await fs.readFile(TOKEN_FILE, 'utf8');
|
|
const tokens = JSON.parse(data);
|
|
// Restore tokens to Map
|
|
Object.entries(tokens).forEach(([key, value]) => {
|
|
authTokens.set(key, value);
|
|
});
|
|
console.log(`Loaded ${Object.keys(tokens).length} tokens from storage`);
|
|
} catch (error) {
|
|
console.log('No existing tokens found, starting fresh');
|
|
}
|
|
}
|
|
|
|
// Save tokens to file
|
|
async function saveTokens() {
|
|
try {
|
|
const tokens = Object.fromEntries(authTokens);
|
|
await fs.writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2));
|
|
} catch (error) {
|
|
console.error('Error saving tokens:', error);
|
|
}
|
|
}
|
|
|
|
const SESSIONS_FILE = path.join(__dirname, 'sessions.json');
|
|
|
|
// Load sessions from file
|
|
async function loadSessionsFromDisk() {
|
|
try {
|
|
const data = await fs.readFile(SESSIONS_FILE, 'utf8');
|
|
const sessionStore = JSON.parse(data);
|
|
// Restore sessions to Map (sessionId -> sessionData)
|
|
Object.entries(sessionStore).forEach(([key, value]) => {
|
|
sessions.set(key, value);
|
|
});
|
|
console.log(`Loaded sessions for ${Object.keys(sessionStore).length} store IDs`);
|
|
} catch (error) {
|
|
console.log('No existing sessions found, starting fresh');
|
|
}
|
|
}
|
|
|
|
// Save sessions to file
|
|
async function saveSessionsToDisk() {
|
|
try {
|
|
const sessionStore = Object.fromEntries(sessions);
|
|
await fs.writeFile(SESSIONS_FILE, JSON.stringify(sessionStore, null, 2));
|
|
} catch (error) {
|
|
console.error('Error saving sessions:', error);
|
|
}
|
|
}
|
|
|
|
// Check if user is actually authenticated with Qwen
|
|
async function verifyQwenAuth() {
|
|
try {
|
|
const authStatus = await openCodeBackend.checkAuth('qwen');
|
|
return authStatus.authenticated;
|
|
} catch (error) {
|
|
console.error('Error verifying Qwen auth:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Routes
|
|
app.get('/', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'web', 'index.html'));
|
|
});
|
|
|
|
// Authentication endpoints
|
|
app.post('/api/auth/login', async (req, res) => {
|
|
try {
|
|
const { provider } = req.body;
|
|
|
|
if (provider === 'qwen') {
|
|
console.log('Starting Qwen Device Code Flow...');
|
|
|
|
// Check if already authenticated with QwenOAuth
|
|
const authStatus = await qwenOAuth.checkAuth();
|
|
console.log('Qwen OAuth status:', authStatus);
|
|
|
|
if (authStatus.authenticated) {
|
|
console.log('User authenticated with Qwen:', authStatus.method);
|
|
const token = generateToken();
|
|
authTokens.set(token, {
|
|
provider: 'qwen',
|
|
status: 'authenticated',
|
|
method: authStatus.method,
|
|
hasVisionSupport: authStatus.hasVisionSupport || false,
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
|
|
await saveTokens();
|
|
|
|
return res.json({
|
|
success: true,
|
|
alreadyAuthenticated: true,
|
|
hasVisionSupport: authStatus.hasVisionSupport || false,
|
|
token,
|
|
user: {
|
|
id: 'qwen_user',
|
|
name: 'Qwen User',
|
|
provider: 'qwen'
|
|
}
|
|
});
|
|
}
|
|
|
|
// Start Device Code Flow
|
|
try {
|
|
const deviceFlowData = await qwenOAuth.startDeviceFlow();
|
|
console.log('Device flow started:', deviceFlowData);
|
|
|
|
// Start polling in background
|
|
qwenOAuth.pollForTokens().then(async (tokens) => {
|
|
console.log('Device flow completed - tokens received!');
|
|
}).catch((error) => {
|
|
console.error('Device flow polling error:', error.message);
|
|
});
|
|
|
|
// Return the verification URL and user code
|
|
res.json({
|
|
success: true,
|
|
requiresDeviceCode: true,
|
|
verificationUri: deviceFlowData.verificationUri,
|
|
verificationUriComplete: deviceFlowData.verificationUriComplete,
|
|
userCode: deviceFlowData.userCode,
|
|
expiresIn: deviceFlowData.expiresIn,
|
|
message: `Please go to ${deviceFlowData.verificationUri} and enter code: ${deviceFlowData.userCode}`
|
|
});
|
|
} catch (deviceError) {
|
|
console.error('Device flow error:', deviceError);
|
|
res.status(400).json({
|
|
success: false,
|
|
error: deviceError.message || 'Failed to start device authentication'
|
|
});
|
|
}
|
|
} else {
|
|
res.status(400).json({ success: false, error: 'Unsupported provider' });
|
|
}
|
|
} catch (error) {
|
|
console.error('Auth login error:', error);
|
|
res.status(500).json({ success: false, error: `Internal server error: ${error.message}` });
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/callback', async (req, res) => {
|
|
try {
|
|
const { code, state } = req.query;
|
|
|
|
if (!state || !authTokens.has(state)) {
|
|
return res.status(400).send('Invalid state parameter');
|
|
}
|
|
|
|
// Simulate token exchange
|
|
const token = generateToken();
|
|
const userData = {
|
|
id: 'user_' + Date.now(),
|
|
name: 'OpenQode User',
|
|
email: 'user@openqode.local',
|
|
provider: 'qwen'
|
|
};
|
|
|
|
authTokens.set(token, {
|
|
...userData,
|
|
status: 'authenticated',
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
|
|
authTokens.delete(state);
|
|
|
|
// Redirect back to frontend with token
|
|
res.redirect(`/#token=${token}`);
|
|
} catch (error) {
|
|
console.error('Auth callback error:', error);
|
|
res.status(500).send('Authentication failed');
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/status', async (req, res) => {
|
|
try {
|
|
const { token } = req.body;
|
|
|
|
if (!token || !validateToken(token)) {
|
|
return res.json({ authenticated: false });
|
|
}
|
|
|
|
const userData = authTokens.get(token);
|
|
|
|
// Verify actual Qwen authentication status
|
|
if (userData && userData.provider === 'qwen') {
|
|
const isActuallyAuthed = await verifyQwenAuth();
|
|
if (!isActuallyAuthed) {
|
|
// Remove invalid token
|
|
authTokens.delete(token);
|
|
await saveTokens();
|
|
return res.json({ authenticated: false, error: 'Qwen authentication expired' });
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
authenticated: true,
|
|
user: {
|
|
id: userData.id || 'qwen_user',
|
|
name: userData.name || 'Qwen User',
|
|
email: userData.email || 'user@openqode.local',
|
|
provider: userData.provider
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Auth status error:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// Complete browser authentication
|
|
app.post('/api/auth/complete', async (req, res) => {
|
|
try {
|
|
const { state } = req.body;
|
|
|
|
if (!state || !authTokens.has(state)) {
|
|
return res.status(400).json({ success: false, error: 'Invalid state parameter' });
|
|
}
|
|
|
|
console.log('Completing authentication for state:', state);
|
|
|
|
// Check if user completed browser auth
|
|
const authStatus = await openCodeBackend.checkAuth('qwen');
|
|
console.log('Post-browser auth status:', authStatus);
|
|
|
|
if (authStatus.authenticated) {
|
|
// Generate new authenticated token
|
|
const token = generateToken();
|
|
authTokens.set(token, {
|
|
provider: 'qwen',
|
|
status: 'authenticated',
|
|
details: authStatus.details,
|
|
createdAt: new Date().toISOString()
|
|
});
|
|
|
|
// Remove pending state
|
|
authTokens.delete(state);
|
|
|
|
await saveTokens();
|
|
|
|
res.json({
|
|
success: true,
|
|
token,
|
|
user: {
|
|
id: 'qwen_user',
|
|
name: 'Qwen User',
|
|
provider: 'qwen'
|
|
}
|
|
});
|
|
} else {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: 'Authentication not completed. Please try again.'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Auth complete error:', error);
|
|
res.status(500).json({ success: false, error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/auth/logout', (req, res) => {
|
|
try {
|
|
const { token } = req.body;
|
|
|
|
if (token && authTokens.has(token)) {
|
|
authTokens.delete(token);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Auth logout error:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// Chat endpoints
|
|
app.post('/api/chat', async (req, res) => {
|
|
try {
|
|
const { message, model, features, token } = req.body;
|
|
|
|
if (!token || !validateToken(token)) {
|
|
return res.status(401).json({ success: false, error: 'Authentication required' });
|
|
}
|
|
|
|
console.log('📨 Chat request received:', { message: message?.substring(0, 50), model });
|
|
|
|
// Use QwenOAuth to send message directly to Qwen API
|
|
try {
|
|
const result = await qwenOAuth.sendMessage(message, model || 'qwen-coder-plus');
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
response: result.response,
|
|
metadata: {
|
|
model: model || 'qwen-coder-plus',
|
|
timestamp: new Date().toISOString(),
|
|
features,
|
|
usage: result.usage
|
|
}
|
|
});
|
|
} else {
|
|
res.json({
|
|
success: false,
|
|
error: result.error || 'Failed to get response from Qwen'
|
|
});
|
|
}
|
|
} catch (qwenError) {
|
|
console.error('Qwen API error:', qwenError.message);
|
|
|
|
// If auth error, tell user to re-authenticate
|
|
if (qwenError.message.includes('Authentication') || qwenError.message.includes('auth')) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: 'Session expired. Please re-authenticate with Qwen.',
|
|
needsReauth: true
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: false,
|
|
error: qwenError.message || 'Failed to get response from Qwen'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Chat error:', error);
|
|
res.status(500).json({ success: false, error: 'Failed to get response' });
|
|
}
|
|
});
|
|
|
|
// Streaming chat endpoint
|
|
app.post('/api/chat/stream', async (req, res) => {
|
|
try {
|
|
const { message, model, features, token, attachment } = req.body;
|
|
|
|
if (!token || !validateToken(token)) {
|
|
return res.status(401).json({ success: false, error: 'Authentication required' });
|
|
}
|
|
|
|
console.log('📨 Stream chat request received:', {
|
|
message: message?.substring(0, 50),
|
|
model,
|
|
hasAttachment: !!attachment,
|
|
attachmentType: attachment?.type,
|
|
attachmentName: attachment?.name,
|
|
hasImageData: !!attachment?.data
|
|
});
|
|
|
|
// Set headers for Server-Sent Events
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Headers': 'Cache-Control'
|
|
});
|
|
|
|
// Prepare the message with attachment context
|
|
let fullMessage = message;
|
|
let imageData = null;
|
|
|
|
if (attachment) {
|
|
console.log('🔍 Attachment received:', {
|
|
type: attachment.type,
|
|
name: attachment.name,
|
|
size: attachment.size,
|
|
dataLength: attachment.data?.length
|
|
});
|
|
if (attachment.type === 'image') {
|
|
// For images, include the base64 data for vision models
|
|
imageData = attachment.data;
|
|
console.log('📷 Image attachment extracted! Data starts with:', imageData?.substring(0, 50));
|
|
} else if (attachment.type === 'text') {
|
|
// For text files, the content is already in the message
|
|
console.log('📄 Text attachment detected:', attachment.name);
|
|
}
|
|
} else {
|
|
console.log('⚠️ No attachment in request body');
|
|
}
|
|
|
|
// Use qwenOAuth (non-streaming, but send as single chunk)
|
|
try {
|
|
// If we have an image, use vision model
|
|
const effectiveModel = imageData ? 'qwen-vl-plus' : (model || 'qwen-coder-plus');
|
|
const result = await qwenOAuth.sendMessage(fullMessage, effectiveModel, imageData);
|
|
|
|
if (result.success) {
|
|
// Send the response as a single chunk
|
|
res.write(`data: ${JSON.stringify({ type: 'chunk', content: result.response })}\n\n`);
|
|
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
|
} else {
|
|
res.write(`data: ${JSON.stringify({ type: 'error', error: result.error || 'Failed to get response' })}\n\n`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Qwen stream error:', error.message);
|
|
res.write(`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`);
|
|
}
|
|
|
|
res.end();
|
|
} catch (error) {
|
|
console.error('Stream chat error:', error);
|
|
res.write(`data: ${JSON.stringify({ type: 'error', error: 'Internal server error' })}\n\n`);
|
|
res.end();
|
|
}
|
|
});
|
|
|
|
// File system APIs (workspace-scoped)
|
|
// NOTE: Read-only operations (tree, read) work without auth for local-first experience
|
|
app.get('/api/files/tree', async (req, res) => {
|
|
try {
|
|
// Allow file tree without authentication - it's your local files
|
|
const tree = await buildTree(WORKSPACE_ROOT, '');
|
|
res.json({ success: true, root: WORKSPACE_ROOT, tree });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/files/read', async (req, res) => {
|
|
try {
|
|
// Allow file reading without authentication - it's your local files
|
|
const { path: relPath } = req.query;
|
|
const fullPath = resolveWorkspacePath(relPath);
|
|
const content = await fs.readFile(fullPath, 'utf8');
|
|
res.json({ success: true, path: relPath, content });
|
|
} catch (error) {
|
|
res.status(400).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/files/write', async (req, res) => {
|
|
try {
|
|
// Local file writing doesn't require authentication
|
|
const { path: relPath, content } = req.body;
|
|
const fullPath = resolveWorkspacePath(relPath);
|
|
await fs.writeFile(fullPath, content ?? '', 'utf8');
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(400).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/files/create', async (req, res) => {
|
|
try {
|
|
// Local file creation doesn't require authentication
|
|
const { path: relPath, type } = req.body;
|
|
const fullPath = resolveWorkspacePath(relPath);
|
|
if (type === 'dir') {
|
|
await fs.mkdir(fullPath, { recursive: true });
|
|
} else {
|
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
await fs.writeFile(fullPath, '', 'utf8');
|
|
}
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(400).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// CRITICAL: Write content to a file (used by AI file creation)
|
|
app.post('/api/files/write', async (req, res) => {
|
|
try {
|
|
const { path: relPath, content } = req.body;
|
|
|
|
if (!relPath) {
|
|
return res.status(400).json({ success: false, error: 'Missing file path' });
|
|
}
|
|
|
|
const fullPath = resolveWorkspacePath(relPath);
|
|
|
|
// Ensure parent directory exists
|
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
|
|
// Write the file content
|
|
await fs.writeFile(fullPath, content || '', 'utf8');
|
|
|
|
console.log(`📝 File written: ${relPath}`);
|
|
res.json({ success: true, path: relPath });
|
|
} catch (error) {
|
|
console.error('File write error:', error);
|
|
res.status(400).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// Upload file (saves to .uploads folder in workspace)
|
|
app.post('/api/files/upload', async (req, res) => {
|
|
try {
|
|
const { token, filename, data, type } = req.body;
|
|
if (!token || !validateToken(token)) {
|
|
return res.status(401).json({ success: false, error: 'Authentication required' });
|
|
}
|
|
|
|
// Create uploads directory if it doesn't exist
|
|
const uploadsDir = path.join(WORKSPACE_ROOT, '.uploads');
|
|
await fs.mkdir(uploadsDir, { recursive: true });
|
|
|
|
// Generate unique filename
|
|
const timestamp = Date.now();
|
|
const safeFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
const uniqueFilename = `${timestamp}-${safeFilename}`;
|
|
const fullPath = path.join(uploadsDir, uniqueFilename);
|
|
|
|
// Decode base64 data if it's an image
|
|
if (type === 'image' && data.startsWith('data:')) {
|
|
const base64Data = data.split(',')[1];
|
|
await fs.writeFile(fullPath, Buffer.from(base64Data, 'base64'));
|
|
} else {
|
|
// Text content
|
|
await fs.writeFile(fullPath, data, 'utf8');
|
|
}
|
|
|
|
const relativePath = `.uploads/${uniqueFilename}`;
|
|
console.log(`📁 File uploaded: ${relativePath}`);
|
|
|
|
res.json({
|
|
success: true,
|
|
path: relativePath,
|
|
fullPath: fullPath,
|
|
filename: uniqueFilename
|
|
});
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
res.status(400).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/files/rename', async (req, res) => {
|
|
try {
|
|
const { token, from, to } = req.body;
|
|
if (!token || !validateToken(token)) {
|
|
return res.status(401).json({ success: false, error: 'Authentication required' });
|
|
}
|
|
const fullFrom = resolveWorkspacePath(from);
|
|
const fullTo = resolveWorkspacePath(to);
|
|
await fs.mkdir(path.dirname(fullTo), { recursive: true });
|
|
await fs.rename(fullFrom, fullTo);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(400).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/files/delete', async (req, res) => {
|
|
try {
|
|
const { token, path: relPath } = req.body;
|
|
if (!token || !validateToken(token)) {
|
|
return res.status(401).json({ success: false, error: 'Authentication required' });
|
|
}
|
|
const fullPath = resolveWorkspacePath(relPath);
|
|
const stat = await fs.stat(fullPath);
|
|
if (stat.isDirectory()) {
|
|
await fs.rm(fullPath, { recursive: true, force: true });
|
|
} else {
|
|
await fs.unlink(fullPath);
|
|
}
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(400).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// Terminal/command run API (workspace-scoped)
|
|
app.post('/api/terminal/run', async (req, res) => {
|
|
try {
|
|
const { token, command, cwd = '' } = req.body;
|
|
// Auth check removed for local terminal usage
|
|
/* if (!token || !validateToken(token)) {
|
|
return res.status(401).json({ success: false, error: 'Authentication required' });
|
|
} */
|
|
if (!command || typeof command !== 'string') {
|
|
return res.status(400).json({ success: false, error: 'Missing command' });
|
|
}
|
|
const fullCwd = resolveWorkspacePath(cwd);
|
|
exec(command, { cwd: fullCwd, timeout: 60000, windowsHide: true, maxBuffer: MAX_CMD_OUTPUT }, (err, stdout, stderr) => {
|
|
res.json({
|
|
success: !err,
|
|
code: err?.code ?? 0,
|
|
stdout: (stdout || '').slice(0, MAX_CMD_OUTPUT),
|
|
stderr: (stderr || '').slice(0, MAX_CMD_OUTPUT)
|
|
});
|
|
});
|
|
} catch (error) {
|
|
res.status(400).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// Session management endpoints
|
|
app.post('/api/sessions/save', async (req, res) => {
|
|
try {
|
|
// req.body contains { sessions: {...}, currentSession: "..." }
|
|
const sessionData = req.body;
|
|
const sessionId = 'default'; // In a real app, this would be user-specific
|
|
|
|
sessions.set(sessionId, sessionData);
|
|
await saveSessionsToDisk();
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Session save error:', error);
|
|
res.status(500).json({ error: 'Failed to save sessions' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/sessions/load', async (req, res) => {
|
|
try {
|
|
const sessionId = 'default'; // In a real app, this would be user-specific
|
|
const sessionData = sessions.get(sessionId) || { sessions: {}, currentSession: 'default' };
|
|
|
|
res.json(sessionData);
|
|
} catch (error) {
|
|
console.error('Session load error:', error);
|
|
res.status(500).json({ error: 'Failed to load sessions' });
|
|
}
|
|
});
|
|
|
|
// File upload endpoint
|
|
app.post('/api/upload', async (req, res) => {
|
|
try {
|
|
const { file, filename, token } = req.body;
|
|
|
|
if (!token || !validateToken(token)) {
|
|
return res.status(401).json({ success: false, error: 'Authentication required' });
|
|
}
|
|
|
|
// In a real implementation, you'd save the file to storage
|
|
// For demo, we'll just acknowledge the upload
|
|
res.json({
|
|
success: true,
|
|
filename,
|
|
size: file ? file.length : 0
|
|
});
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
res.status(500).json({ success: false, error: 'Upload failed' });
|
|
}
|
|
});
|
|
|
|
// Preview server management endpoint with smart platform detection
|
|
app.post('/api/preview/start', async (req, res) => {
|
|
try {
|
|
const { port, path: relativePath = '.' } = req.body;
|
|
|
|
if (!port || typeof port !== 'number') {
|
|
return res.status(400).json({ success: false, error: 'Missing or invalid port' });
|
|
}
|
|
|
|
const fullCwd = resolveWorkspacePath(relativePath);
|
|
|
|
// Check if directory exists, create if it doesn't
|
|
try {
|
|
await fs.access(fullCwd);
|
|
} catch (err) {
|
|
await fs.mkdir(fullCwd, { recursive: true });
|
|
}
|
|
|
|
const platform = os.platform();
|
|
let command;
|
|
let useWSL = false;
|
|
|
|
if (platform === 'win32') {
|
|
// Check if WSL is available
|
|
try {
|
|
const wslCheck = await new Promise((resolve) => {
|
|
exec('wsl --list --quiet', { timeout: 5000 }, (err, stdout) => {
|
|
resolve(!err && stdout.trim().length > 0);
|
|
});
|
|
});
|
|
useWSL = wslCheck;
|
|
} catch (e) {
|
|
useWSL = false;
|
|
}
|
|
|
|
if (useWSL) {
|
|
// Convert Windows path to WSL path
|
|
const wslPath = fullCwd.replace(/\\/g, '/').replace(/^([A-Za-z]):/, '/mnt/$1').toLowerCase();
|
|
console.log(`🐧 Using WSL for preview - Path: ${wslPath}`);
|
|
// Use WSL to run a simple Python HTTP server (more reliable than npx on WSL)
|
|
command = `wsl -e bash -c "cd '${wslPath}' && python3 -m http.server ${port} &"`;
|
|
} else {
|
|
// Fallback: Use PowerShell to run a simple HTTP server
|
|
console.log(`💻 Using PowerShell for preview`);
|
|
// Use Start-Process to run in background
|
|
command = `powershell -Command "Start-Process -NoNewWindow -FilePath 'npx' -ArgumentList 'serve', '-l', '${port}', '${fullCwd.replace(/\\/g, '\\\\')}'"`;
|
|
}
|
|
} else {
|
|
// Unix-like (Linux/Mac): Use Python's built-in HTTP server (more reliable)
|
|
console.log(`🖥️ Using native Python HTTP server for preview`);
|
|
command = `cd "${fullCwd}" && python3 -m http.server ${port} &`;
|
|
}
|
|
|
|
console.log(`📡 Starting preview server: ${command}`);
|
|
|
|
exec(command, { cwd: fullCwd, timeout: 10000, windowsHide: true, maxBuffer: MAX_CMD_OUTPUT, shell: true }, (err, stdout, stderr) => {
|
|
if (err && !useWSL) {
|
|
// If npx/python failed, try a fallback
|
|
console.error('Preview server start error:', err.message);
|
|
res.json({
|
|
success: false,
|
|
error: err.message,
|
|
platform: platform,
|
|
useWSL: useWSL
|
|
});
|
|
} else {
|
|
res.json({
|
|
success: true,
|
|
platform: platform,
|
|
useWSL: useWSL,
|
|
message: useWSL ? 'Started via WSL' : 'Started natively'
|
|
});
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Preview start error:', error);
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// Platform info endpoint
|
|
app.get('/api/platform', (req, res) => {
|
|
const platform = os.platform();
|
|
const info = {
|
|
platform: platform,
|
|
arch: os.arch(),
|
|
release: os.release(),
|
|
isWindows: platform === 'win32',
|
|
isMac: platform === 'darwin',
|
|
isLinux: platform === 'linux'
|
|
};
|
|
|
|
// Check for WSL on Windows
|
|
if (platform === 'win32') {
|
|
exec('wsl --list --quiet', { timeout: 3000 }, (err, stdout) => {
|
|
info.hasWSL = !err && stdout.trim().length > 0;
|
|
info.wslDistros = info.hasWSL ? stdout.trim().split('\n').filter(d => d.trim()) : [];
|
|
res.json(info);
|
|
});
|
|
} else {
|
|
info.hasWSL = false;
|
|
res.json(info);
|
|
}
|
|
});
|
|
|
|
|
|
// === Git Endpoints for Web Assist ===
|
|
|
|
// Git status
|
|
app.get('/api/git/status', async (req, res) => {
|
|
try {
|
|
exec('git status --porcelain && git branch --show-current', { cwd: WORKSPACE_ROOT }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
return res.json({ success: false, error: 'Not a git repository' });
|
|
}
|
|
|
|
const lines = stdout.trim().split('\n');
|
|
const branch = lines.pop() || 'main';
|
|
const changes = lines.filter(l => l.trim()).length;
|
|
|
|
res.json({ success: true, branch, changes });
|
|
});
|
|
} catch (error) {
|
|
res.json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// Git commit
|
|
app.post('/api/git/commit', async (req, res) => {
|
|
const { message } = req.body;
|
|
|
|
if (!message) {
|
|
return res.status(400).json({ success: false, error: 'Commit message required' });
|
|
}
|
|
|
|
try {
|
|
exec(`git add -A && git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: WORKSPACE_ROOT }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
return res.json({ success: false, error: stderr || err.message });
|
|
}
|
|
res.json({ success: true, output: stdout });
|
|
});
|
|
} catch (error) {
|
|
res.json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// Git push
|
|
app.post('/api/git/push', async (req, res) => {
|
|
try {
|
|
exec('git push', { cwd: WORKSPACE_ROOT, timeout: 30000 }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
return res.json({ success: false, error: stderr || err.message });
|
|
}
|
|
res.json({ success: true, output: stdout || stderr });
|
|
});
|
|
} catch (error) {
|
|
res.json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// Vercel auth status - check token file exists
|
|
app.get('/api/deploy/vercel/status', async (req, res) => {
|
|
try {
|
|
const homeDir = os.homedir();
|
|
const tokenPath = path.join(homeDir, '.vercel', 'auth.json');
|
|
|
|
try {
|
|
const authData = await fs.readFile(tokenPath, 'utf-8');
|
|
const auth = JSON.parse(authData);
|
|
if (auth.token) {
|
|
res.json({ loggedIn: true, user: 'authenticated' });
|
|
} else {
|
|
res.json({ loggedIn: false });
|
|
}
|
|
} catch (e) {
|
|
res.json({ loggedIn: false });
|
|
}
|
|
} catch (error) {
|
|
res.json({ loggedIn: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// Vercel login - open terminal with interactive login
|
|
app.post('/api/deploy/vercel/login', async (req, res) => {
|
|
const platform = os.platform();
|
|
|
|
let terminalCmd;
|
|
if (platform === 'win32') {
|
|
// Windows: Open new cmd window with vercel login
|
|
terminalCmd = 'start cmd /k "vercel login && echo. && echo Login complete! You can close this window."';
|
|
} else if (platform === 'darwin') {
|
|
// macOS: Open Terminal app
|
|
terminalCmd = 'osascript -e \'tell app "Terminal" to do script "vercel login"\'';
|
|
} else {
|
|
// Linux: Try xterm
|
|
terminalCmd = 'x-terminal-emulator -e "vercel login"';
|
|
}
|
|
|
|
exec(terminalCmd, { shell: true, cwd: WORKSPACE_ROOT }, (err) => {
|
|
if (err) {
|
|
res.json({
|
|
success: false,
|
|
error: 'Could not open terminal. Run "vercel login" manually.'
|
|
});
|
|
} else {
|
|
res.json({
|
|
success: true,
|
|
message: 'Terminal opened! Complete login there, then click Deploy.'
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// Vercel deployment - check token file first
|
|
app.post('/api/deploy/vercel', async (req, res) => {
|
|
try {
|
|
const homeDir = os.homedir();
|
|
const tokenPath = path.join(homeDir, '.vercel', 'auth.json');
|
|
|
|
// Check if logged in
|
|
let hasToken = false;
|
|
try {
|
|
const authData = await fs.readFile(tokenPath, 'utf-8');
|
|
hasToken = !!JSON.parse(authData).token;
|
|
} catch (e) {
|
|
hasToken = false;
|
|
}
|
|
|
|
if (!hasToken) {
|
|
return res.json({
|
|
success: false,
|
|
needsLogin: true,
|
|
error: 'Run "vercel login" in terminal first'
|
|
});
|
|
}
|
|
|
|
// Deploy
|
|
exec('vercel --yes --prod', { cwd: WORKSPACE_ROOT, timeout: 180000 }, (err, stdout, stderr) => {
|
|
if (err) {
|
|
return res.json({ success: false, error: stderr || err.message });
|
|
}
|
|
const urlMatch = stdout.match(/https:\/\/[^\s]+\.vercel\.app/);
|
|
res.json({ success: true, url: urlMatch ? urlMatch[0] : null });
|
|
});
|
|
} catch (error) {
|
|
res.json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
|
|
// Error handling middleware
|
|
app.use((err, req, res, next) => {
|
|
console.error('Unhandled error:', err);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
});
|
|
|
|
// 404 handler
|
|
app.use((req, res) => {
|
|
res.status(404).json({ error: 'Not found' });
|
|
});
|
|
|
|
// Start server
|
|
startServer();
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGINT', () => {
|
|
console.log('\n🛑 Shutting down OpenQode Web Server...');
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
console.log('\n🛑 Shutting down OpenQode Web Server...');
|
|
process.exit(0);
|
|
});
|