UNPKG

git-contextor

Version:

A code context tool with vector search and real-time monitoring, with optional Git integration.

430 lines (363 loc) 15.5 kB
const crypto = require('crypto'); const fs = require('fs').promises; const path = require('path'); const { spawn } = require('child_process'); const ManagedTunnelingProvider = require('../tunneling/ManagedTunnelingProvider'); const CorrentlyTunnelProvider = require('../tunneling/CorrentlyTunnelProvider'); function parseDuration(duration) { // Already in milliseconds if (typeof duration === 'number') { return duration; } // Default to 24h if invalid if (typeof duration !== 'string') { return 24 * 60 * 60 * 1000; } const match = duration.match(/^(\d+)([hdwm])$/); if (!match) return 24 * 60 * 60 * 1000; // Default 24h for invalid strings const [, amount, unit] = match; const multipliers = { h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, w: 7 * 24 * 60 * 60 * 1000, m: 30 * 24 * 60 * 60 * 1000 }; return parseInt(amount, 10) * multipliers[unit]; } class SharingService { constructor(repoPath, config) { this.repoPath = repoPath; this.config = config; this.shareStore = new Map(); // In-memory store for shares this.shareDir = path.join(repoPath, '.gitcontextor', 'shares'); this.tunnelProcess = null; this.tunnelUrl = null; this.tunnelService = null; this.tunnelStatus = 'stopped'; // stopped, starting, running, error this.tunnelPassword = null; this.apiKeyStore = new Map(); // In-memory store for apiKey -> shareId mapping // Initialize tunnel providers this.managedTunnelingProvider = null; this.correntlyTunnelProvider = null; if (config.tunneling && config.tunneling.provider === 'managed') { this.managedTunnelingProvider = new ManagedTunnelingProvider(config.tunneling.managed); } else if (config.tunneling && config.tunneling.provider === 'corrently') { this.correntlyTunnelProvider = new CorrentlyTunnelProvider(config.tunneling.corrently); } } async init() { await fs.mkdir(this.shareDir, { recursive: true }); await this.loadExistingShares(); } async createShare(options = {}) { const shareId = crypto.randomBytes(16).toString('hex'); const durationMs = parseDuration(options.duration); const shareConfig = { id: shareId, created_at: new Date().toISOString(), expires_at: new Date(Date.now() + durationMs).toISOString(), scope: options.scope || ['general'], description: options.description || 'Repository AI Access', access_count: 0, max_queries: options.maxQueries || 100, allowed_ips: options.allowedIps || [], api_key: crypto.randomBytes(32).toString('hex') }; this.shareStore.set(shareId, shareConfig); this.apiKeyStore.set(shareConfig.api_key, shareId); // Diese Zeile hinzufügen await this.saveShare(shareConfig); const response = { share_id: shareId, api_key: shareConfig.api_key, expires_at: shareConfig.expires_at, access_url: `/shared/${shareId}` }; if (this.tunnelStatus === 'running' && this.tunnelUrl) { response.public_url = `${this.tunnelUrl}${response.access_url}`; response.mcp_server_url = `${this.tunnelUrl}/mcp/v1`; // Diese Zeile hinzufügen } return response; } async validateShare(shareId, apiKey) { const share = this.shareStore.get(shareId); if (!share) { throw new Error('Share not found'); } if (new Date() > new Date(share.expires_at)) { await this.deleteShare(shareId); throw new Error('Share expired'); } if (share.api_key !== apiKey) { throw new Error('Invalid API key'); } if (share.access_count >= share.max_queries) { throw new Error('Query limit exceeded'); } return share; } async getAndValidateShareByApiKey(apiKey) { const shareId = this.apiKeyStore.get(apiKey); if (!shareId) { return null; } const share = this.shareStore.get(shareId); if (!share) { // Sollte nicht passieren, wenn die Stores synchron sind, aber zur Sicherheit aufräumen this.apiKeyStore.delete(apiKey); return null; } if (new Date() > new Date(share.expires_at)) { // Abgelaufenen Share entfernen await this.deleteShare(shareId); return null; } return share; } async incrementUsage(shareId) { const share = this.shareStore.get(shareId); if (share) { share.access_count++; await this.saveShare(share); } } async saveShare(shareConfig) { const filePath = path.join(this.shareDir, `${shareConfig.id}.json`); await fs.writeFile(filePath, JSON.stringify(shareConfig, null, 2)); } async loadExistingShares() { try { const files = await fs.readdir(this.shareDir); for (const file of files) { if (file.endsWith('.json')) { const content = await fs.readFile(path.join(this.shareDir, file), 'utf8'); const share = JSON.parse(content); // Only load non-expired shares if (new Date() <= new Date(share.expires_at)) { this.shareStore.set(share.id, share); this.apiKeyStore.set(share.api_key, share.id); // Diese Zeile hinzufügen } else { await fs.unlink(path.join(this.shareDir, file)); } } } } catch (error) { // Directory doesn't exist yet, ignore } } async deleteShare(shareId) { const share = this.shareStore.get(shareId); if (share) { this.apiKeyStore.delete(share.api_key); } this.shareStore.delete(shareId); try { await fs.unlink(path.join(this.shareDir, `${shareId}.json`)); } catch (error) { if (error.code !== 'ENOENT') { console.error(`Error unlinking share file for ${shareId}:`, error); } } } getActiveShares() { return Array.from(this.shareStore.values()) .filter(share => new Date() <= new Date(share.expires_at)) .map(share => ({ id: share.id, description: share.description, expires_at: share.expires_at, access_count: share.access_count, max_queries: share.max_queries })); } _parseTunnelOutput(output) { // Match URL, which can appear on stdout or stderr const urlMatch = output.match(/your url is: (https:\/\/[^\s]+)/); if (urlMatch && urlMatch[1] && !this.tunnelUrl) { // Set URL only once this.tunnelUrl = urlMatch[1]; this.tunnelStatus = 'running'; } // Match an explicitly printed password, which can appear on stderr. const passwordMatch = output.match(/(?:your password is:|Password:)\s*(\w+)/i); if (passwordMatch && passwordMatch[1]) { this.tunnelPassword = passwordMatch[1]; } } getTunnelStatus() { if (this.tunnelService === 'managed' && this.managedTunnelingProvider) { return this.managedTunnelingProvider.getTunnelStatus(); } if (this.tunnelService === 'corrently' && this.correntlyTunnelProvider) { return this.correntlyTunnelProvider.getTunnelStatus(); } return { status: this.tunnelStatus, url: this.tunnelUrl, service: this.tunnelService, password: this.tunnelPassword }; } async stopTunnel() { return new Promise(async (resolve) => { if (this.tunnelService === 'managed' && this.managedTunnelingProvider) { try { await this.managedTunnelingProvider.stopTunnel(); } catch (error) { console.error('Error stopping managed tunnel:', error); } } else if (this.tunnelService === 'corrently' && this.correntlyTunnelProvider) { try { await this.correntlyTunnelProvider.stopTunnel(); } catch (error) { console.error('Error stopping corrently tunnel:', error); } } else if (this.tunnelProcess) { this.tunnelProcess.kill(); // The 'exit' event handler will clean up state. } // Reset state immediately for snappy UI this.tunnelProcess = null; this.tunnelUrl = null; this.tunnelService = null; this.tunnelStatus = 'stopped'; this.tunnelPassword = null; resolve(); }); } async startTunnel(service, options = {}) { if (this.tunnelStatus !== 'stopped' && this.tunnelStatus !== 'error') { throw new Error(`Tunnel is already active with status: ${this.tunnelStatus}`); } this.tunnelStatus = 'starting'; this.tunnelService = service; this.tunnelUrl = null; this.tunnelPassword = null; const port = this.config.services.port; if (service === 'managed') { return this.startManagedTunnel(port, options); } else if (service === 'corrently') { return this.startCorrentlyTunnel(port, options); } else if (service === 'localtunnel') { return this.startLocalTunnel(port, options); } else { this.tunnelStatus = 'error'; throw new Error(`Unsupported tunnel service: ${service}`); } } async startManagedTunnel(port, options = {}) { // Create provider if it doesn't exist if (!this.managedTunnelingProvider) { if (!this.config.tunneling?.managed) { throw new Error('Managed tunneling configuration not found. Please configure managed tunneling.'); } this.managedTunnelingProvider = new ManagedTunnelingProvider(this.config.tunneling.managed); } if (!this.config.tunneling.managed.apiKey) { throw new Error('API key required for managed tunneling'); } try { const tunnelData = await this.managedTunnelingProvider.createTunnel({ localPort: port, subdomain: options.subdomain || this.config.tunneling.managed.subdomain, description: options.description || 'Git Contextor Share' }); this.tunnelUrl = tunnelData.url; this.tunnelStatus = 'running'; // Set up event listeners this.managedTunnelingProvider.on('tunnel-error', (error) => { console.error('Managed tunnel error:', error); this.tunnelStatus = 'error'; }); this.managedTunnelingProvider.on('tunnel-disconnected', () => { console.log('Managed tunnel disconnected'); this.tunnelStatus = 'error'; }); this.managedTunnelingProvider.on('tunnel-stopped', () => { console.log('Managed tunnel stopped'); this.tunnelStatus = 'stopped'; this.tunnelUrl = null; }); return tunnelData; } catch (error) { this.tunnelStatus = 'error'; throw error; } } async startCorrentlyTunnel(port, options = {}) { // Create provider if it doesn't exist or if configuration changed const configApiKey = this.config.tunneling?.corrently?.apiKey; const envApiKey = process.env.CORRENTLY_TUNNEL_API_KEY; const apiKey = configApiKey || envApiKey; if (!apiKey) { throw new Error('API key required for tunnel.corrently.cloud service. Set CORRENTLY_TUNNEL_API_KEY environment variable or configure it in the config.'); } if (!this.correntlyTunnelProvider) { const correntlyConfig = { apiKey: apiKey, serverUrl: this.config.tunneling?.corrently?.serverUrl || 'https://tunnel.corrently.cloud', description: this.config.tunneling?.corrently?.description || 'Git Contextor Share' }; this.correntlyTunnelProvider = new CorrentlyTunnelProvider(correntlyConfig); } try { const tunnelData = await this.correntlyTunnelProvider.createTunnel({ localPort: port, subdomain: options.subdomain, gitContextorShare: true, description: options.description || this.config.tunneling?.corrently?.description || 'Git Contextor Share' }); this.tunnelUrl = tunnelData.url; this.tunnelStatus = 'running'; // Set up event listeners this.correntlyTunnelProvider.on('tunnel-error', (error) => { console.error('Corrently tunnel error:', error); this.tunnelStatus = 'error'; }); this.correntlyTunnelProvider.on('tunnel-disconnected', () => { console.log('Corrently tunnel disconnected'); this.tunnelStatus = 'error'; }); this.correntlyTunnelProvider.on('tunnel-stopped', () => { console.log('Corrently tunnel stopped'); this.tunnelStatus = 'stopped'; this.tunnelUrl = null; }); this.correntlyTunnelProvider.on('tunnel-created', (data) => { console.log('Corrently tunnel created:', data.url); }); return tunnelData; } catch (error) { this.tunnelStatus = 'error'; throw error; } } async startLocalTunnel(port, options = {}) { let command, args; command = 'npx'; args = ['localtunnel', '--port', port]; this.tunnelProcess = spawn(command, args); this.tunnelProcess.stdout.on('data', (data) => { const output = data.toString(); this._parseTunnelOutput(output); }); this.tunnelProcess.stderr.on('data', (data) => { const output = data.toString(); // Log stderr for debugging, but still parse it for info like URL or password console.error(`Tunnel service output on stderr: ${output}`); this._parseTunnelOutput(output); }); this.tunnelProcess.on('error', (err) => { console.error('Failed to start tunnel process:', err); this.tunnelStatus = 'error'; this.tunnelProcess = null; }); this.tunnelProcess.on('exit', (code) => { if (this.tunnelStatus !== 'stopped') { this.tunnelStatus = code === 0 ? 'stopped' : 'error'; this.tunnelUrl = null; this.tunnelProcess = null; this.tunnelPassword = null; } }); } } module.exports = SharingService;