UNPKG

synchronizer-cli

Version:

WEBSOCKET PORT FIXED - WebSocket connection now uses correct port 3333 where the server actually runs (confirmed with curl test). No more connection failures or socket hang ups - Complete CLI toolkit for Multisynq Synchronizer with working real-time WebSo

1,408 lines (1,196 loc) 240 kB
#!/usr/bin/env node const { Command } = require('commander'); const inquirer = require('inquirer'); const chalk = require('chalk'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const os = require('os'); const { spawn, execSync } = require('child_process'); const express = require('express'); const packageJson = require('./package.json'); const fetch = require('node-fetch'); // Add node-fetch for API validation const WebSocket = require('ws'); // Add WebSocket for real-time container communication const program = new Command(); const CONFIG_DIR = path.join(os.homedir(), '.synchronizer-cli'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); const POINTS_FILE = path.join(CONFIG_DIR, 'points.json'); // Cache file for wallet points API responses const CACHE_FILE = path.join(CONFIG_DIR, 'wallet-points-cache.json'); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds for successful responses const ERROR_CACHE_DURATION = 30 * 1000; // 30 seconds for error responses // Global variable to store WebSocket connection and latest data let containerWebSocket = null; let latestContainerData = null; let wsConnectionAttempts = 0; const MAX_WS_RECONNECT_ATTEMPTS = 5; let wsInitialized = false; // Flag to ensure we only try to connect once // Global rate limiting and caching for ALL stats requests let lastStatsRequestTime = 0; let lastStatsResult = null; let statsRequestInProgress = null; // Promise to prevent race conditions const STATS_REQUEST_COOLDOWN = 60 * 1000; // 60 seconds between ANY stats requests (once a minute as requested) const STATS_CACHE_DURATION = 60 * 1000; // Cache results for 60 seconds (once a minute as requested) // Global WebSocket connection management let wsConnectionInProgress = false; let lastWebSocketRequestTime = 0; const WS_REQUEST_COOLDOWN = 10 * 1000; // Only allow WebSocket requests every 10 seconds // Global caching for all dashboard data to prevent redundant requests let globalCache = { performance: { data: null, timestamp: 0 }, points: { data: null, timestamp: 0 }, status: { data: null, timestamp: 0 } }; const DASHBOARD_CACHE_DURATION = 30 * 1000; // Cache dashboard data for 30 seconds function loadConfig() { if (fs.existsSync(CONFIG_FILE)) { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } return {}; } function saveConfig(config) { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); } fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); } function loadPointsData() { if (fs.existsSync(POINTS_FILE)) { try { return JSON.parse(fs.readFileSync(POINTS_FILE, 'utf8')); } catch (error) { console.log('Error loading points data, starting fresh:', error.message); return createEmptyPointsData(); } } return createEmptyPointsData(); } function savePointsData(pointsData) { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); } fs.writeFileSync(POINTS_FILE, JSON.stringify(pointsData, null, 2)); } function createEmptyPointsData() { return { totalLifetimePoints: 0, sessions: [], lastUpdated: new Date().toISOString(), version: '1.0' }; } function authenticateRequest(req, res, next) { const config = loadConfig(); // If no password is set, allow access if (!config.dashboardPassword) { return next(); } const auth = req.headers.authorization; if (!auth || !auth.startsWith('Basic ')) { res.setHeader('WWW-Authenticate', 'Basic realm="Synchronizer Dashboard"'); res.status(401).send('Authentication required'); return; } const credentials = Buffer.from(auth.slice(6), 'base64').toString(); const [username, password] = credentials.split(':'); // Simple authentication - username can be anything, password must match if (password === config.dashboardPassword) { req.authenticated = true; return next(); } res.setHeader('WWW-Authenticate', 'Basic realm="Synchronizer Dashboard"'); res.status(401).send('Invalid credentials'); } function generateSyncHash(userName, secret, hostname) { const input = `${userName || ''}:${hostname}:${secret}`; const hash = crypto.createHash('sha256').update(input).digest('hex'); return `synq-${hash.slice(0, 12)}`; } function detectNpxPath() { try { // Try to find npx using 'which' command const npxPath = execSync('which npx', { encoding: 'utf8', stdio: 'pipe' }).trim(); if (npxPath && fs.existsSync(npxPath)) { return npxPath; } } catch (error) { // 'which' failed, try other methods } try { // Try to find npm and assume npx is in the same directory const npmPath = execSync('which npm', { encoding: 'utf8', stdio: 'pipe' }).trim(); if (npmPath) { const npxPath = npmPath.replace(/npm$/, 'npx'); if (fs.existsSync(npxPath)) { return npxPath; } } } catch (error) { // npm not found either } // Common fallback locations const fallbackPaths = [ '/usr/bin/npx', '/usr/local/bin/npx', '/opt/homebrew/bin/npx', path.join(os.homedir(), '.npm-global/bin/npx'), path.join(os.homedir(), '.nvm/current/bin/npx') ]; for (const fallbackPath of fallbackPaths) { if (fs.existsSync(fallbackPath)) { return fallbackPath; } } // Last resort - assume it's in PATH return 'npx'; } /** * Check if a new Docker image is available by comparing local and remote digests * @param {string} imageName Docker image name with tag * @returns {Promise<boolean>} True if new image is available or no local image exists */ async function isNewDockerImageAvailable(imageName) { try { // Check if we have the image locally try { const localImageCmd = `docker images ${imageName} --format "{{.ID}}"`; const localImageId = execSync(localImageCmd, { encoding: 'utf8', stdio: 'pipe' }).trim(); // If there's no local image, we need to pull if (!localImageId) { return true; } } catch (error) { // No local image found return true; } // For now, we'll use a simpler approach: // Always pull with --pull always flag when starting containers // This lets Docker handle the logic of whether to actually download // Return false to avoid duplicate pulling attempts return false; } catch (error) { // On any error, assume we should try to pull return true; } } /** * Validate synq key format using regex pattern * Checks if the key is a valid UUID v4 format * @param {string} key The synq key to validate * @returns {boolean} True if the key format is valid */ function validateSynqKeyFormat(key) { return /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(key); } /** * Check if a synq key is valid by calling the remote API * @param {string} key The synq key to check * @param {string} nickname Optional nickname for the synchronizer * @returns {Promise<{isValid: boolean, message: string}>} Result object with validation status and message */ async function validateSynqKeyWithAPI(key, nickname = '') { const DOMAIN = 'multisynq.io'; const SYNQ_KEY_URL = `https://api.${DOMAIN}/depin/synchronizers/key`; // If no nickname is provided, use a default one to prevent the "missing synchronizer name" error const syncNickname = nickname || 'cli-validator'; const url = `${SYNQ_KEY_URL}/${key}/precheck?nickname=${encodeURIComponent(syncNickname)}`; console.log(chalk.gray(`Validating synq key with remote API...`)); try { const response = await fetch(url); const keyStatus = await response.text(); if (keyStatus === 'ok') { return { isValid: true, message: 'Key is valid and available' }; } else { return { isValid: false, message: keyStatus }; } } catch (error) { return { isValid: false, message: `Could not validate key with API: ${error.message}. Will proceed with local validation only.` }; } } async function init() { const questions = []; questions.push({ type: 'input', name: 'userName', message: 'Optional sync name (for your reference only):', default: '' }); // Get the userName first const userNameAnswer = await inquirer.prompt([questions[0]]); const userName = userNameAnswer.userName; // Then use it when validating the key const keyQuestion = { type: 'input', name: 'key', message: 'Synq key:', validate: async (input) => { if (!input) return 'Synq key is required'; // First validate the format locally if (!validateSynqKeyFormat(input)) { return 'Invalid synq key format. Must be a valid UUID v4 format (XXXXXXXX-XXXX-4XXX-YXXX-XXXXXXXXXXXX where Y is 8, 9, A, or B)'; } // If local validation passes, try remote validation with the userName try { // Use the userName or a default nickname const nickname = userName || 'cli-setup'; const validationResult = await validateSynqKeyWithAPI(input, nickname); if (!validationResult.isValid) { // If API returns an error specific to the key, show it if (validationResult.message.includes('Key')) { return validationResult.message; } // For network errors, we'll accept the key if it passed format validation console.log(chalk.yellow(`⚠️ ${validationResult.message}`)); console.log(chalk.yellow('Continuing with local validation only.')); } else { console.log(chalk.green('✅ Key validated successfully with API')); } return true; } catch (error) { // If API validation fails for any reason, accept the key if it passed format validation console.log(chalk.yellow(`⚠️ API validation error: ${error.message}`)); console.log(chalk.yellow('Continuing with local validation only.')); return true; } } }; // Add the key question and wallet question const remainingQuestions = [ keyQuestion, { type: 'input', name: 'wallet', message: 'Wallet address:', validate: input => input ? true : 'Wallet is required', }, { type: 'confirm', name: 'setDashboardPassword', message: 'Set a password for the web dashboard? (Recommended for security):', default: true } ]; // Get answers for the remaining questions const remainingAnswers = await inquirer.prompt(remainingQuestions); // Combine all answers const answers = { ...userNameAnswer, ...remainingAnswers }; // Ask for password if user wants to set one if (answers.setDashboardPassword) { const passwordQuestions = [{ type: 'password', name: 'dashboardPassword', message: 'Dashboard password:', validate: input => input && input.length >= 4 ? true : 'Password must be at least 4 characters', mask: '*' }]; const passwordAnswers = await inquirer.prompt(passwordQuestions); answers.dashboardPassword = passwordAnswers.dashboardPassword; } const secret = crypto.randomBytes(8).toString('hex'); const hostname = os.hostname(); const syncHash = generateSyncHash(answers.userName, secret, hostname); const config = { ...answers, secret, hostname, syncHash, depin: 'wss://api.multisynq.io/depin', launcher: 'cli' }; // Remove the setDashboardPassword flag from config delete config.setDashboardPassword; saveConfig(config); console.log(chalk.green('Configuration saved to'), CONFIG_FILE); if (config.dashboardPassword) { console.log(chalk.yellow('🔒 Dashboard password protection enabled')); console.log(chalk.gray('Use any username with your password to access the web dashboard')); } else { console.log(chalk.yellow('⚠️ Dashboard is unprotected - synq key will be visible to anyone')); } } function checkDocker() { try { execSync('docker --version', { stdio: 'ignore' }); return true; } catch (error) { return false; } } async function installDocker() { const platform = os.platform(); console.log(chalk.blue('🐳 Docker Installation Helper')); console.log(chalk.yellow('This will help you install Docker on your system.\n')); if (platform === 'linux') { const distro = await detectLinuxDistro(); console.log(chalk.cyan(`Detected Linux distribution: ${distro}`)); const confirm = await inquirer.prompt([{ type: 'confirm', name: 'install', message: 'Would you like to install Docker automatically?', default: true }]); if (confirm.install) { await installDockerLinux(distro); } else { showManualInstructions(platform); } } else { console.log(chalk.yellow(`Automatic installation not supported on ${platform}.`)); showManualInstructions(platform); } } async function detectLinuxDistro() { try { const release = fs.readFileSync('/etc/os-release', 'utf8'); if (release.includes('ubuntu') || release.includes('Ubuntu')) return 'ubuntu'; if (release.includes('debian') || release.includes('Debian')) return 'debian'; if (release.includes('centos') || release.includes('CentOS')) return 'centos'; if (release.includes('rhel') || release.includes('Red Hat')) return 'rhel'; if (release.includes('fedora') || release.includes('Fedora')) return 'fedora'; return 'unknown'; } catch (error) { return 'unknown'; } } async function installDockerLinux(distro) { console.log(chalk.blue('Installing Docker...')); try { if (distro === 'ubuntu' || distro === 'debian') { console.log(chalk.cyan('Updating package index...')); execSync('sudo apt-get update', { stdio: 'inherit' }); console.log(chalk.cyan('Installing prerequisites...')); execSync('sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release', { stdio: 'inherit' }); console.log(chalk.cyan('Adding Docker GPG key...')); execSync('curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg', { stdio: 'inherit' }); console.log(chalk.cyan('Adding Docker repository...')); const arch = execSync('dpkg --print-architecture', { encoding: 'utf8' }).trim(); const codename = execSync('lsb_release -cs', { encoding: 'utf8' }).trim(); execSync(`echo "deb [arch=${arch} signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu ${codename} stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null`, { stdio: 'inherit' }); console.log(chalk.cyan('Installing Docker...')); execSync('sudo apt-get update', { stdio: 'inherit' }); execSync('sudo apt-get install -y docker-ce docker-ce-cli containerd.io', { stdio: 'inherit' }); } else if (distro === 'centos' || distro === 'rhel' || distro === 'fedora') { console.log(chalk.cyan('Installing Docker via yum/dnf...')); const installer = distro === 'fedora' ? 'dnf' : 'yum'; execSync(`sudo ${installer} install -y yum-utils`, { stdio: 'inherit' }); execSync(`sudo ${installer}-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo`, { stdio: 'inherit' }); execSync(`sudo ${installer} install -y docker-ce docker-ce-cli containerd.io`, { stdio: 'inherit' }); } console.log(chalk.cyan('Starting Docker service...')); execSync('sudo systemctl start docker', { stdio: 'inherit' }); execSync('sudo systemctl enable docker', { stdio: 'inherit' }); console.log(chalk.cyan('Adding user to docker group...')); const username = os.userInfo().username; execSync(`sudo usermod -aG docker ${username}`, { stdio: 'inherit' }); console.log(chalk.green('✅ Docker installed successfully!')); console.log(chalk.yellow('⚠️ You may need to log out and log back in for group changes to take effect.')); console.log(chalk.blue('You can test Docker with: docker run hello-world')); } catch (error) { console.error(chalk.red('❌ Failed to install Docker automatically.')); console.error(chalk.red('Error:', error.message)); showManualInstructions('linux'); } } function showManualInstructions(platform) { console.log(chalk.blue('\n📖 Manual Installation Instructions:')); if (platform === 'linux') { console.log(chalk.white('For Ubuntu/Debian:')); console.log(chalk.gray(' curl -fsSL https://get.docker.com -o get-docker.sh')); console.log(chalk.gray(' sudo sh get-docker.sh')); console.log(chalk.white('\nFor CentOS/RHEL/Fedora:')); console.log(chalk.gray(' sudo yum install -y docker-ce')); console.log(chalk.gray(' sudo systemctl start docker')); } else if (platform === 'darwin') { console.log(chalk.white('For macOS:')); console.log(chalk.gray(' Download Docker Desktop from: https://docs.docker.com/desktop/mac/install/')); console.log(chalk.gray(' Or install via Homebrew: brew install --cask docker')); } else if (platform === 'win32') { console.log(chalk.white('For Windows:')); console.log(chalk.gray(' Download Docker Desktop from: https://docs.docker.com/desktop/windows/install/')); } console.log(chalk.blue('\nFor more details: https://docs.docker.com/get-docker/')); } async function start() { const config = loadConfig(); if (!config.key) { console.error(chalk.red('Missing synq key. Run `synchronize init` first.')); process.exit(1); } if (config.hostname !== os.hostname()) { console.error(chalk.red(`This config was created for ${config.hostname}, not ${os.hostname()}.`)); process.exit(1); } // Check if Docker is installed if (!checkDocker()) { console.error(chalk.red('Docker is not installed or not accessible.')); const shouldInstall = await inquirer.prompt([{ type: 'confirm', name: 'install', message: 'Would you like to install Docker now?', default: true }]); if (shouldInstall.install) { await installDocker(); // Check again after installation if (!checkDocker()) { console.error(chalk.red('Docker installation may have failed or requires a restart.')); console.error(chalk.yellow('Please try running the command again after restarting your terminal.')); process.exit(1); } } else { console.error(chalk.yellow('Please install Docker first: https://docs.docker.com/get-docker/')); process.exit(1); } } const syncName = config.syncHash; const containerName = 'synchronizer-cli'; // Check if container is already running try { const runningContainers = execSync(`docker ps --filter name=${containerName} --format "{{.Names}}"`, { encoding: 'utf8', stdio: 'pipe' }); if (runningContainers.includes(containerName)) { console.log(chalk.green(`✅ Found existing synchronizer container running`)); console.log(chalk.cyan(`🔗 Connecting to logs... (Ctrl+C will stop the container)`)); // Connect to the existing container's logs const logProc = spawn('docker', ['logs', '-f', containerName], { stdio: 'inherit' }); // Handle Ctrl+C to stop the container const cleanup = () => { console.log(chalk.yellow('\n🛑 Stopping synchronizer container...')); try { execSync(`docker stop ${containerName}`, { stdio: 'pipe' }); console.log(chalk.green('✅ Container stopped')); } catch (error) { console.log(chalk.red('❌ Error stopping container:', error.message)); } process.exit(0); }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); logProc.on('exit', (code) => { process.exit(code); }); return; } } catch (error) { // No existing container, continue with normal startup } // Detect platform architecture const arch = os.arch(); const platform = os.platform(); let dockerPlatform = 'linux/amd64'; // Default to amd64 if (platform === 'linux') { if (arch === 'arm64' || arch === 'aarch64') { dockerPlatform = 'linux/arm64'; } else if (arch === 'x64' || arch === 'x86_64') { dockerPlatform = 'linux/amd64'; } } else if (platform === 'darwin') { dockerPlatform = arch === 'arm64' ? 'linux/arm64' : 'linux/amd64'; } console.log(chalk.blue(`Detected platform: ${platform}/${arch} -> Using Docker platform: ${dockerPlatform}`)); // Use the main synchronizer image const imageName = 'cdrakep/synqchronizer:latest'; // Get dynamic version info for launcher let dockerImageVersion = 'latest'; try { // Try to get the version from the image we're about to use const imageInspectOutput = execSync(`docker inspect ${imageName} --format "{{json .Config.Labels}}"`, { encoding: 'utf8', stdio: 'pipe' }); const labels = JSON.parse(imageInspectOutput); if (labels && labels.version) { dockerImageVersion = labels.version; } else { // Get image creation date as fallback const createdOutput = execSync(`docker inspect ${imageName} --format "{{.Created}}"`, { encoding: 'utf8', stdio: 'pipe' }); const created = new Date(createdOutput.trim()); dockerImageVersion = `${created.toISOString().split('T')[0]}`; } } catch (error) { // Use latest as fallback dockerImageVersion = 'latest'; } // Set launcher with dynamic version const launcherWithVersion = `cli-${packageJson.version}/docker-${dockerImageVersion}`; console.log(chalk.cyan(`Using launcher identifier: ${launcherWithVersion}`)); // Check if we need to pull the latest Docker image const shouldPull = await isNewDockerImageAvailable(imageName); // Pull the latest image only if necessary if (shouldPull) { console.log(chalk.cyan('Pulling latest Docker image...')); try { execSync(`docker pull ${imageName}`, { stdio: ['ignore', 'pipe', 'pipe'] }); console.log(chalk.green('✅ Docker image pulled successfully')); } catch (error) { console.log(chalk.yellow('⚠️ Could not pull latest image - will use local cache if available')); console.log(chalk.gray(error.message)); } } // Create Docker command const dockerCmd = 'docker'; const args = [ 'run', '--rm', '--name', containerName, '--pull', 'always', // Always try to pull the latest image '--platform', dockerPlatform, '-p', '3333:3333', // Expose WebSocket CLI port '-p', '9090:9090', // Expose HTTP metrics port imageName ]; // Add container arguments correctly - each flag and value as separate items if (config.depin) { args.push('--depin'); args.push(config.depin); } else { args.push('--depin'); args.push('wss://api.multisynq.io/depin'); } args.push('--sync-name'); args.push(syncName); args.push('--launcher'); args.push(launcherWithVersion); args.push('--key'); args.push(config.key); if (config.wallet) { args.push('--wallet'); args.push(config.wallet); } if (config.account) { args.push('--account'); args.push(config.account); } console.log(chalk.cyan(`Running synchronizer "${syncName}" with wallet ${config.wallet || '[none]'}`)); // For debugging console.log(chalk.gray(`Running command: ${dockerCmd} ${args.join(' ')}`)); const proc = spawn(dockerCmd, args, { stdio: 'inherit' }); // Handle Ctrl+C to stop the container const cleanup = () => { console.log(chalk.yellow('\n🛑 Stopping synchronizer container...')); try { execSync(`docker stop ${containerName}`, { stdio: 'pipe' }); console.log(chalk.green('✅ Container stopped')); } catch (error) { console.log(chalk.red('❌ Error stopping container:', error.message)); } process.exit(0); }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); proc.on('error', (err) => { if (err.code === 'ENOENT') { console.error(chalk.red('Docker command not found. Please ensure Docker is installed and in your PATH.')); } else { console.error(chalk.red('Error running Docker:'), err.message); } process.exit(1); }); proc.on('exit', code => { if (code === 126) { console.error(chalk.red('❌ Docker permission denied.')); console.error(chalk.yellow('This usually means your user is not in the docker group.')); console.error(chalk.blue('\n🔧 To fix this:')); console.error(chalk.white('1. Add your user to the docker group:')); console.error(chalk.gray(` sudo usermod -aG docker ${os.userInfo().username}`)); console.error(chalk.white('2. Log out and log back in (or restart your terminal)')); console.error(chalk.white('3. Test with: docker run hello-world')); console.error(chalk.blue('\n💡 Alternative: Run with sudo (not recommended):')); console.error(chalk.gray(' sudo synchronize start')); console.error(chalk.blue('\n🔧 Or use the fix command:')); console.error(chalk.gray(' synchronize fix-docker')); } else if (code === 125) { console.error(chalk.red('❌ Docker container failed to start.')); console.error(chalk.yellow('This might be due to platform architecture issues.')); console.error(chalk.blue('\n🔧 Troubleshooting steps:')); console.error(chalk.gray('1. Test platform compatibility:')); console.error(chalk.gray(' synchronize test-platform')); console.error(chalk.gray('2. Check Docker logs:')); console.error(chalk.gray(' docker logs synchronizer-cli')); console.error(chalk.gray('3. Try running with different platform:')); console.error(chalk.gray(' docker run --platform linux/amd64 cdrakep/synqchronizer:latest --help')); } else if (code !== 0) { console.error(chalk.red(`Docker process exited with code ${code}`)); } process.exit(code); }); } /** * Generate systemd service file and environment file for headless operation. */ async function installService() { const config = loadConfig(); if (!config.key) { console.error(chalk.red('Missing synq key. Run `synchronize init` first.')); process.exit(1); } if (!config.wallet && !config.account) { console.error(chalk.red('Missing wallet or account. Run `synchronize init` first.')); process.exit(1); } const serviceFile = path.join(CONFIG_DIR, 'synchronizer-cli.service'); const user = os.userInfo().username; // Detect platform architecture (same logic as start function) const arch = os.arch(); const platform = os.platform(); let dockerPlatform = 'linux/amd64'; // Default to amd64 if (platform === 'linux') { if (arch === 'arm64' || arch === 'aarch64') { dockerPlatform = 'linux/arm64'; } else if (arch === 'x64' || arch === 'x86_64') { dockerPlatform = 'linux/amd64'; } } else if (platform === 'darwin') { dockerPlatform = arch === 'arm64' ? 'linux/arm64' : 'linux/amd64'; } // Detect Docker path for PATH environment let dockerPath = '/usr/bin/docker'; try { const dockerWhich = execSync('which docker', { encoding: 'utf8', stdio: 'pipe' }).trim(); if (dockerWhich && fs.existsSync(dockerWhich)) { dockerPath = dockerWhich; } } catch (error) { // Use default path } const dockerDir = path.dirname(dockerPath); // Build PATH environment variable including docker directory const systemPaths = [ '/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', '/sbin', '/bin' ]; // Add docker directory to the beginning of PATH if it's not already a system path const pathDirs = systemPaths.includes(dockerDir) ? systemPaths : [dockerDir, ...systemPaths]; const pathEnv = pathDirs.join(':'); // Get dynamic version info for service launcher let dockerImageVersion = 'latest'; try { // Try to get the version from the main image const imageName = 'cdrakep/synqchronizer:latest'; const imageInspectOutput = execSync(`docker inspect ${imageName} --format "{{json .Config.Labels}}"`, { encoding: 'utf8', stdio: 'pipe' }); const labels = JSON.parse(imageInspectOutput); if (labels && labels.version) { dockerImageVersion = labels.version; } else { // Get image creation date as fallback const createdOutput = execSync(`docker inspect ${imageName} --format "{{.Created}}"`, { encoding: 'utf8', stdio: 'pipe' }); const created = new Date(createdOutput.trim()); dockerImageVersion = `${created.toISOString().split('T')[0]}`; } } catch (error) { // Use latest as fallback dockerImageVersion = 'latest'; } // Set launcher with dynamic version const launcherWithVersion = `cli-${packageJson.version}/docker-${dockerImageVersion}`; console.log(chalk.cyan(`Using launcher identifier: ${launcherWithVersion}`)); // No need to check for image updates here - the service will use --pull always // Build the exact same command as the start function const dockerArgs = [ 'run', '--rm', '--name', 'synchronizer-cli', '--pull', 'always', // Always try to pull the latest image '--platform', dockerPlatform, 'cdrakep/synqchronizer:latest', '--depin', config.depin || 'wss://api.multisynq.io/depin', '--sync-name', config.syncHash, '--launcher', launcherWithVersion, '--key', config.key, ...(config.wallet ? ['--wallet', config.wallet] : []), ...(config.account ? ['--account', config.account] : []) ].join(' '); const unit = `[Unit] Description=Multisynq Synchronizer headless service After=docker.service Requires=docker.service [Service] Type=simple User=${user} Restart=always RestartSec=10 ExecStart=${dockerPath} ${dockerArgs} Environment=PATH=${pathEnv} [Install] WantedBy=multi-user.target `; fs.writeFileSync(serviceFile, unit); console.log(chalk.green('Systemd service file written to'), serviceFile); console.log(chalk.blue(`To install the service, run: sudo cp ${serviceFile} /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable synchronizer-cli sudo systemctl start synchronizer-cli`)); console.log(chalk.cyan('\n📋 Service will run with the following configuration:')); console.log(chalk.gray(`Platform: ${dockerPlatform}`)); console.log(chalk.gray(`Docker Path: ${dockerPath}`)); console.log(chalk.gray(`PATH: ${pathEnv}`)); console.log(chalk.gray(`DePIN: ${config.depin || 'wss://api.multisynq.io/depin'}`)); console.log(chalk.gray(`Sync Name: ${config.syncHash}`)); console.log(chalk.gray(`Wallet: ${config.wallet || '[none]'}`)); console.log(chalk.gray(`Account: ${config.account || '[none]'}`)); } async function fixDockerPermissions() { console.log(chalk.blue('🔧 Docker Permissions Fix')); console.log(chalk.yellow('This will add your user to the docker group.\n')); const username = os.userInfo().username; try { console.log(chalk.cyan(`Adding user "${username}" to docker group...`)); execSync(`sudo usermod -aG docker ${username}`, { stdio: 'inherit' }); console.log(chalk.green('✅ User added to docker group successfully!')); console.log(chalk.yellow('⚠️ You need to log out and log back in for changes to take effect.')); console.log(chalk.blue('\n🧪 To test after logging back in:')); console.log(chalk.gray(' docker run hello-world')); console.log(chalk.gray(' synchronize start')); } catch (error) { console.error(chalk.red('❌ Failed to add user to docker group.')); console.error(chalk.red('Error:', error.message)); console.error(chalk.blue('\n📖 Manual steps:')); console.error(chalk.gray(` sudo usermod -aG docker ${username}`)); console.error(chalk.gray(' # Then log out and log back in')); } } async function testPlatform() { console.log(chalk.blue('🔍 Platform Compatibility Test')); console.log(chalk.yellow('Testing Docker platform compatibility...\n')); const arch = os.arch(); const platform = os.platform(); console.log(chalk.cyan(`Host System: ${platform}/${arch}`)); // Test Docker availability if (!checkDocker()) { console.error(chalk.red('❌ Docker is not available')); return; } console.log(chalk.green('✅ Docker is available')); // Test both platforms and fallback const tests = [ { name: 'linux/amd64', args: ['--platform', 'linux/amd64'] }, { name: 'linux/arm64', args: ['--platform', 'linux/arm64'] }, { name: 'no platform flag', args: [] } ]; let workingPlatforms = []; for (const test of tests) { console.log(chalk.blue(`\nTesting ${test.name}...`)); try { const args = [ 'run', '--rm', ...test.args, 'cdrakep/synqchronizer:latest', '--help' ]; const result = execSync(`docker ${args.join(' ')}`, { encoding: 'utf8', timeout: 30000, stdio: 'pipe' }); if (result.includes('Usage:') || result.includes('--help')) { console.log(chalk.green(`✅ ${test.name} works`)); workingPlatforms.push(test.name); } else { console.log(chalk.yellow(`⚠️ ${test.name} responded but output unexpected`)); } } catch (error) { const errorMsg = error.message.split('\n')[0]; console.log(chalk.red(`❌ ${test.name} failed: ${errorMsg}`)); } } // Recommend best platform let recommendedPlatform = 'linux/amd64'; if (arch === 'arm64' || arch === 'aarch64') { recommendedPlatform = 'linux/arm64'; } console.log(chalk.blue(`\n💡 Recommended platform for your system: ${recommendedPlatform}`)); if (workingPlatforms.length === 0) { console.log(chalk.red('\n❌ No platforms are working!')); console.log(chalk.yellow('This suggests the Docker image may not support your architecture.')); console.log(chalk.blue('\n🔧 Troubleshooting steps:')); console.log(chalk.gray('1. Check what platforms the image supports:')); console.log(chalk.gray(' docker manifest inspect cdrakep/synqchronizer:latest')); console.log(chalk.gray('2. Try pulling the image manually:')); console.log(chalk.gray(' docker pull cdrakep/synqchronizer:latest')); console.log(chalk.gray('3. Check if there are architecture-specific tags:')); console.log(chalk.gray(' docker search cdrakep/synqchronizer')); console.log(chalk.gray('4. Contact the image maintainer for multi-arch support')); } else { console.log(chalk.green(`\n✅ Working platforms: ${workingPlatforms.join(', ')}`)); console.log(chalk.gray('synchronize start will try these platforms automatically')); } } async function showStatus() { console.log(chalk.blue('🔍 synchronizer Service Status')); console.log(chalk.yellow('Checking systemd service status...\n')); try { // Check if service file exists const serviceExists = fs.existsSync('/etc/systemd/system/synchronizer-cli.service'); if (!serviceExists) { console.log(chalk.yellow('⚠️ Systemd service not installed')); console.log(chalk.gray('Run `synchronize service` to generate the service file')); return; } console.log(chalk.green('✅ Service file exists: /etc/systemd/system/synchronizer-cli.service')); // Get service status try { const statusOutput = execSync('systemctl status synchronizer-cli --no-pager', { encoding: 'utf8', stdio: 'pipe' }); // Parse status for key information const lines = statusOutput.split('\n'); const statusLine = lines.find(line => line.includes('Active:')); const loadedLine = lines.find(line => line.includes('Loaded:')); if (statusLine) { if (statusLine.includes('active (running)')) { console.log(chalk.green('🟢 Status: Running')); } else if (statusLine.includes('inactive (dead)')) { console.log(chalk.red('🔴 Status: Stopped')); } else if (statusLine.includes('failed')) { console.log(chalk.red('❌ Status: Failed')); } else { console.log(chalk.yellow('🟡 Status: Unknown')); } } if (loadedLine && loadedLine.includes('enabled')) { console.log(chalk.green('✅ Auto-start: Enabled')); } else { console.log(chalk.yellow('⚠️ Auto-start: Disabled')); } } catch (error) { console.log(chalk.red('❌ Service status: Not found or error')); console.log(chalk.gray('The service may not be installed or you may need sudo access')); } // Show recent logs console.log(chalk.blue('\n📋 Recent Logs (last 10 lines):')); console.log(chalk.gray('─'.repeat(60))); try { const logsOutput = execSync('journalctl -u synchronizer-cli --no-pager -n 10', { encoding: 'utf8', stdio: 'pipe' }); if (logsOutput.trim()) { // Color-code log levels const coloredLogs = logsOutput .split('\n') .map(line => { if (line.includes('"level":"error"') || line.includes('ERROR')) { return chalk.red(line); } else if (line.includes('"level":"warn"') || line.includes('WARNING')) { return chalk.yellow(line); } else if (line.includes('"level":"info"') || line.includes('INFO')) { return chalk.cyan(line); } else if (line.includes('"level":"debug"') || line.includes('DEBUG')) { return chalk.gray(line); } else if (line.includes('proxy-connected') || line.includes('registered')) { return chalk.green(line); } else { return line; } }) .join('\n'); console.log(coloredLogs); } else { console.log(chalk.gray('No recent logs found')); } } catch (error) { console.log(chalk.red('❌ Could not retrieve logs')); console.log(chalk.gray('You may need sudo access to view systemd logs')); } // Show helpful commands console.log(chalk.blue('\n🛠️ Useful Commands:')); console.log(chalk.gray(' Start service: sudo systemctl start synchronizer-cli')); console.log(chalk.gray(' Stop service: sudo systemctl stop synchronizer-cli')); console.log(chalk.gray(' Restart service: sudo systemctl restart synchronizer-cli')); console.log(chalk.gray(' Enable auto-start: sudo systemctl enable synchronizer-cli')); console.log(chalk.gray(' View live logs: journalctl -u synchronizer-cli -f')); console.log(chalk.gray(' View all logs: journalctl -u synchronizer-cli')); // Check if running as manual process try { const dockerPs = execSync('docker ps --filter name=synchronizer-cli --format "table {{.Names}}\\t{{.Status}}"', { encoding: 'utf8', stdio: 'pipe' }); if (dockerPs.includes('synchronizer-cli')) { console.log(chalk.yellow('\n⚠️ Manual synchronizer process also detected!')); console.log(chalk.gray('You may have both service and manual process running')); console.log(chalk.gray('Consider stopping one to avoid conflicts')); } } catch (error) { // Docker not available or no containers running } } catch (error) { console.error(chalk.red('❌ Error checking service status:'), error.message); } } /** * Get the primary local IP address, filtering out virtual adapters and loopback interfaces * Works across Windows, Mac, and Linux * @returns {string} The primary local IP address or 'localhost' as fallback */ function getPrimaryLocalIP() { const interfaces = os.networkInterfaces(); // Priority order for interface types (prefer physical over virtual) const interfacePriority = { // Physical interfaces (highest priority) 'eth': 100, // Ethernet (Linux) 'en': 90, // Ethernet (macOS) 'Ethernet': 80, // Ethernet (Windows) 'Wi-Fi': 70, // WiFi (Windows) 'wlan': 60, // WiFi (Linux) 'wlp': 55, // WiFi (Linux - newer naming) // Virtual interfaces (lower priority) 'docker': 10, // Docker interfaces 'veth': 10, // Virtual Ethernet 'br-': 10, // Bridge interfaces 'virbr': 10, // Virtual bridge 'vmnet': 10, // VMware 'vbox': 10, // VirtualBox 'tun': 10, // Tunnel interfaces 'tap': 10, // TAP interfaces 'utun': 10, // User tunnel (macOS) 'awdl': 10, // Apple Wireless Direct Link 'llw': 10, // Low Latency WLAN (macOS) 'bridge': 10, // Bridge interfaces 'vnic': 10, // Virtual NIC 'Hyper-V': 10, // Hyper-V (Windows) 'VirtualBox': 10, // VirtualBox (Windows) 'VMware': 10, // VMware (Windows) 'Loopback': 5, // Loopback (Windows) 'lo': 5 // Loopback (Linux/macOS) }; const candidates = []; for (const [interfaceName, addresses] of Object.entries(interfaces)) { // Skip loopback interfaces if (interfaceName === 'lo' || interfaceName.includes('Loopback')) { continue; } for (const addr of addresses) { // Only consider IPv4 addresses that are not internal (loopback) if (addr.family === 'IPv4' && !addr.internal) { // Calculate priority based on interface name let priority = 1; // Default low priority for (const [pattern, score] of Object.entries(interfacePriority)) { if (interfaceName.toLowerCase().startsWith(pattern.toLowerCase()) || interfaceName.toLowerCase().includes(pattern.toLowerCase())) { priority = score; break; } } // Boost priority for common private network ranges const ip = addr.address; if (ip.startsWith('192.168.') || ip.startsWith('10.') || (ip.startsWith('172.') && parseInt(ip.split('.')[1]) >= 16 && parseInt(ip.split('.')[1]) <= 31)) { priority += 20; // Prefer private network IPs } // Penalise virtual/container networks if (interfaceName.toLowerCase().includes('docker') || interfaceName.toLowerCase().includes('veth') || interfaceName.toLowerCase().includes('br-') || interfaceName.toLowerCase().includes('virbr') || ip.startsWith('172.17.') || // Default Docker network ip.startsWith('172.18.') || // Docker networks ip.startsWith('172.19.') || ip.startsWith('172.20.') || ip.startsWith('169.254.')) { // Link-local priority -= 50; } candidates.push({ ip: ip, interface: interfaceName, priority: priority, mac: addr.mac }); } } } // Sort by priority (highest first) and return the best candidate candidates.sort((a, b) => b.priority - a.priority); if (candidates.length > 0) { const best = candidates[0]; console.log(chalk.gray(`🌐 Detected primary IP: ${best.ip} (${best.interface})`)); // Log other candidates for debugging if needed if (candidates.length > 1) { console.log(chalk.gray(` Other interfaces: ${candidates.slice(1, 3).map(c => `${c.ip} (${c.interface})`).join(', ')}`)); } return best.ip; } console.log(chalk.yellow('⚠️ Could not detect primary IP, using localhost')); return 'localhost'; } async function startWebGUI(options = {}) { console.log(chalk.blue('🌐 Starting synchronizer Web GUI')); console.log(chalk.yellow('Setting up web dashboard and metrics endpoints...\n')); const config = loadConfig(); if (config.dashboardPassword) { console.log(chalk.green('🔒 Dashboard password protection enabled')); } else { console.log(chalk.yellow('⚠️ Dashboard is unprotected - consider setting a password')); } // Get the primary local IP address const primaryIP = getPrimaryLocalIP(); // Use custom ports if provided, otherwise find available ports let guiPort, metricsPort; if (options.port && options.metricsPort) { // Both ports provided - validate they don't conflict guiPort = options.port; metricsPort = options.metricsPort; console.log(chalk.cyan(`📌 Using custom dashboard port: ${guiPort}`)); console.log(chalk.cyan(`📌 Using custom metrics port: ${metricsPort}`)); if (guiPort === metricsPort) { console.error(chalk.red('❌ Error: Dashboard and metrics ports cannot be the same')); console.log(chalk.gray(' Use different values for --port and --metrics-port')); process.exit(1); } } else if (options.port) { // Only dashboard port provided guiPort = options.port; console.log(chalk.cyan(`📌 Using custom dashboard port: ${guiPort}`)); console.log(chalk.gray('🔍 Finding available port for metrics...')); metricsPort = await findAvailablePort(guiPort + 1); console.log(chalk.green(`✅ Found metrics port: ${metricsPort}`)); } else if (options.metricsPort) { // Only metrics port provided metricsPort = options.metricsPort; console.log(chalk.cyan(`📌 Using custom metrics port: ${metricsPort}`)); console.log(chalk.gray('🔍 Finding available port for dashboard...')); guiPort = await findAvailablePort(3000); if (guiPort === metricsPort) { // If we found the same port, find a different one guiPort = await findAvailablePort(metricsPort === 3000 ? 3001 : 3000); } if (guiPort !== 3000) { console.log(chalk.yellow(`⚠️ Port 3000 was busy, using port ${guiPort} for dashboard`)); } } else { // No custom ports - find both automatically console.log(chalk.gray('🔍 Finding available ports for dashboard and metrics...')); // Find dashboard port first guiPort = await findAvailablePort(3000); if (guiPort !== 3000) { console.log(chalk.yellow(`⚠️ Port 3000 was busy, using port ${guiPort} for dashboard`)); } // Find metrics port, starting from guiPort + 1 metricsPort = await findAvailablePort(guiPort + 1); const expectedMetricsPort = guiPort === 3000 ? 3001 : guiPort + 1; if (metricsPort !== expectedMetricsPort) { console.log(chalk.yellow(`⚠️ Port ${expectedMetricsPort} was busy, using port ${metricsPort} for metrics`)); } } // Final validation if (guiPort === metricsPort) { console.error(chalk.red('❌ Error: Dashboard and metrics ports cannot be the same')); console.log(chalk.gray(' This should not happen - please report this as a bug')); process.exit(1); } console.log(chalk.blue(`🎯 Dashboard will use port ${guiPort}, metrics will use port ${metricsPort}`)); // Initialize the global WebSocket connection ONCE when web server starts console.log(chalk.blue('🔌 Starting global WebSocket initialization...')); await initializeGlobalWebSocket(); // Create Express apps const guiApp = express(); const metricsApp = express(); // Add authentication middleware to GUI app guiApp.use(authenticateRequest); // GUI Dashboard guiApp.get('/', async (req, res) => { const versionInfo = await getVersionInfo(); const html = generateDashboardHTML(config, metricsPort, req.authenticated, primaryIP, versionInfo); res.send(html); }); guiApp.get('/api/status', async (req, res) => { const status = await getSystemStatus(config); res.json(status); }); guiApp.get('/api/versions', async (req, res) => { const versions = await getVersionInfo(); res.json({ timestamp: new Date().toISOString(), versions }); }); guiApp.get('/api/logs', async (req, res) => { const logs = await getRecentLogs(); res.json({ logs }); }); guiApp.get('/api/performance', async (req, res) => { const performance = await getPerformanceData(config); res.json(performance); }); guiApp.get('/api/points', async (req, res) => { const points = await getPointsData(config); res.json(points); }); guiApp.post('/api/install-web-service', async (req, res) => { try { const result = await installWebServiceFile(); res.json(result); } catch (error) { res.json({ success: false, error: error.message }); } }); guiApp.get('/api/check-updates', async (req, res) => { try { const images = [ 'cdrakep/synqchronizer:latest', 'cdrakep/synqchronizer-test-fixed:latest' ]; const updateStatus = []; let totalUpdates = 0; for (const imageName of images) { try { const hasUpdate = await isNewDockerImageAvailable(imageName); updateStatus.push({ name: imageName, updateAvailable: hasUpdate, checked: true }); if (hasUpdate) totalUpdates++; } catch (error) { updateStatus.push({ name: imageName, updateAvailable: false, checked: false, error: error.message }); } } res.json({ success: true, totalUpdates, images: updateStatus, timestamp: new Date().toISOString() }); } catch (error) { res.json({ success: false, error: error.message }); } }); guiApp.post('/api/pull-image', async (req, res) => { try { const { imageName } = req.body; if (!imageName) { return res.json({ success: false, error: 'Image name is required' }); } // Security check - only allow known synchronizer images const allowedImages = [ 'cdrakep/synqchronizer:latest', 'cdrakep/synqchronizer-test-fixed:latest' ]; if (!allowedImages.includes(imageName)) {