-
Notifications
You must be signed in to change notification settings - Fork 82
feat: Improve Flysystem bundle DX with push/pull console commands #204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
maxhelias
merged 12 commits into
thephpleague:3.x
from
Nitram1123:feat/push-pull-console-command
Mar 25, 2026
Merged
Changes from 5 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
26ce4ce
Add flysystem:push console command
Nitram1123 4f87980
Test flysystem:push command
Nitram1123 c686061
Add flysystem:pull console command
Nitram1123 ada83b5
Test flysystem:pull command
Nitram1123 3ccbd08
Document push and pull commands
Nitram1123 3cef16a
Add --force option to flysystem:pull to prevent accidental overwrites
Nitram1123 ca7b442
Check return value of stream_copy_to_stream to detect silent failures
Nitram1123 d5b7339
Use 0755 instead of 0777 for directory creation
Nitram1123 635f564
Extract base temp directory name into a constant
Nitram1123 562d460
Use Command constants instead of raw integers for exit codes in tests
Nitram1123 6d6aa69
Add tearDown to clean up temp directory after each test
Nitram1123 11a251f
Add --force protection to flysystem:push to prevent accidental overwr…
Nitram1123 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the flysystem-bundle project. | ||
| * | ||
| * (c) Titouan Galopin <galopintitouan@gmail.com> | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace League\FlysystemBundle\Command; | ||
|
|
||
| use League\Flysystem\FilesystemException; | ||
| use League\Flysystem\FilesystemOperator; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Console\Exception\InvalidArgumentException; | ||
| use Symfony\Component\Console\Input\InputArgument; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
| use Symfony\Component\Console\Question\Question; | ||
| use Symfony\Component\Console\Style\SymfonyStyle; | ||
| use Symfony\Component\DependencyInjection\ServiceLocator; | ||
|
|
||
| abstract class AbstractTransferCommand extends Command | ||
| { | ||
| public function __construct(private readonly ServiceLocator $storages) | ||
| { | ||
| parent::__construct(); | ||
| } | ||
|
|
||
| protected function configure(): void | ||
| { | ||
| $this | ||
| ->addArgument('storage', InputArgument::REQUIRED, 'The configured Flysystem storage name.') | ||
| ->addArgument('source', InputArgument::REQUIRED, 'The source path to transfer.') | ||
| ->addArgument('destination', InputArgument::OPTIONAL, 'The destination path. Defaults to the source basename.'); | ||
| } | ||
|
|
||
| protected function interact(InputInterface $input, OutputInterface $output): void | ||
| { | ||
| $io = new SymfonyStyle($input, $output); | ||
|
|
||
| if (null === $input->getArgument('storage')) { | ||
| $input->setArgument('storage', $io->askQuestion($this->createStorageQuestion())); | ||
| } | ||
|
|
||
| if (null === $input->getArgument('source')) { | ||
| $input->setArgument('source', $io->askQuestion($this->createRequiredQuestion( | ||
| 'What is the source path to transfer?', | ||
| 'The source path cannot be empty.' | ||
| ))); | ||
| } | ||
|
|
||
| if (null === $input->getArgument('destination')) { | ||
| $input->setArgument('destination', $io->askQuestion($this->createDestinationQuestion((string) $input->getArgument('source')))); | ||
| } | ||
| } | ||
|
|
||
| final protected function execute(InputInterface $input, OutputInterface $output): int | ||
| { | ||
| $io = new SymfonyStyle($input, $output); | ||
| $storageName = (string) $input->getArgument('storage'); | ||
| $source = (string) $input->getArgument('source'); | ||
| $destination = $input->getArgument('destination'); | ||
| $destination = null === $destination ? basename($source) : (string) $destination; | ||
| $destination = $this->normalizeDestination($source, $destination); | ||
|
|
||
| try { | ||
| $storage = $this->getStorage($storageName); | ||
| $this->transfer($storage, $source, $destination); | ||
| } catch (InvalidArgumentException|\InvalidArgumentException $exception) { | ||
| $io->error($exception->getMessage()); | ||
|
|
||
| return self::INVALID; | ||
| } catch (FilesystemException|\RuntimeException $exception) { | ||
| $io->error($exception->getMessage()); | ||
|
|
||
| return self::FAILURE; | ||
| } | ||
|
|
||
| $io->success($this->createSuccessMessage($storageName, $source, $destination)); | ||
|
|
||
| return self::SUCCESS; | ||
| } | ||
|
|
||
| private function createStorageQuestion(): Question | ||
| { | ||
| $storageNames = array_keys($this->storages->getProvidedServices()); | ||
| sort($storageNames); | ||
|
|
||
| $question = new Question('Which configured Flysystem storage should be used?', 1 === count($storageNames) ? $storageNames[0] : null); | ||
| $question->setAutocompleterValues($storageNames); | ||
| $question->setValidator(function (?string $answer): string { | ||
| $answer = trim((string) $answer); | ||
|
|
||
| if ('' === $answer) { | ||
| throw new \RuntimeException('The storage name cannot be empty.'); | ||
| } | ||
|
|
||
| if (!$this->storages->has($answer)) { | ||
| throw new \RuntimeException(sprintf('The storage "%s" does not exist.', $answer)); | ||
| } | ||
|
|
||
| return $answer; | ||
| }); | ||
|
|
||
| return $question; | ||
| } | ||
|
|
||
| private function createDestinationQuestion(string $source): Question | ||
| { | ||
| $question = new Question('What is the destination path?', basename($source)); | ||
| $question->setValidator(function (?string $answer): string { | ||
| $answer = trim((string) $answer); | ||
|
|
||
| if ('' === $answer) { | ||
| throw new \RuntimeException('The destination path cannot be empty.'); | ||
| } | ||
|
|
||
| return $answer; | ||
| }); | ||
|
|
||
| return $question; | ||
| } | ||
|
|
||
| private function createRequiredQuestion(string $label, string $errorMessage): Question | ||
| { | ||
| $question = new Question($label); | ||
| $question->setValidator(function (?string $answer) use ($errorMessage): string { | ||
| $answer = trim((string) $answer); | ||
|
|
||
| if ('' === $answer) { | ||
| throw new \RuntimeException($errorMessage); | ||
| } | ||
|
|
||
| return $answer; | ||
| }); | ||
|
|
||
| return $question; | ||
| } | ||
|
|
||
| private function getStorage(string $storageName): FilesystemOperator | ||
| { | ||
| if (!$this->storages->has($storageName)) { | ||
| throw new InvalidArgumentException(sprintf('The storage "%s" does not exist.', $storageName)); | ||
| } | ||
|
|
||
| $storage = $this->storages->get($storageName); | ||
| if (!$storage instanceof FilesystemOperator) { | ||
| throw new \RuntimeException(sprintf('The storage "%s" is not a Flysystem operator.', $storageName)); | ||
| } | ||
|
|
||
| return $storage; | ||
| } | ||
|
|
||
| protected function normalizeDestination(string $source, string $destination): string | ||
| { | ||
| return $destination; | ||
| } | ||
|
|
||
| abstract protected function transfer(FilesystemOperator $storage, string $source, string $destination): void; | ||
|
|
||
| abstract protected function createSuccessMessage(string $storageName, string $source, string $destination): string; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the flysystem-bundle project. | ||
| * | ||
| * (c) Titouan Galopin <galopintitouan@gmail.com> | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace League\FlysystemBundle\Command; | ||
|
|
||
| use League\Flysystem\FilesystemOperator; | ||
| use Symfony\Component\Console\Attribute\AsCommand; | ||
|
|
||
| #[AsCommand(name: 'flysystem:pull', description: 'Pull a file from a configured Flysystem storage to the local filesystem.')] | ||
| final class PullCommand extends AbstractTransferCommand | ||
| { | ||
| protected function normalizeDestination(string $source, string $destination): string | ||
| { | ||
| if (!is_dir($destination)) { | ||
| return $destination; | ||
| } | ||
|
|
||
| return rtrim($destination, '/\\').DIRECTORY_SEPARATOR.basename($source); | ||
| } | ||
|
|
||
| protected function transfer(FilesystemOperator $storage, string $source, string $destination): void | ||
| { | ||
| $directory = dirname($destination); | ||
| if ('.' !== $directory && !is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { | ||
| throw new \RuntimeException(sprintf('Unable to create the destination directory "%s".', $directory)); | ||
| } | ||
|
|
||
| $resource = $storage->readStream($source); | ||
| if (!is_resource($resource)) { | ||
| throw new \RuntimeException(sprintf('Unable to read the source file "%s" from storage.', $source)); | ||
| } | ||
|
|
||
| $local = fopen($destination, 'wb'); | ||
|
maxhelias marked this conversation as resolved.
|
||
| if (false === $local) { | ||
| if (is_resource($resource)) { | ||
| fclose($resource); | ||
| } | ||
|
|
||
| throw new \RuntimeException(sprintf('Unable to open the destination file "%s" for writing.', $destination)); | ||
| } | ||
|
|
||
| try { | ||
| stream_copy_to_stream($resource, $local); | ||
|
maxhelias marked this conversation as resolved.
Outdated
|
||
| } finally { | ||
| fclose($resource); | ||
| fclose($local); | ||
| } | ||
| } | ||
|
|
||
| protected function createSuccessMessage(string $storageName, string $source, string $destination): string | ||
| { | ||
| return sprintf('Pulled "%s" from storage "%s" to "%s".', $source, $storageName, $destination); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the flysystem-bundle project. | ||
| * | ||
| * (c) Titouan Galopin <galopintitouan@gmail.com> | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace League\FlysystemBundle\Command; | ||
|
|
||
| use League\Flysystem\FilesystemOperator; | ||
| use Symfony\Component\Console\Attribute\AsCommand; | ||
|
|
||
| #[AsCommand(name: 'flysystem:push', description: 'Push a local file to a configured Flysystem storage.')] | ||
| final class PushCommand extends AbstractTransferCommand | ||
| { | ||
| protected function transfer(FilesystemOperator $storage, string $source, string $destination): void | ||
| { | ||
| if (!is_file($source)) { | ||
| throw new \InvalidArgumentException(sprintf('The source file "%s" does not exist or is not a regular file.', $source)); | ||
| } | ||
|
|
||
| $resource = fopen($source, 'rb'); | ||
| if (false === $resource) { | ||
| throw new \RuntimeException(sprintf('Unable to open the source file "%s" for reading.', $source)); | ||
| } | ||
|
|
||
| try { | ||
| $storage->writeStream($destination, $resource); | ||
| } finally { | ||
| fclose($resource); | ||
| } | ||
| } | ||
|
|
||
| protected function createSuccessMessage(string $storageName, string $source, string $destination): string | ||
| { | ||
| return sprintf('Pushed "%s" to "%s" on storage "%s".', $source, $destination, $storageName); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.