From d1e5a59207b1ccfce90a4dd55b79dd5e5aec16fd Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Fri, 29 Mar 2024 16:50:09 -0400 Subject: [PATCH] Refactor --- composer.json | 3 +- config/sync.php | 6 +- src/CommandGenerator.php | 101 ---------------------- src/Commands/BaseCommand.php | 134 ++++++++++++++++++++++++----- src/Commands/Sync.php | 37 ++++---- src/Commands/SyncCommands.php | 7 +- src/Commands/SyncList.php | 10 ++- src/Config.php | 49 ----------- src/Exceptions/ConfigException.php | 23 ----- src/Exceptions/SyncException.php | 18 ---- src/PathGenerator.php | 16 ++-- src/SyncCommand.php | 52 +++++++++++ src/SyncProcessor.php | 47 ---------- 13 files changed, 204 insertions(+), 299 deletions(-) delete mode 100644 src/CommandGenerator.php delete mode 100644 src/Config.php delete mode 100644 src/Exceptions/ConfigException.php delete mode 100644 src/Exceptions/SyncException.php create mode 100644 src/SyncCommand.php delete mode 100644 src/SyncProcessor.php diff --git a/composer.json b/composer.json index 1deb37b..e3d7b43 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ ], "require": { "php": "^8.2", - "illuminate/support": "^11.0" + "illuminate/support": "^11.0", + "laravel/prompts": "^0.1.17" }, "require-dev": { "nunomaduro/collision": "^8.1", diff --git a/config/sync.php b/config/sync.php index 87b2a34..ad80407 100644 --- a/config/sync.php +++ b/config/sync.php @@ -51,11 +51,7 @@ */ 'options' => [ - // '--archive', - // '--itemize-changes', - // '--verbose', - // '--human-readable', - // '--progress' + '--archive', ], ]; diff --git a/src/CommandGenerator.php b/src/CommandGenerator.php deleted file mode 100644 index eff1098..0000000 --- a/src/CommandGenerator.php +++ /dev/null @@ -1,101 +0,0 @@ -operation = $operation; - - return $this; - } - - public function remote(array $remote): self - { - $this->remote = $remote; - - return $this; - } - - public function recipe(array $recipe): self - { - $this->recipe = $recipe; - - return $this; - } - - public function options(array $options): self - { - $this->options = implode(' ', $options); - - return $this; - } - - public function run(): Collection - { - if ($this->localPathEqualsRemotePath()) { - throw SyncException::samePath(); - } - - if ($this->remoteIsReadOnly() && $this->operation === 'push') { - throw SyncException::readOnly(); - } - - return $this->commandsString(); - } - - public function commandsArray(): Collection - { - return collect($this->recipe)->map(fn ($path, $key) => [ - 'origin' => ($this->operation === 'pull') ? $this->remotePath($key) : $this->localPath($key), - 'target' => ($this->operation === 'pull') ? $this->localPath($key) : $this->remotePath($key), - 'options' => $this->options, - 'port' => $this->port(), - ]); - } - - public function commandsString(): Collection - { - return $this->commandsArray() - ->map(fn ($command) => "rsync -e 'ssh -p {$command['port']}' {$command['options']} {$command['origin']} {$command['target']}"); - } - - protected function port(): string - { - return $this->remote['port'] ?? '22'; - } - - protected function localPath(string $key): string - { - return PathGenerator::localPath($this->recipe[$key]); - } - - protected function remotePath(string $key): string - { - return PathGenerator::remotePath($this->remote, $this->recipe[$key]); - } - - protected function localPathEqualsRemotePath(): bool - { - return $this->localPath(0) === $this->remotePath(0); - } - - protected function remoteIsReadOnly(): bool - { - return Arr::get($this->remote, 'read_only', false); - } -} diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php index 08fefc6..18fd65f 100644 --- a/src/Commands/BaseCommand.php +++ b/src/Commands/BaseCommand.php @@ -2,63 +2,151 @@ namespace Aerni\Sync\Commands; -use Facades\Aerni\Sync\CommandGenerator; -use Facades\Aerni\Sync\Config; +use Aerni\Sync\SyncCommand; +use Illuminate\Support\Arr; +use Aerni\Sync\PathGenerator; use Illuminate\Console\Command; +use Illuminate\Validation\Rule; +use Illuminate\Support\Collection; +use function Laravel\Prompts\select; +use Illuminate\Support\Facades\Validator; +use Symfony\Component\Console\Output\OutputInterface; +use Illuminate\Contracts\Console\PromptsForMissingInput; -class BaseCommand extends Command +class BaseCommand extends Command implements PromptsForMissingInput { public function __construct() { - $baseSignature = " - {operation : Choose if you want to 'push' or 'pull'} + $baseSignature = ' + {operation : Choose if you want to push or pull} {remote : The remote you want to sync with} {recipe : The recipe defining the paths to sync} {--O|option=* : An rsync option to use} {--D|dry : Perform a dry run of the sync} - "; + '; $this->signature .= $baseSignature; parent::__construct(); } - protected function commandGenerator(): \Aerni\Sync\CommandGenerator + protected function promptForMissingArgumentsUsing(): array { - return CommandGenerator::operation($this->operation()) - ->remote($this->remote()) - ->recipe($this->recipe()) - ->options($this->rsyncOptions()); + return [ + 'operation' => fn () => select( + label: 'Choose if you want to push or pull', + options: ['push', 'pull'], + ), + 'remote' => fn () => select( + label: 'Choose the remote you want to sync with', + options: array_keys($this->remotes()), + ), + 'recipe' => fn () => select( + label: 'Choose the recipe defining the paths to sync', + options: array_keys($this->recipes()), + ), + ]; + } + + protected function validate(): void + { + Validator::validate($this->arguments(), [ + 'operation' => 'required|in:push,pull', + 'remote' => ['required', Rule::in(array_keys($this->remotes()))], + 'recipe' => ['required', Rule::in(array_keys($this->recipes()))], + ], [ + 'operation.in' => "The :attribute [:input] does not exists. Valid values are [push] or [pull].", + 'remote.in' => "The :attribute [:input] does not exists. Please choose a valid remote.", + 'recipe.in' => "The :attribute [:input] does not exists. Please choose a valid recipe.", + ]); + + if ($this->localPathEqualsRemotePath()) { + throw new \RuntimeException("The origin and target path are one and the same. You can't sync a path with itself."); + } + + if ($this->remoteIsReadOnly() && $this->operation() === 'push') { + throw new \RuntimeException("You can't push to the selected target as it is configured to be read-only."); + } + } + + protected function localPathEqualsRemotePath(): bool + { + return PathGenerator::localPath($this->recipe()[0]) + === PathGenerator::remotePath($this->remote(), $this->recipe()[0]); + } + + protected function remoteIsReadOnly(): bool + { + return Arr::get($this->remote(), 'read_only', false); + } + + protected function commands(): Collection + { + return collect($this->recipe()) + ->map(fn ($path) => new SyncCommand( + path: $path, + operation: $this->operation(), + remote: $this->remote(), + options: $this->rsyncOptions(), + )); } protected function operation(): string { - return Config::operation($this->argument('operation')); + return $this->argument('operation'); } protected function remote(): array { - return Config::remote($this->argument('remote')); + return Arr::get($this->remotes(), $this->argument('remote')); } protected function recipe(): array { - return Config::recipe($this->argument('recipe')); + return Arr::get($this->recipes(), $this->argument('recipe')); } - protected function rsyncOptions(): array + protected function remotes(): array { - $options = Config::options($this->option('option')); + $remotes = config('sync.remotes'); - return collect($options) - ->push($this->dry()) - ->filter() - ->unique() - ->toArray(); + if (empty($remotes)) { + throw new \RuntimeException('You need to define at least one remote in your config file.'); + } + + return $remotes; + } + + protected function recipes(): array + { + $recipes = config('sync.recipes'); + + if (empty($recipes)) { + throw new \RuntimeException('You need to define at least one recipe in your config file.'); + } + + return $recipes; } - protected function dry(): string + protected function rsyncOptions(): string { - return $this->option('dry') ? '--dry-run' : ''; + $options = $this->option('option'); + + if (empty($options)) { + $options = config('sync.options'); + } + + return collect($options) + ->when( + $this->option('dry'), + fn ($collection) => $collection->merge(['--dry-run', '--human-readable', '--progress', '--stats', '--verbose']) + ) + ->when( + $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE, + fn ($collection) => $collection->merge(['--human-readable', '--progress', '--stats', '--verbose']) + ) + ->filter() + ->unique() + ->implode(' '); } } diff --git a/src/Commands/Sync.php b/src/Commands/Sync.php index 2201780..38861e6 100644 --- a/src/Commands/Sync.php +++ b/src/Commands/Sync.php @@ -2,8 +2,9 @@ namespace Aerni\Sync\Commands; -use Facades\Aerni\Sync\SyncProcessor; -use Illuminate\Support\Arr; +use function Laravel\Prompts\confirm; +use Illuminate\Support\Facades\Process; +use Symfony\Component\Console\Output\OutputInterface; class Sync extends BaseCommand { @@ -26,25 +27,29 @@ class Sync extends BaseCommand */ public function handle(): void { - if ($this->operation() === 'push' && Arr::get($this->remote(), 'read_only') === true) { - $this->error("You can't push to the selected target as it is configured to be read only."); + $this->validate(); + /* Only show the confirmation if we're not performing a dry run */ + if (! $this->option('dry') && ! confirm($this->confirmText())) { return; } - if (! $this->confirm($this->confirmText(), true)) { - return; - } + $this->option('dry') + ? $this->info('Starting a dry run ...') + : $this->info('Syncing files ...'); - $commands = $this->commandGenerator()->run(); + $this->commands()->each(function ($command) { + Process::forever()->run($command, function (string $type, string $output) { + /* Only show the output if we're performing a dry run or the verbosity is set to verbose */ + if ($this->option('dry') || $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) { + echo $output; + } + }); + }); - $sync = SyncProcessor::commands($commands) - ->artisanCommand($this) - ->run(); - - if ($sync->successful()) { - $this->info('The sync was successful'); - } + $this->option('dry') + ? $this->info("The dry run of the {$this->argument('recipe')} recipe was successfull.") + : $this->info("The sync of the {$this->argument('recipe')} recipe was successfull."); } protected function confirmText(): string @@ -54,6 +59,6 @@ protected function confirmText(): string $remote = $this->argument('remote'); $preposition = $operation === 'pull' ? 'from' : 'to'; - return "Please confirm that you want to $operation the $recipe $preposition $remote"; + return "You are about to $operation the $recipe $preposition $remote. Are you sure?"; } } diff --git a/src/Commands/SyncCommands.php b/src/Commands/SyncCommands.php index 51e9f50..24a9832 100644 --- a/src/Commands/SyncCommands.php +++ b/src/Commands/SyncCommands.php @@ -23,9 +23,8 @@ class SyncCommands extends BaseCommand */ public function handle(): void { - $this->commandGenerator()->commandsString()->each(function ($command) { - $this->info($command); - $this->newLine(); - }); + $this->validate(); + + $this->commands()->each(fn ($command) => $this->info($command)); } } diff --git a/src/Commands/SyncList.php b/src/Commands/SyncList.php index 6c25af9..7b4db69 100644 --- a/src/Commands/SyncList.php +++ b/src/Commands/SyncList.php @@ -2,6 +2,8 @@ namespace Aerni\Sync\Commands; +use function Laravel\Prompts\table; + class SyncList extends BaseCommand { /** @@ -23,9 +25,11 @@ class SyncList extends BaseCommand */ public function handle(): void { - $headers = ['Origin', 'Target', 'Options', 'Port']; - $commands = $this->commandGenerator()->commandsArray(); + $this->validate(); - $this->table($headers, $commands); + table( + ['Origin', 'Target', 'Options', 'Port'], + $this->commands()->map->toArray() + ); } } diff --git a/src/Config.php b/src/Config.php deleted file mode 100644 index 33e2612..0000000 --- a/src/Config.php +++ /dev/null @@ -1,49 +0,0 @@ -joinPaths($remote['root'], $path); + $fullPath = self::joinPaths($remote['root'], $path); - if ($this->remoteHostEqualsLocalHost($remote['host'])) { + if (self::remoteHostEqualsLocalHost($remote['host'])) { return $fullPath; } return "{$remote['user']}@{$remote['host']}:$fullPath"; } - protected function joinPaths(): string + protected static function joinPaths(): string { $paths = []; @@ -35,10 +35,8 @@ protected function joinPaths(): string return preg_replace('#/+#', '/', implode('/', $paths)); } - protected function remoteHostEqualsLocalHost(string $remoteHost): bool + protected static function remoteHostEqualsLocalHost(string $host): bool { - $publicIp = Http::get('https://api.ipify.org/?format=json')->json('ip'); - - return $publicIp === $remoteHost; + return once(fn () => $host === Http::get('https://api.ipify.org/?format=json')->json('ip')); } } diff --git a/src/SyncCommand.php b/src/SyncCommand.php new file mode 100644 index 0000000..4210fe7 --- /dev/null +++ b/src/SyncCommand.php @@ -0,0 +1,52 @@ + $this->origin(), + 'target' => $this->target(), + 'options' => $this->options, + 'port' => $this->port(), + ]; + } + + public function __toString(): string + { + return "rsync -e 'ssh -p {$this->port()}' {$this->options} {$this->origin()} {$this->target()}"; + } + + protected function origin(): string + { + return $this->operation === 'pull' + ? PathGenerator::remotePath($this->remote, $this->path) + : PathGenerator::localPath($this->path); + } + + protected function target(): string + { + return $this->operation === 'pull' + ? PathGenerator::localPath($this->path) + : PathGenerator::remotePath($this->remote, $this->path); + } + + protected function port(): string + { + return $this->remote['port'] ?? '22'; + } +} diff --git a/src/SyncProcessor.php b/src/SyncProcessor.php deleted file mode 100644 index da7af9b..0000000 --- a/src/SyncProcessor.php +++ /dev/null @@ -1,47 +0,0 @@ -commands = $commands; - - return $this; - } - - public function artisanCommand(Command $artisanCommand): self - { - $this->artisanCommand = $artisanCommand; - - return $this; - } - - public function run(): self - { - $this->commands->each(function ($command) { - $response = Terminal::timeout(null) - ->output($this->artisanCommand) - ->run($command) - ->throw(); - - $this->successful = $response->successful(); - }); - - return $this; - } - - public function successful(): bool - { - return $this->successful; - } -}