diff --git a/README.md b/README.md index b6e5ba4..5267334 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,17 @@ If you run the command with `--force`, the installer becomes aggressive: - existing files inside published folders are overwritten - appendable content is appended again, even if it is already present +### Filtering files + +You can pass `--filter` one or more times to publish only files whose target path contains any of the given strings (case-insensitive): + +```bash +php artisan my-install-command --filter=Services +php artisan my-install-command --filter=Services --filter=Models +``` + +This is useful when you want to re-publish specific files without running the full install. Note that `--filter` only affects file publishing; Composer and Node package installation is unaffected. + ### Publish overview After publishing files, the command prints an overview of what happened: diff --git a/src/InstallCommand.php b/src/InstallCommand.php index aa9c879..1fd671f 100755 --- a/src/InstallCommand.php +++ b/src/InstallCommand.php @@ -15,6 +15,7 @@ use Illuminate\Console\Command; use Illuminate\Process\Exceptions\ProcessFailedException; use Illuminate\Support\Collection; +use Symfony\Component\Console\Input\InputOption; abstract class InstallCommand extends Command { @@ -22,6 +23,14 @@ abstract class InstallCommand extends Command protected ComposerPackageInstaller $composerPackageInstaller; protected NodePackageInstaller $nodePackageInstaller; + protected function configure(): void + { + parent::configure(); + + $this->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing files'); + $this->addOption('filter', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Filter publishable files by target path'); + } + public function handle( FileInstaller $fileInstaller, ComposerPackageInstaller $composerPackageInstaller, @@ -49,27 +58,30 @@ protected function installFiles(): void { $fileCollection = collect($this->publishableFiles()); $force = (bool) $this->option('force'); + $filters = $this->option('filter'); $publishResults = []; $this->info("🗄 Installing files..."); $fileCollection ->filter(fn ($publishableFile) => $publishableFile instanceof PublishableFile) - ->each(function (PublishableFile $publishableFile) use ($force, &$publishResults) { + ->each(function (PublishableFile $publishableFile) use ($force, $filters, &$publishResults) { $publishResults[] = $this->fileInstaller->publishFile( path: $publishableFile->path, target: $publishableFile->target, - force: $force + force: $force, + filters: $filters, ); }); $fileCollection ->filter(fn ($publishableFile) => $publishableFile instanceof PublishableFolder) - ->each(function (PublishableFolder $publishableFolder) use ($force, &$publishResults) { + ->each(function (PublishableFolder $publishableFolder) use ($force, $filters, &$publishResults) { $folderPublishResults = $this->fileInstaller->publishFolder( path: $publishableFolder->path, target: $publishableFolder->target, - force: $force + force: $force, + filters: $filters, ); $publishResults = [...$publishResults, ...$folderPublishResults]; @@ -77,12 +89,13 @@ protected function installFiles(): void $fileCollection ->filter(fn ($publishableFile) => $publishableFile instanceof AppendableFile) - ->each(function (AppendableFile $appendableFile) use ($force, &$publishResults) { + ->each(function (AppendableFile $appendableFile) use ($force, $filters, &$publishResults) { $publishResults[] = $this->fileInstaller->appendToFile( path: $appendableFile->path, target: $appendableFile->target, search: $appendableFile->search, - force: $force + force: $force, + filters: $filters, ); }); diff --git a/src/Installers/FileInstaller.php b/src/Installers/FileInstaller.php index 931c9e8..b4b50d8 100644 --- a/src/Installers/FileInstaller.php +++ b/src/Installers/FileInstaller.php @@ -12,8 +12,12 @@ public function __construct( protected Filesystem $filesystem, ) {} - public function publishFile(string $path, string $target, bool $force = true): PublishResult + public function publishFile(string $path, string $target, bool $force = true, array $filters = []): PublishResult { + if (! $this->matchesFilters($target, $filters)) { + return PublishResult::skipped(path: $path, target: $target); + } + if ($this->filesystem->exists($target) && !$force) { return PublishResult::skipped(path: $path, target: $target); } @@ -31,24 +35,28 @@ public function publishFile(string $path, string $target, bool $force = true): P } /** @return array */ - public function publishFolder(string $path, string $target, bool $force = true): array + public function publishFolder(string $path, string $target, bool $force = true, array $filters = []): array { $sourceDirectory = rtrim($path, DIRECTORY_SEPARATOR); $targetDirectory = rtrim($target, DIRECTORY_SEPARATOR); return collect($this->filesystem->allFiles($sourceDirectory)) - ->map(function (SplFileInfo $sourceFile) use ($sourceDirectory, $targetDirectory, $force) { + ->map(function (SplFileInfo $sourceFile) use ($sourceDirectory, $targetDirectory, $force, $filters) { $sourcePath = $sourceFile->getPathname(); $relativePath = ltrim(str_replace($sourceDirectory, '', $sourcePath), DIRECTORY_SEPARATOR); $targetPath = $targetDirectory . DIRECTORY_SEPARATOR . $relativePath; - return $this->publishFile($sourcePath, $targetPath, $force); + return $this->publishFile($sourcePath, $targetPath, $force, $filters); }) ->all(); } - public function appendToFile(string $path, string $target, ?string $search, bool $force = true): PublishResult + public function appendToFile(string $path, string $target, ?string $search, bool $force = true, array $filters = []): PublishResult { + if (! $this->matchesFilters($target, $filters)) { + return PublishResult::skipped(path: $path, target: $target); + } + $contentToAppend = $this->filesystem->get($path); if (!$this->filesystem->exists($target)) { @@ -99,4 +107,15 @@ public function fileContainsString(string $path, ?string $search): bool needle: $search ?? '' ); } + + private function matchesFilters(string $target, array $filters): bool + { + if (empty($filters)) { + return true; + } + + return collect($filters)->contains( + fn ($filter) => str_contains(strtolower($target), strtolower($filter)) + ); + } } diff --git a/tests/InstallCommandTest.php b/tests/InstallCommandTest.php index 895c59c..4c4f839 100644 --- a/tests/InstallCommandTest.php +++ b/tests/InstallCommandTest.php @@ -251,4 +251,81 @@ public function it_shows_publish_overview_for_skipped_files(): void $command->expectsOutput('📄 Publish overview: 0 published, 4 skipped.'); } -} \ No newline at end of file + + #[Test] + public function it_can_filter_publishable_files_by_target_path(): void + { + $this->artisan(InstallCommand::class, ['--force' => true, '--filter' => ['Services']]) + ->expectsOutput('📄 Publish overview: 1 published, 3 skipped.') + ->assertExitCode(0); + + $this->assertFileExists(app_path('Services/UserService.php')); + } + + #[Test] + public function it_can_filter_publishable_folders_by_target_path(): void + { + $this->artisan(InstallCommand::class, ['--force' => true, '--filter' => ['resources']]) + ->expectsOutput('📄 Publish overview: 2 published, 2 skipped.') + ->assertExitCode(0); + + $this->assertFileExists(base_path('resources/vendor/stubs/js/app.js')); + $this->assertFileExists(base_path('resources/vendor/stubs/views/layouts/app.blade.php')); + } + + #[Test] + public function it_can_filter_individual_files_inside_publishable_folders(): void + { + File::deleteDirectory(base_path('resources/vendor/stubs')); + + $this->artisan(InstallCommand::class, ['--force' => true, '--filter' => ['js']]) + ->expectsOutput('📄 Publish overview: 1 published, 3 skipped.') + ->assertExitCode(0); + + $this->assertFileExists(base_path('resources/vendor/stubs/js/app.js')); + $this->assertFileDoesNotExist(base_path('resources/vendor/stubs/views/layouts/app.blade.php')); + } + + #[Test] + public function it_can_filter_appendable_files_by_target_path(): void + { + File::delete(app_path('Models/User.php')); + + $this->artisan(InstallCommand::class, ['--force' => true, '--filter' => ['Models']]) + ->expectsOutput('📄 Publish overview: 1 published, 3 skipped.') + ->assertExitCode(0); + + $this->assertFileExists(app_path('Models/User.php')); + } + + #[Test] + public function it_shows_skipped_when_no_files_match_the_filter(): void + { + $this->artisan(InstallCommand::class, ['--filter' => ['NonExistentPath']]) + ->expectsOutput('📄 Publish overview: 0 published, 4 skipped.') + ->assertExitCode(0); + } + + #[Test] + public function it_performs_case_insensitive_filtering(): void + { + $this->artisan(InstallCommand::class, ['--force' => true, '--filter' => ['services']]) + ->expectsOutput('📄 Publish overview: 1 published, 3 skipped.') + ->assertExitCode(0); + + $this->assertFileExists(app_path('Services/UserService.php')); + } + + #[Test] + public function it_can_filter_with_multiple_values(): void + { + File::delete(app_path('Models/User.php')); + + $this->artisan(InstallCommand::class, ['--force' => true, '--filter' => ['Services', 'Models']]) + ->expectsOutput('📄 Publish overview: 2 published, 2 skipped.') + ->assertExitCode(0); + + $this->assertFileExists(app_path('Services/UserService.php')); + $this->assertFileExists(app_path('Models/User.php')); + } +} diff --git a/tests/Support/InstallCommand.php b/tests/Support/InstallCommand.php index 80fd631..9696b0c 100644 --- a/tests/Support/InstallCommand.php +++ b/tests/Support/InstallCommand.php @@ -11,7 +11,7 @@ class InstallCommand extends BaseInstallCommand { - protected $signature = 'app:install-command {--force : Overwrite existing files}'; + protected $signature = 'app:install-command'; protected function publishableFiles(): array {