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
JavaScript
#!/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)) {