├── .github └── workflows │ ├── pint.yaml │ └── run-tests.yaml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── sync.php ├── phpunit.xml ├── src ├── Commands │ ├── BaseCommand.php │ ├── Sync.php │ ├── SyncCommands.php │ └── SyncList.php ├── PathGenerator.php ├── SyncCommand.php └── SyncServiceProvider.php └── tests └── ExampleTest.php /.github/workflows/pint.yaml: -------------------------------------------------------------------------------- 1 | name: Fix Styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v3 12 | with: 13 | ref: ${{ github.head_ref }} 14 | 15 | - name: Fix styling issues 16 | uses: aglipanci/laravel-pint-action@0.1.0 17 | 18 | - name: Commit changes 19 | uses: stefanzweifel/git-auto-commit-action@v4 20 | with: 21 | commit_message: Fix styling 22 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: [8.4, 8.3, 8.2] 13 | laravel: [11.*, 12.*] 14 | stability: [prefer-lowest, prefer-stable] 15 | include: 16 | - laravel: 11.* 17 | testbench: 9.* 18 | - laravel: 12.* 19 | testbench: 10.* 20 | 21 | name: PHP ${{ matrix.php }} – Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 32 | coverage: none 33 | 34 | - name: Install dependencies 35 | run: | 36 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 37 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 38 | 39 | - name: Run tests 40 | run: vendor/bin/phpunit 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | .DS_Store 4 | .phpunit.result.cache 5 | .php-cs-fixer.cache 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Michael Aerni 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Packagist version](https://flat.badgen.net/packagist/v/aerni/sync/latest) ![Packagist Total Downloads](https://flat.badgen.net/packagist/dt/aerni/sync) ![License](https://flat.badgen.net/github/license/aerni/laravel-sync) 2 | 3 | # Laravel Sync 4 | 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. 5 | 6 | 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! 7 | 8 | ## Requirements 9 | - `rsync` on both your source and destination machine 10 | - A working `SSH` setup between your source and destination machine 11 | 12 | ## Installation 13 | Install the package using Composer. 14 | 15 | ```bash 16 | composer require aerni/sync 17 | ``` 18 | 19 | Publish the config of the package. 20 | 21 | ```bash 22 | php artisan vendor:publish --provider="Aerni\Sync\SyncServiceProvider" 23 | ``` 24 | 25 | The following config will be published to `config/sync.php`. 26 | 27 | ```php 28 | [ 43 | 44 | // 'production' => [ 45 | // 'user' => 'forge', 46 | // 'host' => '104.26.3.113', 47 | // 'port' => 1431, 48 | // 'root' => '/home/forge/statamic.com', 49 | // 'read_only' => false, 50 | // ], 51 | 52 | ], 53 | 54 | /* 55 | |-------------------------------------------------------------------------- 56 | | Recipes 57 | |-------------------------------------------------------------------------- 58 | | 59 | | Define one or more recipes with the paths you want to sync. 60 | | Each recipe is an array of paths relative to your project's root. 61 | | 62 | */ 63 | 64 | 'recipes' => [ 65 | 66 | // 'assets' => ['storage/app/assets/', 'storage/app/img/'], 67 | 68 | ], 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Options 73 | |-------------------------------------------------------------------------- 74 | | 75 | | An array of default rsync options. 76 | | You can override these options when executing the command. 77 | | 78 | */ 79 | 80 | 'options' => [ 81 | '--archive', 82 | ], 83 | 84 | ]; 85 | ``` 86 | 87 | ## Configuration 88 | To use this package, you have to define at least one remote and recipe. 89 | 90 | ### Remotes 91 | Each remote consists of a `user`, `host`, and `root`. Optionally, you may also define the SSH `port` and make a remote `read_only`. 92 | 93 | | Key | Description | 94 | | ----------- | ---------------------------------------------- | 95 | | `user` | The username to log in to the host | 96 | | `host` | The IP address of your server. | 97 | | `port` | The SSH port to use for this connection | 98 | | `root` | The absolute path to the project's root folder | 99 | | `read_only` | Set to `true` to make the remote read-only | 100 | 101 | ```php 102 | 'remotes' => [ 103 | 104 | 'production' => [ 105 | 'user' => 'forge', 106 | 'host' => '104.26.3.113', 107 | 'port' => 1431, 108 | 'root' => '/home/forge/statamic.com', 109 | 'read_only' => env('SYNC_PRODUCTION', true), 110 | ], 111 | 112 | ], 113 | ``` 114 | 115 | The `read_only` option comes in handy if you want to prevent pushing to a remote from a certain environment, e.g. pushing to `production` from your local environment. 116 | 117 | ### Recipes 118 | Add any number of recipes with the paths you want to sync. Each recipe is an array of paths relative to your project's root. 119 | 120 | ```php 121 | 'recipes' => [ 122 | 'assets' => ['storage/app/assets/', 'storage/app/img/'], 123 | 'env' => ['.env'], 124 | ], 125 | ``` 126 | 127 | ### Options 128 | Configure the default rsync options to use when performing a sync. You can override these options when executing the command. 129 | 130 | ```php 131 | 'options' => [ 132 | '--archive', 133 | ], 134 | ``` 135 | 136 | ## The Command 137 | If you know git, you'll feel right at home with the syntax of this command: 138 | 139 | ```bash 140 | php artisan [command] [operation] [remote] [recipe] [options] 141 | ``` 142 | 143 | ### Command Structure 144 | The structure of the command looks like this: 145 | 146 | | Structure | Description | 147 | | ----------- | ------------------------------------------------ | 148 | | `command` | The command you want to use | 149 | | `operation` | The operation you want to use (`pull` or `push`) | 150 | | `remote` | The remote you want to sync with | 151 | | `recipe` | The recipe defining the paths to sync | 152 | | `options` | The options you want to use | 153 | 154 | ### Available Commands 155 | You have three commands at your disposal: 156 | 157 | | Command | Description | 158 | | --------------- | ---------------------------------------------------------- | 159 | | `sync` | Perform the sync | 160 | | `sync:list` | List the origin, target, options, and port in a nice table | 161 | | `sync:commands` | List all rsync commands | 162 | 163 | ### Available Options 164 | You may use the following options: 165 | 166 | | Option | Description | 167 | | --------------------- | --------------------------------------------------------- | 168 | | `-O*` or `--option=*` | Override the default rsync options | 169 | | `-D` or `--dry` | Perform a dry run of the sync with real-time output | 170 | | `-v` or `--verbose` | Displays the real-time output of the sync in the terminal | 171 | 172 | ## Usage Examples 173 | 174 | Pull the assets recipe from the staging remote: 175 | ```bash 176 | php artisan sync pull staging assets 177 | ``` 178 | 179 | Push the assets recipe to the production remote with some custom rsync options: 180 | ```bash 181 | php artisan sync push production assets --option=-avh --option=--delete 182 | ``` 183 | 184 | Perform a dry run: 185 | ```bash 186 | php artisan sync pull staging assets --dry 187 | ``` 188 | 189 | Echo the real-time output of the sync in your terminal: 190 | ```bash 191 | php artisan sync pull staging assets --verbose 192 | ``` 193 | 194 | List the origin, target, options, and port in a nice table: 195 | ```bash 196 | php artisan sync:list pull staging assets 197 | ``` 198 | 199 | List all rsync commands: 200 | ```bash 201 | php artisan sync:commands pull staging assets 202 | ``` 203 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aerni/sync", 3 | "description": "A git-like artisan command to easily sync files and folders between environments", 4 | "homepage": "https://github.com/aerni/laravel-sync", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Michael Aerni", 9 | "email": "hello@michaelaerni.ch", 10 | "homepage": "https://www.michaelaerni.ch", 11 | "role": "Developer" 12 | } 13 | ], 14 | "require": { 15 | "php": "^8.2", 16 | "illuminate/support": "^11.0 || ^12.0", 17 | "laravel/prompts": "^0.1.17 || ^0.2.0 || ^0.3.0" 18 | }, 19 | "require-dev": { 20 | "nunomaduro/collision": "^8.1", 21 | "orchestra/testbench": "^9.0 || ^10.0", 22 | "phpunit/phpunit": "^10.0 || ^11.0" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Aerni\\Sync\\": "src" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Aerni\\Sync\\Tests\\": "tests" 32 | } 33 | }, 34 | "scripts": { 35 | "test": "vendor/bin/phpunit" 36 | }, 37 | "extra": { 38 | "laravel": { 39 | "providers": [ 40 | "Aerni\\Sync\\SyncServiceProvider" 41 | ] 42 | } 43 | }, 44 | "config": { 45 | "sort-packages": true 46 | }, 47 | "prefer-stable": true, 48 | "minimum-stability": "dev" 49 | } 50 | -------------------------------------------------------------------------------- /config/sync.php: -------------------------------------------------------------------------------- 1 | [ 16 | 17 | // 'production' => [ 18 | // 'user' => 'forge', 19 | // 'host' => '104.26.3.113', 20 | // 'port' => 1431, 21 | // 'root' => '/home/forge/statamic.com', 22 | // 'read_only' => false, 23 | // ], 24 | 25 | ], 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Recipes 30 | |-------------------------------------------------------------------------- 31 | | 32 | | Define one or more recipes with the paths you want to sync. 33 | | Each recipe is an array of relative paths to your project's root. 34 | | 35 | */ 36 | 37 | 'recipes' => [ 38 | 39 | // 'assets' => ['storage/app/assets/', 'storage/app/img/'], 40 | 41 | ], 42 | 43 | /* 44 | |-------------------------------------------------------------------------- 45 | | Options 46 | |-------------------------------------------------------------------------- 47 | | 48 | | An array of default rsync options. 49 | | You can override these options when executing the command. 50 | | 51 | */ 52 | 53 | 'options' => [ 54 | '--archive', 55 | ], 56 | 57 | ]; 58 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ./tests 7 | 8 | 9 | 10 | 11 | ./src 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Commands/BaseCommand.php: -------------------------------------------------------------------------------- 1 | signature .= $baseSignature; 30 | 31 | parent::__construct(); 32 | } 33 | 34 | protected function promptForMissingArgumentsUsing(): array 35 | { 36 | return [ 37 | 'operation' => fn () => select( 38 | label: 'Choose if you want to push or pull', 39 | options: ['push', 'pull'], 40 | ), 41 | 'remote' => fn () => select( 42 | label: 'Choose the remote you want to sync with', 43 | options: array_keys($this->remotes()), 44 | ), 45 | 'recipe' => fn () => select( 46 | label: 'Choose the recipe defining the paths to sync', 47 | options: array_keys($this->recipes()), 48 | ), 49 | ]; 50 | } 51 | 52 | protected function validate(): void 53 | { 54 | Validator::validate($this->arguments(), [ 55 | 'operation' => 'required|in:push,pull', 56 | 'remote' => ['required', Rule::in(array_keys($this->remotes()))], 57 | 'recipe' => ['required', Rule::in(array_keys($this->recipes()))], 58 | ], [ 59 | 'operation.in' => 'The :attribute [:input] does not exists. Valid values are [push] or [pull].', 60 | 'remote.in' => 'The :attribute [:input] does not exists. Please choose a valid remote.', 61 | 'recipe.in' => 'The :attribute [:input] does not exists. Please choose a valid recipe.', 62 | ]); 63 | 64 | if ($this->localPathEqualsRemotePath()) { 65 | throw new \RuntimeException("The origin and target path are one and the same. You can't sync a path with itself."); 66 | } 67 | 68 | if ($this->remoteIsReadOnly() && $this->operation() === 'push') { 69 | throw new \RuntimeException("You can't push to the selected target as it is configured to be read-only."); 70 | } 71 | } 72 | 73 | protected function localPathEqualsRemotePath(): bool 74 | { 75 | return PathGenerator::localPath($this->recipe()[0]) 76 | === PathGenerator::remotePath($this->remote(), $this->recipe()[0]); 77 | } 78 | 79 | protected function remoteIsReadOnly(): bool 80 | { 81 | return Arr::get($this->remote(), 'read_only', false); 82 | } 83 | 84 | protected function commands(): Collection 85 | { 86 | return collect($this->recipe()) 87 | ->map(fn ($path) => new SyncCommand( 88 | path: $path, 89 | operation: $this->operation(), 90 | remote: $this->remote(), 91 | options: $this->rsyncOptions(), 92 | )); 93 | } 94 | 95 | protected function operation(): string 96 | { 97 | return $this->argument('operation'); 98 | } 99 | 100 | protected function remote(): array 101 | { 102 | return Arr::get($this->remotes(), $this->argument('remote')); 103 | } 104 | 105 | protected function recipe(): array 106 | { 107 | return Arr::get($this->recipes(), $this->argument('recipe')); 108 | } 109 | 110 | protected function remotes(): array 111 | { 112 | $remotes = config('sync.remotes'); 113 | 114 | if (empty($remotes)) { 115 | throw new \RuntimeException('You need to define at least one remote in your config file.'); 116 | } 117 | 118 | return $remotes; 119 | } 120 | 121 | protected function recipes(): array 122 | { 123 | $recipes = config('sync.recipes'); 124 | 125 | if (empty($recipes)) { 126 | throw new \RuntimeException('You need to define at least one recipe in your config file.'); 127 | } 128 | 129 | return $recipes; 130 | } 131 | 132 | protected function rsyncOptions(): string 133 | { 134 | $options = $this->option('option'); 135 | 136 | if (empty($options)) { 137 | $options = config('sync.options'); 138 | } 139 | 140 | return collect($options) 141 | ->when( 142 | $this->option('dry'), 143 | fn ($collection) => $collection->merge(['--dry-run', '--human-readable', '--progress', '--stats', '--verbose']) 144 | ) 145 | ->when( 146 | $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE, 147 | fn ($collection) => $collection->merge(['--human-readable', '--progress', '--stats', '--verbose']) 148 | ) 149 | ->filter() 150 | ->unique() 151 | ->implode(' '); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Commands/Sync.php: -------------------------------------------------------------------------------- 1 | validate(); 32 | 33 | /* Only show the confirmation if we're not performing a dry run */ 34 | if (! $this->option('dry') && ! confirm($this->confirmText())) { 35 | return; 36 | } 37 | 38 | $this->option('dry') 39 | ? $this->info('Starting a dry run ...') 40 | : $this->info('Syncing files ...'); 41 | 42 | $this->commands()->each(function ($command) { 43 | Process::forever()->run($command, function (string $type, string $output) { 44 | /* Only show the output if we're performing a dry run or the verbosity is set to verbose */ 45 | if ($this->option('dry') || $this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) { 46 | echo $output; 47 | } 48 | }); 49 | }); 50 | 51 | $this->option('dry') 52 | ? $this->info("The dry run of the {$this->argument('recipe')} recipe was successfull.") 53 | : $this->info("The sync of the {$this->argument('recipe')} recipe was successfull."); 54 | } 55 | 56 | protected function confirmText(): string 57 | { 58 | $operation = $this->argument('operation'); 59 | $recipe = $this->argument('recipe'); 60 | $remote = $this->argument('remote'); 61 | $preposition = $operation === 'pull' ? 'from' : 'to'; 62 | 63 | return "You are about to $operation the $recipe $preposition $remote. Are you sure?"; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Commands/SyncCommands.php: -------------------------------------------------------------------------------- 1 | validate(); 27 | 28 | $this->commands()->each(fn ($command) => $this->info($command)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Commands/SyncList.php: -------------------------------------------------------------------------------- 1 | validate(); 29 | 30 | table( 31 | ['Origin', 'Target', 'Options', 'Port'], 32 | $this->commands()->map->toArray() 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/PathGenerator.php: -------------------------------------------------------------------------------- 1 | $host === Http::get('https://api.ipify.org/?format=json')->json('ip')); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/SyncCommand.php: -------------------------------------------------------------------------------- 1 | $this->origin(), 21 | 'target' => $this->target(), 22 | 'options' => $this->options, 23 | 'port' => $this->port(), 24 | ]; 25 | } 26 | 27 | public function __toString(): string 28 | { 29 | return "rsync -e 'ssh -p {$this->port()}' {$this->options} {$this->origin()} {$this->target()}"; 30 | } 31 | 32 | protected function origin(): string 33 | { 34 | return $this->operation === 'pull' 35 | ? PathGenerator::remotePath($this->remote, $this->path) 36 | : PathGenerator::localPath($this->path); 37 | } 38 | 39 | protected function target(): string 40 | { 41 | return $this->operation === 'pull' 42 | ? PathGenerator::localPath($this->path) 43 | : PathGenerator::remotePath($this->remote, $this->path); 44 | } 45 | 46 | protected function port(): string 47 | { 48 | return $this->remote['port'] ?? '22'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/SyncServiceProvider.php: -------------------------------------------------------------------------------- 1 | commands([ 12 | Commands\Sync::class, 13 | Commands\SyncCommands::class, 14 | Commands\SyncList::class, 15 | ]); 16 | 17 | $this->publishes([ 18 | __DIR__.'/../config/sync.php' => config_path('sync.php'), 19 | ], 'sync-config'); 20 | 21 | $this->mergeConfigFrom(__DIR__.'/../config/sync.php', 'sync'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/ExampleTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 13 | } 14 | } 15 | --------------------------------------------------------------------------------