#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Scripts.Deploy * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /deploy/deploy-dolibarr.php * VERSION: 01.00.00 * BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync */ declare(strict_types=1); class DeployDolibarr { private bool $verbose = false; private bool $dryRun = false; private string $configPath = ''; private string $source = ''; private const MODULE_DIRS = [ 'core/modules', 'class', 'lib', 'sql', 'langs', 'css', 'js', 'img', ]; private const EXCLUDES = [ '.git/', 'vendor/', 'tests/', 'node_modules/', ]; public function run(): int { $this->parseArgs(); if ($this->configPath === '' || $this->source === '') { $this->log('Usage: deploy-dolibarr.php --source --config [--dry-run] [--verbose]'); return 1; } if (!is_dir($this->source)) { $this->log("ERROR: Source directory does not exist: {$this->source}"); return 1; } $moduleName = $this->detectModuleName(); if ($moduleName === null) { $this->log('ERROR: Could not auto-detect module name. Expected core/modules/mod*.class.php'); return 1; } $config = $this->loadConfig($this->configPath); if ($config === null) { return 1; } $host = $config['host'] ?? ''; $user = $config['user'] ?? ''; $port = (int) ($config['port'] ?? 22); $remotePath = rtrim($config['remote_path'] ?? '', '/'); $sshKey = $config['ssh_key_file'] ?? ''; if ($host === '' || $user === '' || $remotePath === '') { $this->log('ERROR: Config must contain host, user, and remote_path.'); return 1; } $remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}"; $this->log("Deploying Dolibarr module: {$moduleName}"); $this->log("Source: {$this->source}"); $this->log("Target: {$user}@{$host}:{$remoteBase}"); if ($this->dryRun) { $this->log('*** DRY RUN — no changes will be made ***'); } $failed = 0; // Deploy subdirectories foreach (self::MODULE_DIRS as $dir) { $localDir = rtrim($this->source, '/\\') . '/' . $dir . '/'; if (!is_dir($localDir)) { if ($this->verbose) { $this->log("SKIP: {$dir} (not present in source)"); } continue; } $remoteTarget = "{$remoteBase}/{$dir}/"; $result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey); if (!$result) { $failed++; } } // Deploy root PHP files $rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php'); if (!empty($rootPhpFiles)) { $this->log('Syncing root PHP files...'); $sourceRoot = rtrim($this->source, '/\\') . '/'; $remoteTarget = "{$remoteBase}/"; $sshCmd = "ssh -p {$port}"; if ($sshKey !== '') { $sshCmd .= " -i " . escapeshellarg($sshKey); } $cmd = $this->buildRsyncCommand( $sshCmd, $sourceRoot, "{$user}@{$host}:{$remoteTarget}", ['--include=*.php', '--exclude=*/', '--exclude=.*'] ); if ($this->verbose) { $this->log("CMD: {$cmd}"); } $output = []; $exitCode = 0; exec($cmd, $output, $exitCode); if ($exitCode !== 0) { $this->log("ERROR: rsync failed for root PHP files (exit code {$exitCode})"); foreach ($output as $line) { $this->log(" {$line}"); } $failed++; } else { if ($this->verbose) { foreach ($output as $line) { $this->log(" {$line}"); } } } } if ($failed > 0) { $this->log("Deployment completed with {$failed} error(s)."); return 1; } $this->log('Deployment completed successfully.'); return 0; } private function parseArgs(): void { $args = $_SERVER['argv'] ?? []; $count = count($args); for ($i = 1; $i < $count; $i++) { switch ($args[$i]) { case '--source': $this->source = $args[++$i] ?? ''; break; case '--config': $this->configPath = $args[++$i] ?? ''; break; case '--dry-run': $this->dryRun = true; break; case '--verbose': $this->verbose = true; break; } } } private function detectModuleName(): ?string { $pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php'; $matches = glob($pattern); if (empty($matches)) { return null; } $filename = basename($matches[0]); // mod{ModuleName}.class.php → extract ModuleName, lowercase it if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) { return strtolower($m[1]); } return null; } private function loadConfig(string $path): ?array { if (!is_file($path)) { $this->log("ERROR: Config file not found: {$path}"); return null; } $raw = file_get_contents($path); if ($raw === false) { $this->log("ERROR: Could not read config file: {$path}"); return null; } // Strip // comments (sftp-config.json style) $cleaned = preg_replace('#^\s*//.*$#m', '', $raw); $config = json_decode($cleaned, true); if (!is_array($config)) { $this->log('ERROR: Invalid JSON in config file.'); return null; } return $config; } private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool { $dirName = basename(rtrim($localDir, '/')); $sshCmd = "ssh -p {$port}"; if ($sshKey !== '') { $sshCmd .= " -i " . escapeshellarg($sshKey); } $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); $this->log("Syncing: {$dirName}"); if ($this->verbose) { $this->log("CMD: {$cmd}"); } $output = []; $exitCode = 0; exec($cmd, $output, $exitCode); if ($exitCode !== 0) { $this->log("ERROR: rsync failed for {$dirName} (exit code {$exitCode})"); foreach ($output as $line) { $this->log(" {$line}"); } return false; } if ($this->verbose) { foreach ($output as $line) { $this->log(" {$line}"); } } return true; } private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string { $parts = ['rsync', '-rlptz', '--delete']; foreach (self::EXCLUDES as $exclude) { $parts[] = '--exclude=' . $exclude; } foreach ($extraArgs as $arg) { $parts[] = $arg; } if ($this->dryRun) { $parts[] = '--dry-run'; } if ($this->verbose) { $parts[] = '-v'; } $parts[] = '-e'; $parts[] = escapeshellarg($sshCmd); $parts[] = escapeshellarg($source); $parts[] = escapeshellarg($dest); return implode(' ', $parts); } private function log(string $message): void { $timestamp = date('Y-m-d H:i:s'); fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); } } $app = new DeployDolibarr(); exit($app->run());