From 66939d9cc5bd744cfc51968505c358eaef37e061 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 19 May 2026 20:47:17 +0000 Subject: [PATCH] feat: add deploy-dolibarr.php Authored-by: Moko Consulting --- deploy/deploy-dolibarr.php | 301 +++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 deploy/deploy-dolibarr.php diff --git a/deploy/deploy-dolibarr.php b/deploy/deploy-dolibarr.php new file mode 100644 index 0000000..078b468 --- /dev/null +++ b/deploy/deploy-dolibarr.php @@ -0,0 +1,301 @@ +#!/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());