├── .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 |   
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 |
--------------------------------------------------------------------------------