From 5d3e97bc3ecec8e15e39d733be6f6b30ffa7f0b4 Mon Sep 17 00:00:00 2001 From: Igor Pinchuk Date: Fri, 26 Sep 2025 02:19:06 +0800 Subject: [PATCH 1/2] Add Composer command for refreshing symlinks --- README.md | 19 +++++ src/Symlinks/Command/CommandProvider.php | 17 ++++ src/Symlinks/Command/RefreshCommand.php | 46 +++++++++++ src/Symlinks/Plugin.php | 48 +++-------- src/Symlinks/SymlinksExecutionTrait.php | 47 +++++++++++ tests/PluginTest.php | 22 +++++ tests/RefreshCommandTest.php | 101 +++++++++++++++++++++++ 7 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 src/Symlinks/Command/CommandProvider.php create mode 100644 src/Symlinks/Command/RefreshCommand.php create mode 100644 src/Symlinks/SymlinksExecutionTrait.php create mode 100644 tests/RefreshCommandTest.php diff --git a/README.md b/README.md index f557de2..20c48a4 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,25 @@ For personal configs `link` must be defined } ``` +### 2. Refresh symlinks + +Symlinks are created automatically on `composer install`/`update`, but you can +trigger the process manually with the built-in command: + +```bash +$ composer symlinks:refresh +``` + +Add the `--dry-run` flag to preview the operations without touching the +filesystem: + +```bash +$ composer symlinks:refresh --dry-run +``` + +The legacy environment variable `SYMLINKS_DRY_RUN=1` is still honoured during +Composer hooks for backwards compatibility. + ### 3. Execute composer DO NOT use --no-plugins for composer install or update diff --git a/src/Symlinks/Command/CommandProvider.php b/src/Symlinks/Command/CommandProvider.php new file mode 100644 index 0000000..0768329 --- /dev/null +++ b/src/Symlinks/Command/CommandProvider.php @@ -0,0 +1,17 @@ +setName('symlinks:refresh') + ->setDescription('Create symlinks defined in extra.somework/composer-symlinks.') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show the operations without creating links.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $composer = $this->requireComposer(); + $io = $this->getIO(); + + $dryRun = (bool)$input->getOption('dry-run'); + + $event = new Event('symlinks:refresh', $composer, $io); + $filesystem = new Filesystem(); + $factory = new SymlinksFactory($event, $filesystem); + $processor = new SymlinksProcessor($filesystem, $dryRun); + + $this->runSymlinks($factory, $processor, $io, $dryRun); + + return Command::SUCCESS; + } +} + diff --git a/src/Symlinks/Plugin.php b/src/Symlinks/Plugin.php index 1e5a21e..136ea93 100644 --- a/src/Symlinks/Plugin.php +++ b/src/Symlinks/Plugin.php @@ -5,13 +5,17 @@ use Composer\Composer; use Composer\IO\IOInterface; +use Composer\Plugin\Capable; +use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability; use Composer\Plugin\PluginInterface; use Composer\Script\Event; use Composer\Script\ScriptEvents; use Composer\Util\Filesystem; -class Plugin implements PluginInterface +class Plugin implements PluginInterface, Capable { + use SymlinksExecutionTrait; + public function activate(Composer $composer, IOInterface $io): void { $eventDispatcher = $composer->getEventDispatcher(); @@ -19,6 +23,13 @@ public function activate(Composer $composer, IOInterface $io): void $eventDispatcher->addListener(ScriptEvents::POST_UPDATE_CMD, $this->createLinks()); } + public function getCapabilities(): array + { + return [ + CommandProviderCapability::class => Command\CommandProvider::class, + ]; + } + public function deactivate(Composer $composer, IOInterface $io): void { } @@ -35,40 +46,7 @@ protected function createLinks(): callable $dryRun = getenv('SYMLINKS_DRY_RUN') === '1' || getenv('SYMLINKS_DRY_RUN') === 'true'; $processor = new SymlinksProcessor($fileSystem, $dryRun); - $symlinks = $factory->process(); - foreach ($symlinks as $symlink) { - try { - if (!$processor->processSymlink($symlink)) { - throw new RuntimeException('Unknown error'); - } - $event - ->getIO() - ->write(sprintf( - ' %sSymlinking %s to %s', - $dryRun ? '[DRY RUN] ' : '', - $symlink->getLink(), - $symlink->getTarget() - )); - } catch (LinkDirectoryError $exception) { - $event - ->getIO() - ->write(sprintf( - ' Symlinking %s to %s - %s', - $symlink->getLink(), - $symlink->getTarget(), - 'Skipped' - )); - } catch (\Exception $exception) { - $event - ->getIO() - ->writeError(sprintf( - ' Symlinking %s to %s - %s', - $symlink->getLink(), - $symlink->getTarget(), - $exception->getMessage() - )); - } - } + $this->runSymlinks($factory, $processor, $event->getIO(), $dryRun); }; } } diff --git a/src/Symlinks/SymlinksExecutionTrait.php b/src/Symlinks/SymlinksExecutionTrait.php new file mode 100644 index 0000000..48554e2 --- /dev/null +++ b/src/Symlinks/SymlinksExecutionTrait.php @@ -0,0 +1,47 @@ +process(); + foreach ($symlinks as $symlink) { + try { + if (!$processor->processSymlink($symlink)) { + throw new RuntimeException('Unknown error'); + } + + $io->write(sprintf( + ' %sSymlinking %s to %s', + $dryRun ? '[DRY RUN] ' : '', + $symlink->getLink(), + $symlink->getTarget() + )); + } catch (LinkDirectoryError $exception) { + $io->write(sprintf( + ' Symlinking %s to %s - %s', + $symlink->getLink(), + $symlink->getTarget(), + 'Skipped' + )); + } catch (\Exception $exception) { + $io->writeError(sprintf( + ' Symlinking %s to %s - %s', + $symlink->getLink(), + $symlink->getTarget(), + $exception->getMessage() + )); + } + } + } +} + diff --git a/tests/PluginTest.php b/tests/PluginTest.php index 650fac8..fe72a96 100644 --- a/tests/PluginTest.php +++ b/tests/PluginTest.php @@ -5,9 +5,11 @@ use Composer\Composer; use Composer\EventDispatcher\EventDispatcher; use Composer\IO\NullIO; +use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability; use Composer\Script\ScriptEvents; use PHPUnit\Framework\TestCase; use SomeWork\Symlinks\Plugin; +use SomeWork\Symlinks\Command\RefreshCommand; class PluginTest extends TestCase { @@ -35,4 +37,24 @@ public function addListener(string $eventName, $listener, int $priority = 0): vo $this->assertArrayHasKey(ScriptEvents::POST_UPDATE_CMD, $dispatcher->recorded); $this->assertIsCallable($dispatcher->recorded[ScriptEvents::POST_INSTALL_CMD][0]); } + + public function testGetCapabilitiesRegistersCommand(): void + { + $plugin = new Plugin(); + + $capabilities = $plugin->getCapabilities(); + + $this->assertArrayHasKey(CommandProviderCapability::class, $capabilities); + $this->assertSame( + \SomeWork\Symlinks\Command\CommandProvider::class, + $capabilities[CommandProviderCapability::class] + ); + + $providerClass = $capabilities[CommandProviderCapability::class]; + $provider = new $providerClass(); + $commands = $provider->getCommands(); + + $this->assertNotEmpty($commands); + $this->assertInstanceOf(RefreshCommand::class, $commands[0]); + } } diff --git a/tests/RefreshCommandTest.php b/tests/RefreshCommandTest.php new file mode 100644 index 0000000..e3f7cb5 --- /dev/null +++ b/tests/RefreshCommandTest.php @@ -0,0 +1,101 @@ +createComposer([ + 'somework/composer-symlinks' => [ + 'symlinks' => [ + 'source/file.txt' => 'link.txt', + ], + ], + ]); + + $this->runCommand($composer); + + $this->assertTrue(is_link($tmp . '/link.txt')); + $this->assertSame( + realpath($tmp . '/source/file.txt'), + realpath(dirname($tmp . '/link.txt') . '/' . readlink($tmp . '/link.txt')) + ); + + chdir($cwd); + } + + public function testDryRunOptionDoesNotCreateLinks(): void + { + $tmp = sys_get_temp_dir() . '/command_' . uniqid(); + mkdir($tmp); + mkdir($tmp . '/source'); + file_put_contents($tmp . '/source/file.txt', 'data'); + + $cwd = getcwd(); + chdir($tmp); + + $composer = $this->createComposer([ + 'somework/composer-symlinks' => [ + 'symlinks' => [ + 'source/file.txt' => 'link.txt', + ], + ], + ]); + + $this->runCommand($composer, ['--dry-run' => true]); + + $this->assertFalse(file_exists($tmp . '/link.txt')); + + chdir($cwd); + } + + private function createComposer(array $extra): Composer + { + $composer = new Composer(); + $io = new NullIO(); + $dispatcher = new EventDispatcher($composer, $io); + $composer->setEventDispatcher($dispatcher); + + $package = new RootPackage('test/test', '1.0.0', '1.0.0'); + $package->setExtra($extra); + $composer->setPackage($package); + + $composer->setConfig(new \Composer\Config()); + + return $composer; + } + + private function runCommand(Composer $composer, array $input = []): void + { + $application = new Application(); + $application->setAutoExit(false); + $io = new NullIO(); + + $command = new RefreshCommand(); + $command->setComposer($composer); + $command->setIO($io); + $application->add($command); + + $tester = new CommandTester($application->find('symlinks:refresh')); + $tester->execute(array_merge(['command' => 'symlinks:refresh'], $input)); + } +} + From 85b1cd2931e70359b5f92aca533f2c50c816f14e Mon Sep 17 00:00:00 2001 From: Igor Pinchuk Date: Fri, 26 Sep 2025 02:27:15 +0800 Subject: [PATCH 2/2] Fix coding style for new symlinks command --- src/Symlinks/Command/CommandProvider.php | 1 - src/Symlinks/Command/RefreshCommand.php | 3 +-- src/Symlinks/Plugin.php | 2 +- src/Symlinks/SymlinksExecutionTrait.php | 1 - 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Symlinks/Command/CommandProvider.php b/src/Symlinks/Command/CommandProvider.php index 0768329..028f931 100644 --- a/src/Symlinks/Command/CommandProvider.php +++ b/src/Symlinks/Command/CommandProvider.php @@ -14,4 +14,3 @@ public function getCommands(): array ]; } } - diff --git a/src/Symlinks/Command/RefreshCommand.php b/src/Symlinks/Command/RefreshCommand.php index 1f5ae16..30c71dd 100644 --- a/src/Symlinks/Command/RefreshCommand.php +++ b/src/Symlinks/Command/RefreshCommand.php @@ -31,7 +31,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $composer = $this->requireComposer(); $io = $this->getIO(); - $dryRun = (bool)$input->getOption('dry-run'); + $dryRun = (bool) $input->getOption('dry-run'); $event = new Event('symlinks:refresh', $composer, $io); $filesystem = new Filesystem(); @@ -43,4 +43,3 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } } - diff --git a/src/Symlinks/Plugin.php b/src/Symlinks/Plugin.php index 136ea93..1c99f71 100644 --- a/src/Symlinks/Plugin.php +++ b/src/Symlinks/Plugin.php @@ -5,8 +5,8 @@ use Composer\Composer; use Composer\IO\IOInterface; -use Composer\Plugin\Capable; use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability; +use Composer\Plugin\Capable; use Composer\Plugin\PluginInterface; use Composer\Script\Event; use Composer\Script\ScriptEvents; diff --git a/src/Symlinks/SymlinksExecutionTrait.php b/src/Symlinks/SymlinksExecutionTrait.php index 48554e2..ad3c75e 100644 --- a/src/Symlinks/SymlinksExecutionTrait.php +++ b/src/Symlinks/SymlinksExecutionTrait.php @@ -44,4 +44,3 @@ private function runSymlinks( } } } -