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;
- }
-}