From 0480536c7d48ad6bc0e40a444e9bdbaeb506caa4 Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Sat, 30 Mar 2024 17:38:42 -0400 Subject: [PATCH] Refactor (#8) --- .github/workflows/run-tests.yaml | 19 ++-- .gitignore | 1 + README.md | 29 ++++--- composer.json | 10 +-- config/sync.php | 6 +- src/CommandGenerator.php | 101 --------------------- src/Commands/BaseCommand.php | 135 ++++++++++++++++++++++++----- src/Commands/Sync.php | 38 ++++---- 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 | 51 +++++++++++ src/SyncProcessor.php | 47 ---------- 16 files changed, 234 insertions(+), 326 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/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 9d08c3c..cecff64 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -1,25 +1,26 @@ name: Run Tests -on: [push, pull_request, workflow_dispatch] +on: ['push', 'pull_request'] jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: - fail-fast: false + fail-fast: true matrix: - php: [8.2, 8.1] - laravel: [10.*] - stability: [prefer-stable] + os: [ubuntu-latest] + php: [8.3, 8.2] + laravel: [11.*] + stability: [prefer-lowest, prefer-stable] include: - - laravel: 10.* - testbench: 8.* + - laravel: 11.* + testbench: 9.* name: PHP ${{ matrix.php }} – Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.gitignore b/.gitignore index 8368d97..f999f25 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ vendor +composer.lock .DS_Store .phpunit.result.cache .php-cs-fixer.cache diff --git a/README.md b/README.md index 8060a44..266287a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Laravel Sync This package provides a git-like artisan command to easily sync files and folders between environments. This is super useful for assets, documents, and any other files that are untracked in your git repository. -Laravel Sync is a no-brainer and will soon become best friends with your deploy script. The days are over when you had to manually keep track of files and folders between your environments. Do yourself a favor and give it a try! +Laravel Sync is a no-brainer and the perfect companion for your deploy script. The days are over when you had to manually keep track of files and folders between your environments. Do yourself a favor and give it a try! ## Requirements - `rsync` on both your source and destination machine @@ -78,11 +78,7 @@ return [ */ 'options' => [ - // '--archive', - // '--itemize-changes', - // '--verbose', - // '--human-readable', - // '--progress' + '--archive', ], ]; @@ -92,7 +88,7 @@ return [ To use this package, you have to define at least one remote and recipe. ### Remotes -Each remote consists of a a `user`, a `host` and a `root`. Optionally, you may also define the SSH `port` and define if the remote should be `read_only`. +Each remote consists of a `user`, `host`, and `root`. Optionally, you may also define the SSH `port` and make a remote `read_only`. | Key | Description | | ----------- | ---------------------------------------------- | @@ -100,7 +96,7 @@ Each remote consists of a a `user`, a `host` and a `root`. Optionally, you may a | `host` | The IP address of your server. | | `port` | The SSH port to use for this connection | | `root` | The absolute path to the project's root folder | -| `read_only` | Set to `true` to make the remote read only | +| `read_only` | Set to `true` to make the remote read-only | ```php 'remotes' => [ @@ -134,7 +130,6 @@ Configure the default rsync options to use when performing a sync. You can overr ```php 'options' => [ '--archive', - '--progress' ], ``` @@ -168,10 +163,11 @@ You have three commands at your disposal: ### Available Options You may use the following options: -| Option | Description | -| --------------------- | ---------------------------------- | -| `-O*` or `--option=*` | Override the default rsync options | -| `-D` or `--dry` | Perform a dry run of the sync | +| Option | Description | +| --------------------- | --------------------------------------------------------- | +| `-O*` or `--option=*` | Override the default rsync options | +| `-D` or `--dry` | Perform a dry run of the sync with real-time output | +| `-v` or `--verbose` | Displays the real-time output of the sync in the terminal | ## Usage Examples @@ -185,11 +181,16 @@ Push the assets recipe to the production remote with some custom rsync options: php artisan sync push production assets --option=-avh --option=--delete ``` -Perform a dry sync: +Perform a dry run: ```bash php artisan sync pull staging assets --dry ``` +Echo the real-time output of the sync in your terminal: +```bash +php artisan sync pull staging assets --verbose +``` + List the origin, target, options, and port in a nice table: ```bash php artisan sync:list pull staging assets diff --git a/composer.json b/composer.json index f43f28c..e3d7b43 100644 --- a/composer.json +++ b/composer.json @@ -12,13 +12,13 @@ } ], "require": { - "php": "^8.1", - "illuminate/support": "^10.0", - "titasgailius/terminal": "^1.2" + "php": "^8.2", + "illuminate/support": "^11.0", + "laravel/prompts": "^0.1.17" }, "require-dev": { - "nunomaduro/collision": "^7.0", - "orchestra/testbench": "^8.0", + "nunomaduro/collision": "^8.1", + "orchestra/testbench": "^9.0", "phpunit/phpunit": "^10.0" }, "autoload": { 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..6a72118 100644 --- a/src/Commands/BaseCommand.php +++ b/src/Commands/BaseCommand.php @@ -2,63 +2,152 @@ namespace Aerni\Sync\Commands; -use Facades\Aerni\Sync\CommandGenerator; -use Facades\Aerni\Sync\Config; +use Aerni\Sync\PathGenerator; +use Aerni\Sync\SyncCommand; use Illuminate\Console\Command; +use Illuminate\Contracts\Console\PromptsForMissingInput; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; +use Symfony\Component\Console\Output\OutputInterface; -class BaseCommand extends Command +use function Laravel\Prompts\select; + +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 [ + '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 CommandGenerator::operation($this->operation()) - ->remote($this->remote()) - ->recipe($this->recipe()) - ->options($this->rsyncOptions()); + 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..45f9b66 100644 --- a/src/Commands/Sync.php +++ b/src/Commands/Sync.php @@ -2,8 +2,10 @@ namespace Aerni\Sync\Commands; -use Facades\Aerni\Sync\SyncProcessor; -use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Process; +use Symfony\Component\Console\Output\OutputInterface; + +use function Laravel\Prompts\confirm; class Sync extends BaseCommand { @@ -26,25 +28,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 +60,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..bf0f289 --- /dev/null +++ b/src/SyncCommand.php @@ -0,0 +1,51 @@ + $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; - } -}