Skip to content

Commit 6f8043f

Browse files
authored
feat: Improve Flysystem bundle DX with push/pull console commands (#204)
* Add flysystem:push console command * Test flysystem:push command * Add flysystem:pull console command * Test flysystem:pull command * Document push and pull commands * Add --force option to flysystem:pull to prevent accidental overwrites * Check return value of stream_copy_to_stream to detect silent failures * Use 0755 instead of 0777 for directory creation * Extract base temp directory name into a constant * Use Command constants instead of raw integers for exit codes in tests * Add tearDown to clean up temp directory after each test * Add --force protection to flysystem:push to prevent accidental overwrites
1 parent bd0f219 commit 6f8043f

File tree

7 files changed

+618
-1
lines changed

7 files changed

+618
-1
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ Once you have a FilesystemOperator, you can call methods from the
111111
[Filesystem API](https://flysystem.thephpleague.com/v2/docs/usage/filesystem-api/)
112112
to interact with your storage.
113113

114+
If you need to transfer files between the local filesystem and one of your configured storages, the bundle also provides two console commands:
115+
116+
```bash
117+
bin/console flysystem:push <storage> <local-source> [remote-destination]
118+
bin/console flysystem:pull <storage> <remote-source> [local-destination]
119+
```
120+
121+
The `<storage>` argument is the configured Flysystem storage name (for example `default.storage`), not the adapter type. When the destination is omitted, the basename of the source path is used.
122+
114123
## Full documentation
115124

116125
1. [Getting started](docs/1-getting-started.md)
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the flysystem-bundle project.
5+
*
6+
* (c) Titouan Galopin <galopintitouan@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace League\FlysystemBundle\Command;
13+
14+
use League\Flysystem\FilesystemException;
15+
use League\Flysystem\FilesystemOperator;
16+
use Symfony\Component\Console\Command\Command;
17+
use Symfony\Component\Console\Exception\InvalidArgumentException;
18+
use Symfony\Component\Console\Input\InputArgument;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Input\InputOption;
21+
use Symfony\Component\Console\Output\OutputInterface;
22+
use Symfony\Component\Console\Question\Question;
23+
use Symfony\Component\Console\Style\SymfonyStyle;
24+
use Symfony\Component\DependencyInjection\ServiceLocator;
25+
26+
abstract class AbstractTransferCommand extends Command
27+
{
28+
public function __construct(private readonly ServiceLocator $storages)
29+
{
30+
parent::__construct();
31+
}
32+
33+
protected function configure(): void
34+
{
35+
$this
36+
->addArgument('storage', InputArgument::REQUIRED, 'The configured Flysystem storage name.')
37+
->addArgument('source', InputArgument::REQUIRED, 'The source path to transfer.')
38+
->addArgument('destination', InputArgument::OPTIONAL, 'The destination path. Defaults to the source basename.')
39+
->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the destination file if it already exists.');
40+
}
41+
42+
protected function interact(InputInterface $input, OutputInterface $output): void
43+
{
44+
$io = new SymfonyStyle($input, $output);
45+
46+
if (null === $input->getArgument('storage')) {
47+
$input->setArgument('storage', $io->askQuestion($this->createStorageQuestion()));
48+
}
49+
50+
if (null === $input->getArgument('source')) {
51+
$input->setArgument('source', $io->askQuestion($this->createRequiredQuestion(
52+
'What is the source path to transfer?',
53+
'The source path cannot be empty.'
54+
)));
55+
}
56+
57+
if (null === $input->getArgument('destination')) {
58+
$input->setArgument('destination', $io->askQuestion($this->createDestinationQuestion((string) $input->getArgument('source'))));
59+
}
60+
}
61+
62+
final protected function execute(InputInterface $input, OutputInterface $output): int
63+
{
64+
$io = new SymfonyStyle($input, $output);
65+
$storageName = (string) $input->getArgument('storage');
66+
$source = (string) $input->getArgument('source');
67+
$destination = $input->getArgument('destination');
68+
$destination = null === $destination ? basename($source) : (string) $destination;
69+
$destination = $this->normalizeDestination($source, $destination);
70+
71+
$force = (bool) $input->getOption('force');
72+
73+
try {
74+
$storage = $this->getStorage($storageName);
75+
$this->transfer($storage, $source, $destination, $force);
76+
} catch (InvalidArgumentException|\InvalidArgumentException $exception) {
77+
$io->error($exception->getMessage());
78+
79+
return self::INVALID;
80+
} catch (FilesystemException|\RuntimeException $exception) {
81+
$io->error($exception->getMessage());
82+
83+
return self::FAILURE;
84+
}
85+
86+
$io->success($this->createSuccessMessage($storageName, $source, $destination));
87+
88+
return self::SUCCESS;
89+
}
90+
91+
private function createStorageQuestion(): Question
92+
{
93+
$storageNames = array_keys($this->storages->getProvidedServices());
94+
sort($storageNames);
95+
96+
$question = new Question('Which configured Flysystem storage should be used?', 1 === count($storageNames) ? $storageNames[0] : null);
97+
$question->setAutocompleterValues($storageNames);
98+
$question->setValidator(function (?string $answer): string {
99+
$answer = trim((string) $answer);
100+
101+
if ('' === $answer) {
102+
throw new \RuntimeException('The storage name cannot be empty.');
103+
}
104+
105+
if (!$this->storages->has($answer)) {
106+
throw new \RuntimeException(sprintf('The storage "%s" does not exist.', $answer));
107+
}
108+
109+
return $answer;
110+
});
111+
112+
return $question;
113+
}
114+
115+
private function createDestinationQuestion(string $source): Question
116+
{
117+
$question = new Question('What is the destination path?', basename($source));
118+
$question->setValidator(function (?string $answer): string {
119+
$answer = trim((string) $answer);
120+
121+
if ('' === $answer) {
122+
throw new \RuntimeException('The destination path cannot be empty.');
123+
}
124+
125+
return $answer;
126+
});
127+
128+
return $question;
129+
}
130+
131+
private function createRequiredQuestion(string $label, string $errorMessage): Question
132+
{
133+
$question = new Question($label);
134+
$question->setValidator(function (?string $answer) use ($errorMessage): string {
135+
$answer = trim((string) $answer);
136+
137+
if ('' === $answer) {
138+
throw new \RuntimeException($errorMessage);
139+
}
140+
141+
return $answer;
142+
});
143+
144+
return $question;
145+
}
146+
147+
private function getStorage(string $storageName): FilesystemOperator
148+
{
149+
if (!$this->storages->has($storageName)) {
150+
throw new InvalidArgumentException(sprintf('The storage "%s" does not exist.', $storageName));
151+
}
152+
153+
$storage = $this->storages->get($storageName);
154+
if (!$storage instanceof FilesystemOperator) {
155+
throw new \RuntimeException(sprintf('The storage "%s" is not a Flysystem operator.', $storageName));
156+
}
157+
158+
return $storage;
159+
}
160+
161+
protected function normalizeDestination(string $source, string $destination): string
162+
{
163+
return $destination;
164+
}
165+
166+
abstract protected function transfer(FilesystemOperator $storage, string $source, string $destination, bool $force = false): void;
167+
168+
abstract protected function createSuccessMessage(string $storageName, string $source, string $destination): string;
169+
}

src/Command/PullCommand.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the flysystem-bundle project.
5+
*
6+
* (c) Titouan Galopin <galopintitouan@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace League\FlysystemBundle\Command;
13+
14+
use League\Flysystem\FilesystemOperator;
15+
use Symfony\Component\Console\Attribute\AsCommand;
16+
17+
#[AsCommand(name: 'flysystem:pull', description: 'Pull a file from a configured Flysystem storage to the local filesystem.')]
18+
final class PullCommand extends AbstractTransferCommand
19+
{
20+
protected function normalizeDestination(string $source, string $destination): string
21+
{
22+
if (!is_dir($destination)) {
23+
return $destination;
24+
}
25+
26+
return rtrim($destination, '/\\').DIRECTORY_SEPARATOR.basename($source);
27+
}
28+
29+
protected function transfer(FilesystemOperator $storage, string $source, string $destination, bool $force = false): void
30+
{
31+
$directory = dirname($destination);
32+
if ('.' !== $directory && !is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
33+
throw new \RuntimeException(sprintf('Unable to create the destination directory "%s".', $directory));
34+
}
35+
36+
if (!$force && is_file($destination)) {
37+
throw new \RuntimeException(sprintf('The destination file "%s" already exists. Use the --force option to overwrite it.', $destination));
38+
}
39+
40+
$resource = $storage->readStream($source);
41+
if (!is_resource($resource)) {
42+
throw new \RuntimeException(sprintf('Unable to read the source file "%s" from storage.', $source));
43+
}
44+
45+
$local = fopen($destination, 'wb');
46+
if (false === $local) {
47+
if (is_resource($resource)) {
48+
fclose($resource);
49+
}
50+
51+
throw new \RuntimeException(sprintf('Unable to open the destination file "%s" for writing.', $destination));
52+
}
53+
54+
try {
55+
$bytesCopied = stream_copy_to_stream($resource, $local);
56+
if (false === $bytesCopied) {
57+
throw new \RuntimeException(sprintf('Failed to write "%s" to "%s": stream copy failed.', $source, $destination));
58+
}
59+
} finally {
60+
fclose($resource);
61+
fclose($local);
62+
}
63+
}
64+
65+
protected function createSuccessMessage(string $storageName, string $source, string $destination): string
66+
{
67+
return sprintf('Pulled "%s" from storage "%s" to "%s".', $source, $storageName, $destination);
68+
}
69+
}

src/Command/PushCommand.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the flysystem-bundle project.
5+
*
6+
* (c) Titouan Galopin <galopintitouan@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace League\FlysystemBundle\Command;
13+
14+
use League\Flysystem\FilesystemOperator;
15+
use Symfony\Component\Console\Attribute\AsCommand;
16+
17+
#[AsCommand(name: 'flysystem:push', description: 'Push a local file to a configured Flysystem storage.')]
18+
final class PushCommand extends AbstractTransferCommand
19+
{
20+
protected function transfer(FilesystemOperator $storage, string $source, string $destination, bool $force = false): void
21+
{
22+
if (!is_file($source)) {
23+
throw new \InvalidArgumentException(sprintf('The source file "%s" does not exist or is not a regular file.', $source));
24+
}
25+
26+
if (!$force && $storage->fileExists($destination)) {
27+
throw new \RuntimeException(sprintf('The destination file "%s" already exists on the storage. Use the --force option to overwrite it.', $destination));
28+
}
29+
30+
$resource = fopen($source, 'rb');
31+
if (false === $resource) {
32+
throw new \RuntimeException(sprintf('Unable to open the source file "%s" for reading.', $source));
33+
}
34+
35+
try {
36+
$storage->writeStream($destination, $resource);
37+
} finally {
38+
fclose($resource);
39+
}
40+
}
41+
42+
protected function createSuccessMessage(string $storageName, string $source, string $destination): string
43+
{
44+
return sprintf('Pushed "%s" to "%s" on storage "%s".', $source, $destination, $storageName);
45+
}
46+
}

src/DependencyInjection/FlysystemExtension.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,19 @@
1717
use League\Flysystem\FilesystemWriter;
1818
use League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter;
1919
use League\FlysystemBundle\Adapter\AdapterDefinitionFactory;
20+
use League\FlysystemBundle\Command\PullCommand;
21+
use League\FlysystemBundle\Command\PushCommand;
2022
use League\FlysystemBundle\Exception\MissingPackageException;
2123
use League\FlysystemBundle\Lazy\LazyFactory;
24+
use Symfony\Component\Console\Command\Command;
2225
use Symfony\Component\DependencyInjection\ContainerBuilder;
2326
use Symfony\Component\DependencyInjection\Definition;
2427
use Symfony\Component\DependencyInjection\Extension\Extension;
2528
use Symfony\Component\DependencyInjection\Reference;
2629
use Symfony\Component\OptionsResolver\OptionsResolver;
2730

31+
use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_locator;
32+
2833
/**
2934
* @author Titouan Galopin <galopintitouan@gmail.com>
3035
*/
@@ -40,9 +45,34 @@ public function load(array $configs, ContainerBuilder $container): void
4045
->setPublic(false)
4146
;
4247

48+
if (ContainerBuilder::willBeAvailable('symfony/console', Command::class, ['symfony/framework-bundle'])) {
49+
$this->registerPushCommand($container);
50+
$this->registerPullCommand($container);
51+
}
52+
4353
$this->createStoragesDefinitions($config, $container);
4454
}
4555

56+
private function registerPushCommand(ContainerBuilder $container): void
57+
{
58+
$container
59+
->register(PushCommand::class, PushCommand::class)
60+
->setPublic(false)
61+
->setArgument('$storages', tagged_locator('flysystem.storage', 'storage'))
62+
->addTag('console.command')
63+
;
64+
}
65+
66+
private function registerPullCommand(ContainerBuilder $container): void
67+
{
68+
$container
69+
->register(PullCommand::class, PullCommand::class)
70+
->setPublic(false)
71+
->setArgument('$storages', tagged_locator('flysystem.storage', 'storage'))
72+
->addTag('console.command')
73+
;
74+
}
75+
4676
private function createStoragesDefinitions(array $config, ContainerBuilder $container): void
4777
{
4878
$definitionFactory = new AdapterDefinitionFactory();

0 commit comments

Comments
 (0)