UNPKG

coolify-deploy-logs-cli

Version:
747 lines (632 loc) 30.3 kB
#!/usr/bin/env node /** * View Deployment Logs from Coolify * * This module extends the CoolifyDeploy class to add log viewing functionality * It fetches the latest deployment and displays its logs */ const https = require('https'); const { URL } = require('url'); class CoolifyLogs { constructor(baseURL = null) { // Support multiple ways to specify Coolify URL this.baseURL = baseURL || process.env.COOLIFY_URL || process.env.COOLIFY_BASE_URL || process.env.COOLIFY_HOST || 'https://coolify.acc.l-inc.co.za'; // Ensure URL has protocol if (!this.baseURL.startsWith('http://') && !this.baseURL.startsWith('https://')) { this.baseURL = 'https://' + this.baseURL; } // Remove trailing slash this.baseURL = this.baseURL.replace(/\/$/, ''); this.cookies = ''; this.csrfToken = null; this.projectId = null; this.environmentId = null; this.applicationId = null; } async request(url, options = {}) { return new Promise((resolve, reject) => { const urlObj = new URL(url); const opts = { hostname: urlObj.hostname, port: urlObj.port || 443, path: urlObj.pathname + urlObj.search, method: options.method || 'GET', rejectUnauthorized: false, // Allow self-signed certificates headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Accept': '*/*', 'Accept-Language': 'en-US,en;q=0.9', 'Connection': 'keep-alive', 'Accept-Encoding': 'gzip, deflate, br', 'Cache-Control': 'no-cache', ...options.headers } }; if (this.cookies) opts.headers['Cookie'] = this.cookies; if (this.csrfToken) opts.headers['X-CSRF-Token'] = this.csrfToken; if (options.body) { const body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body); opts.headers['Content-Length'] = Buffer.byteLength(body); } const req = https.request(opts, (res) => { // Capture cookies from Set-Cookie header immediately if (res.headers['set-cookie']) { this.cookies = res.headers['set-cookie'].map(c => c.split(';')[0]).join('; '); } // Handle redirects if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { const redirectUrl = res.headers.location.startsWith('http') ? res.headers.location : `${this.baseURL}${res.headers.location}`; return this.request(redirectUrl, options).then(resolve).catch(reject); } let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { const json = JSON.parse(data); resolve({ status: res.statusCode, body: json, headers: res.headers }); } catch (e) { resolve({ status: res.statusCode, body: data, headers: res.headers }); } }); }); req.on('error', reject); if (options.body) { const body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body); req.write(body); } req.end(); }); } async login(email, password) { console.log(`🔐 Logging in to ${this.baseURL}...`); try { // Get CSRF token from login page const loginPageRes = await this.request(`${this.baseURL}/login`); const csrfMatch = loginPageRes.body.match(/<meta name="csrf-token" content="([^"]+)"/); this.csrfToken = csrfMatch ? csrfMatch[1] : null; // Submit login form const loginRes = await this.request(`${this.baseURL}/login`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': this.csrfToken, 'Referer': `${this.baseURL}/login` }, body: new URLSearchParams({ email, password }).toString() }); if (loginRes.body.includes('These credentials do not match')) { throw new Error('Invalid email or password'); } console.log('✅ Logged in successfully\n'); return true; } catch (error) { if (error.code === 'ENOTFOUND') { throw new Error(`Could not resolve Coolify server: ${this.baseURL}\n\n` + `Please specify your Coolify URL using one of these methods:\n` + ` 1. Command line: coolify-logs https://your-coolify-url\n` + ` 2. Environment variable: export COOLIFY_URL=https://your-coolify-url\n` + ` 3. Environment variable: export COOLIFY_HOST=your-coolify-url\n\n` + `Example: coolify-logs https://coolify.example.com`); } throw error; } } async listAllResources() { console.log('📋 Listing all available resources with domains...\n'); try { // Try /dashboard first, then fall back to root let dashboardRes = await this.request(`${this.baseURL}/dashboard`); if (!dashboardRes.body || dashboardRes.body.includes('404')) { dashboardRes = await this.request(`${this.baseURL}/`); } // Extract all projects with their details const projectRegex = /\/project\/([a-z0-9]+)\/environment\/([a-z0-9]+)/g; let match; const projects = {}; while ((match = projectRegex.exec(dashboardRes.body)) !== null) { const projectId = match[1]; const envId = match[2]; if (!projects[projectId]) { projects[projectId] = { id: projectId, name: 'Unknown Project', environments: [] }; } if (!projects[projectId].environments.includes(envId)) { projects[projectId].environments.push(envId); } } // For each project and environment, get applications and their domains let resourceCount = 0; for (const [projId, project] of Object.entries(projects)) { console.log(`📁 ${project.name || 'Unknown Project'}`); for (const envId of project.environments) { // Get applications for this environment try { const projectRes = await this.request(`${this.baseURL}/project/${projId}/environment/${envId}`); // Extract applications JSON data const jsonMatch = projectRes.body.match(/applications:\s*JSON\.parse\('([^']+)'\)/); if (jsonMatch) { try { // Decode the JSON string (handle unicode escapes and forward slashes) const jsonStr = jsonMatch[1] .replace(/\\u0022/g, '"') .replace(/\\\\\//g, '/') .replace(/\\u005c/g, '\\'); const apps = JSON.parse(jsonStr); // Extract domains from each application's fqdn field apps.forEach(app => { if (app.fqdn) { // Extract domain from "https://domain.com" format (after JSON parsing) const domainMatch = app.fqdn.match(/https?:\/\/([a-z0-9.-]+\.[a-z]{2,})/i); if (domainMatch) { const domain = domainMatch[1].toLowerCase(); console.log(` 🌐 ${domain}`); resourceCount++; } } }); } catch (parseErr) { // Silently skip if JSON parsing fails } } } catch (e) { console.log(` ⚠️ Could not fetch environment: ${e.message}`); } } console.log(); } console.log(`✅ Found ${resourceCount} deployable applications\n`); return true; } catch (e) { console.error('Error listing resources:', e.message); return false; } } async findApplicationByDomain(domain) { console.log(`🔍 Searching for application with domain: ${domain}\n`); try { // Get dashboard let dashboardRes = await this.request(`${this.baseURL}/dashboard`); if (!dashboardRes.body || (typeof dashboardRes.body === 'string' && dashboardRes.body.includes('404'))) { dashboardRes = await this.request(`${this.baseURL}/`); } // Extract all projects and environments const projectRegex = /\/project\/([a-z0-9]+)\/environment\/([a-z0-9]+)/g; let match; const projects = {}; while ((match = projectRegex.exec(dashboardRes.body)) !== null) { const projectId = match[1]; const envId = match[2]; if (!projects[projectId]) { projects[projectId] = []; } if (!projects[projectId].includes(envId)) { projects[projectId].push(envId); } } // For each project and environment, check applications for (const [projId, envIds] of Object.entries(projects)) { for (const envId of envIds) { try { const envRes = await this.request(`${this.baseURL}/project/${projId}/environment/${envId}`); // Extract applications JSON data const jsonMatch = envRes.body.match(/applications:\s*JSON\.parse\('([^']+)'\)/); if (jsonMatch) { try { const jsonStr = jsonMatch[1] .replace(/\\u0022/g, '"') .replace(/\\\\\//g, '/') .replace(/\\u005c/g, '\\'); const apps = JSON.parse(jsonStr); // Check each app for matching domain for (const app of apps) { if (app.fqdn) { const domainMatch = app.fqdn.match(/https?:\/\/([a-z0-9.-]+\.[a-z]{2,})/i); if (domainMatch) { const appDomain = domainMatch[1].toLowerCase(); if (appDomain === domain.toLowerCase()) { console.log(`✅ Found application: ${app.name}`); this.projectId = projId; this.environmentId = envId; this.applicationId = app.uuid; return true; } } } } } catch (parseErr) { // Skip if JSON parsing fails } } } catch (e) { // Skip environments that fail } } } console.error(`❌ Application with domain '${domain}' not found`); return false; } catch (error) { console.error('Error searching for application:', error.message); return false; } } async discoverResources() { console.log('🔍 Discovering projects, environments, and applications...'); try { // Try /dashboard first, then fall back to root let dashboardRes = await this.request(`${this.baseURL}/dashboard`); if (!dashboardRes.body || dashboardRes.body.includes('404')) { dashboardRes = await this.request(`${this.baseURL}/`); } // Extract project IDs const projectRegex = /\/project\/([a-z0-9]+)/g; let match; const projects = []; while ((match = projectRegex.exec(dashboardRes.body)) !== null) { if (!projects.includes(match[1])) { projects.push(match[1]); } } if (projects.length > 0) { this.projectId = projects[0]; console.log(`Found project: ${this.projectId}`); // Now get environment and application from the first project const projectRes = await this.request(`${this.baseURL}/project/${this.projectId}`); const envRegex = /\/environment\/([a-z0-9]+)/g; const environments = []; while ((match = envRegex.exec(projectRes.body)) !== null) { if (!environments.includes(match[1])) { environments.push(match[1]); } } if (environments.length > 0) { this.environmentId = environments[0]; console.log(`Found environment: ${this.environmentId}`); // Get application const appRegex = /\/application\/([a-z0-9]+)/g; const applications = []; while ((match = appRegex.exec(projectRes.body)) !== null) { if (!applications.includes(match[1])) { applications.push(match[1]); } } if (applications.length > 0) { this.applicationId = applications[0]; console.log(`Found application: ${this.applicationId}\n`); return true; } } } return false; } catch (e) { console.error('Error discovering resources:', e.message); return false; } } async getDeploymentsList() { console.log('📋 Fetching deployments list...'); // If we don't have the IDs, try to discover them if (!this.projectId || !this.environmentId || !this.applicationId) { const discovered = await this.discoverResources(); if (!discovered) { console.error('❌ Could not discover project/environment/application IDs'); return []; } } const deploymentsPageUrl = `${this.baseURL}/project/${this.projectId}/environment/${this.environmentId}/application/${this.applicationId}/deployment`; const res = await this.request(deploymentsPageUrl); // Extract deployments with their timestamps for proper ordering const deployments = []; // Look for deployment entries in the HTML // Pattern: deployment ID followed by status and timestamps const deploymentPattern = /\/deployment\/([a-z0-9]{24})[^<]*(?:<[^>]*>)*([^<]*(?:Success|Failed|Running|Pending)[^<]*)/g; let match; while ((match = deploymentPattern.exec(res.body)) !== null) { const deploymentId = match[1]; // Check if we already have this deployment if (!deployments.some(d => d.id === deploymentId)) { deployments.push({ id: deploymentId, info: match[2] }); } } // If pattern didn't work, fall back to simple ID extraction if (deployments.length === 0) { const deploymentRegex = /\/deployment\/([a-z0-9]{24})/g; while ((match = deploymentRegex.exec(res.body)) !== null) { const deploymentId = match[1]; if (!deployments.some(d => d.id === deploymentId)) { deployments.push({ id: deploymentId, info: '' }); } } } console.log(`✅ Found ${deployments.length} deployments\n`); // Return just the IDs in order (first is latest) return deployments.map(d => d.id); } async getDeploymentDetails(deploymentId) { console.log(`📦 Getting deployment details for ${deploymentId}...`); const deploymentPageUrl = `${this.baseURL}/project/${this.projectId}/environment/${this.environmentId}/application/${this.applicationId}/deployment/${deploymentId}`; const res = await this.request(deploymentPageUrl); // Extract deployment information from HTML const details = { id: deploymentId, url: deploymentPageUrl, statusMatch: res.body.match(/Success|Failed|Running|Pending/), status: null, startedAt: null, endedAt: null }; if (details.statusMatch) { details.status = details.statusMatch[0]; } // Try to extract timestamps const startedMatch = res.body.match(/Started:\s*([^<]+)/); const endedMatch = res.body.match(/Ended:\s*([^<]+)/); if (startedMatch) details.startedAt = startedMatch[1]; if (endedMatch) details.endedAt = endedMatch[1]; console.log(`✅ Deployment Status: ${details.status || 'Unknown'}`); if (details.startedAt) console.log(` Started: ${details.startedAt}`); if (details.endedAt) console.log(` Ended: ${details.endedAt}`); console.log(); return details; } async getDeploymentLogs(deploymentId) { console.log(`📜 Fetching deployment logs for ${deploymentId}...\n`); // Try the deployment page const logsPageUrl = `${this.baseURL}/project/${this.projectId}/environment/${this.environmentId}/application/${this.applicationId}/deployment/${deploymentId}`; const res = await this.request(logsPageUrl); let logsContent = null; let logFound = false; // Strategy 1: Extract from Livewire component snapshot data const wireMatch = res.body.match(/wire:snapshot="([^"]+)"/); if (wireMatch) { try { // Decode HTML entities in the snapshot let decoded = wireMatch[1] .replace(/&quot;/g, '"') .replace(/&lt;/g, '<') .replace(/&gt;/g, '>') .replace(/&amp;/g, '&'); const wireData = JSON.parse(decoded); // Look for fields that might contain deployment output if (wireData.data && wireData.data.output) { logsContent = wireData.data.output; logFound = true; } else if (wireData.data && wireData.data.logs) { logsContent = wireData.data.logs; logFound = true; } else if (wireData.data && wireData.data.deployment_logs) { logsContent = wireData.data.deployment_logs; logFound = true; } else if (wireData.data) { // Search for any field containing significant log-like data for (const [key, value] of Object.entries(wireData.data || {})) { if (typeof value === 'string' && value.length > 500) { logsContent = value; logFound = true; break; } } } } catch (e) { console.error('Error parsing wire snapshot:', e.message); } } // Strategy 2: Extract from HTML rendered logs section if (!logFound) { // Look for error messages or status blocks const errorMatch = res.body.match(/<[^>]*>(Error|Server is not functional)[^<]*<\/[^>]*>/); if (errorMatch) { logsContent = errorMatch[0]; logFound = true; } } // Strategy 3: Look for pre/code tags as fallback if (!logFound) { const preMatch = res.body.match(/<pre[^>]*>([\s\S]{1,5000}?)<\/pre>/); if (preMatch) { logsContent = preMatch[1]; logFound = true; } } // Strategy 4: Look for code tags if (!logFound) { const codeMatch = res.body.match(/<code[^>]*>([\s\S]{1,5000}?)<\/code>/); if (codeMatch) { logsContent = codeMatch[1]; logFound = true; } } // Strategy 5: Extract any substantial text content that looks like logs if (!logFound) { const textMatch = res.body.match(/(step|clone|install|build|error|failed|success|docker)[^\n]{0,200}/gi); if (textMatch && textMatch.length > 0) { logsContent = textMatch.join('\n'); logFound = true; } } if (logFound && logsContent) { // Decode HTML entities logsContent = String(logsContent) .replace(/&lt;/g, '<') .replace(/&gt;/g, '>') .replace(/&amp;/g, '&') .replace(/&quot;/g, '"') .replace(/&#039;/g, "'") .replace(/&nbsp;/g, ' ') .replace(/\\n/g, '\n') .replace(/\\r/g, '\r') .replace(/\\t/g, '\t') .replace(/<br\s*\/?>/gi, '\n') .replace(/<[^>]+>/g, ''); // Remove remaining HTML tags console.log('--- DEPLOYMENT LOGS ---\n'); console.log(logsContent); console.log('\n--- END LOGS ---\n'); return logsContent; } else { console.log('⚠️ Could not extract logs from deployment page\n'); console.log('Deployment URL: ' + logsPageUrl + '\n'); console.log('Try accessing the deployment page directly for detailed logs.\n'); return null; } } async viewLatestDeploymentLogsByDomain(domain) { try { const email = process.env.COOLIFY_USERNAME; const password = process.env.COOLIFY_PASSWORD; if (!email || !password) { console.error('❌ Missing credentials'); console.error('Set COOLIFY_USERNAME and COOLIFY_PASSWORD environment variables with your Coolify login details\n'); process.exit(1); } console.log('═════════════════════════════════════════\n'); console.log('🚀 Coolify Deployment Logs Viewer\n'); console.log('═════════════════════════════════════════\n'); // Step 1: Login await this.login(email, password); // Step 2: Find application by domain const found = await this.findApplicationByDomain(domain); if (!found) { console.error(`❌ Application with domain '${domain}' not found`); process.exit(1); } // Step 3: Get deployments list const deploymentIds = await this.getDeploymentsList(); if (deploymentIds.length === 0) { console.log('❌ No deployments found\n'); return; } // Step 4: Get latest deployment details const latestDeploymentId = deploymentIds[0]; const deploymentDetails = await this.getDeploymentDetails(latestDeploymentId); // Step 5: Fetch and display logs await this.getDeploymentLogs(latestDeploymentId); console.log('✅ Done!\n'); } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } } async viewLatestDeploymentLogs(projId = null, envId = null, appId = null) { try { const email = process.env.COOLIFY_USERNAME; const password = process.env.COOLIFY_PASSWORD; if (!email || !password) { console.error('❌ Missing credentials'); console.error('Set COOLIFY_USERNAME and COOLIFY_PASSWORD environment variables with your Coolify login details\n'); process.exit(1); } console.log('═════════════════════════════════════════\n'); console.log('🚀 Coolify Deployment Logs Viewer\n'); console.log('═════════════════════════════════════════\n'); // Step 1: Login await this.login(email, password); // Step 2: If IDs not provided, discover them if (!projId || !envId || !appId) { const discovered = await this.discoverResources(); if (!discovered) { console.error('❌ Could not discover project/environment/application IDs'); return; } projId = this.projectId; envId = this.environmentId; appId = this.applicationId; } else { this.projectId = projId; this.environmentId = envId; this.applicationId = appId; } // Step 3: Get deployments list const deploymentIds = await this.getDeploymentsList(); if (deploymentIds.length === 0) { console.log('❌ No deployments found\n'); return; } // Step 4: Get latest deployment details const latestDeploymentId = deploymentIds[0]; const deploymentDetails = await this.getDeploymentDetails(latestDeploymentId); // Step 5: Fetch and display logs await this.getDeploymentLogs(latestDeploymentId); console.log('✅ Done!\n'); } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } } } // Run if called directly if (require.main === module) { // Parse command line arguments const args = process.argv.slice(2); let baseURL = null; let command = 'logs'; // Parse arguments: coolify-logs [<url>] [command] if (args.length > 0) { if (args[0] === 'list') { command = 'list'; baseURL = process.env.COOLIFY_URL; } else if (args[0].startsWith('http')) { baseURL = args[0]; if (args[1] === 'list') { command = 'list'; } else if (args[1]) { command = args[1]; } } else { command = args[0]; baseURL = process.env.COOLIFY_URL; } } const viewer = new CoolifyLogs(baseURL); if (command === 'list') { // For list command, login and show all available resources with domains (async () => { try { const email = process.env.COOLIFY_USERNAME; const password = process.env.COOLIFY_PASSWORD; if (!email || !password) { console.error('❌ Missing credentials'); console.error('Set COOLIFY_USERNAME and COOLIFY_PASSWORD environment variables with your Coolify login details\n'); process.exit(1); } await viewer.login(email, password); await viewer.listAllResources(); } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } })(); } else { // Check if command is a domain (contains . and no /) or proj/env/app format let parsedProjId = null, parsedEnvId = null, parsedAppId = null; if (command && command.includes('.') && !command.includes('/')) { // Command looks like a domain viewer.viewLatestDeploymentLogsByDomain(command); } else if (command && command.includes('/')) { // Command looks like proj/env/app format const parts = command.split('/'); if (parts.length === 3) { [parsedProjId, parsedEnvId, parsedAppId] = parts; viewer.viewLatestDeploymentLogs(parsedProjId, parsedEnvId, parsedAppId); } else { viewer.viewLatestDeploymentLogs(); } } else { viewer.viewLatestDeploymentLogs(); } } } module.exports = CoolifyLogs;