From 845e08b1d2fbee5e3e232d0cc82d54febf947f8d Mon Sep 17 00:00:00 2001 From: xentixar Date: Sat, 31 Oct 2026 13:41:08 +0545 Subject: [PATCH 1/2] Fix vendor:publish dumping all assets for unknown tags. When --tag did not match a launcher's publish groups, pathsToPublish fell through to every registered path, so other packages' configs were published alongside the requested tag. --- src/Phaseolies/Launchers/ServiceLauncher.php | 5 +++- tests/Launchers/ServiceLauncherTest.php | 27 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Phaseolies/Launchers/ServiceLauncher.php b/src/Phaseolies/Launchers/ServiceLauncher.php index 8b56be39..1fb2ef42 100644 --- a/src/Phaseolies/Launchers/ServiceLauncher.php +++ b/src/Phaseolies/Launchers/ServiceLauncher.php @@ -213,11 +213,14 @@ public function pathsToPublish(string|null $provider = null, string|null $group protected function pathsForProviderOrGroup(string|null $provider, string|null $group): ?array { if ($provider && $group) { - return $this->publishGroups[$group] ?? null; + return $this->publishGroups[$group] ?? []; } elseif ($group && isset($this->publishGroups[$group])) { return $this->publishGroups[$group]; } elseif ($provider && isset($this->publishes[$provider])) { return [$provider => $this->publishes[$provider]]; + } elseif ($group || $provider) { + // Unknown tag/launcher must not fall through to "publish everything". + return []; } return null; diff --git a/tests/Launchers/ServiceLauncherTest.php b/tests/Launchers/ServiceLauncherTest.php index b1ac6a94..8333e348 100644 --- a/tests/Launchers/ServiceLauncherTest.php +++ b/tests/Launchers/ServiceLauncherTest.php @@ -209,6 +209,33 @@ public function testPathsToPublishWithGroupOnly() $this->assertEquals(['group-path'], $paths); } + public function testPathsToPublishWithUnknownGroupReturnsEmpty() + { + $reflection = new \ReflectionClass($this->provider); + $publishesProperty = $reflection->getProperty('publishes'); + $publishGroupsProperty = $reflection->getProperty('publishGroups'); + + $publishesProperty->setValue($this->provider, ['other' => 'other-path']); + $publishGroupsProperty->setValue($this->provider, [ + 'config' => ['insight.php' => 'runtime/config/insight.php'], + ]); + + $paths = $this->provider->pathsToPublish(null, 'missing-tag'); + $this->assertSame([], $paths); + } + + public function testPathsToPublishWithUnknownProviderAndGroupReturnsEmpty() + { + $reflection = new \ReflectionClass($this->provider); + $publishGroupsProperty = $reflection->getProperty('publishGroups'); + $publishGroupsProperty->setValue($this->provider, [ + 'config' => ['insight.php' => 'runtime/config/insight.php'], + ]); + + $paths = $this->provider->pathsToPublish('SomeLauncher', 'missing-tag'); + $this->assertSame([], $paths); + } + public function testPathsToPublishWithProviderOnly() { $reflection = new \ReflectionClass($this->provider); From 1ad86ef126281fcd370594ecc95d006c47462a1c Mon Sep 17 00:00:00 2001 From: techmahedy Date: Sat, 31 Oct 2026 19:59:18 +0600 Subject: [PATCH 2/2] Fix vendor:publish --launcher publishing nothing and give tag/launcher publishing per-file output --- .../Console/Commands/VendorPublishCommand.php | 390 +++++++++++++++--- src/Phaseolies/Launchers/ServiceLauncher.php | 21 +- tests/Console/CommandBehaviorCoverageTest.php | 273 +++++++++++- tests/Launchers/ServiceLauncherTest.php | 34 ++ 4 files changed, 651 insertions(+), 67 deletions(-) diff --git a/src/Phaseolies/Console/Commands/VendorPublishCommand.php b/src/Phaseolies/Console/Commands/VendorPublishCommand.php index 6a140b18..f4531b5f 100644 --- a/src/Phaseolies/Console/Commands/VendorPublishCommand.php +++ b/src/Phaseolies/Console/Commands/VendorPublishCommand.php @@ -7,6 +7,7 @@ use Phaseolies\Console\Schedule\Command; use Phaseolies\Application; use FilesystemIterator; +use Symfony\Component\Console\Formatter\OutputFormatter; class VendorPublishCommand extends Command { @@ -26,6 +27,27 @@ class VendorPublishCommand extends Command protected Application $app; + /** + * Files copied during the current run. + * + * @var int + */ + protected int $published = 0; + + /** + * Existing files left untouched during the current run. + * + * @var int + */ + protected int $skipped = 0; + + /** + * Files that could not be copied during the current run. + * + * @var int + */ + protected int $failed = 0; + public function __construct() { parent::__construct(); @@ -40,24 +62,45 @@ public function __construct() public function handle(): int { return $this->executeWithTiming(function () { + $this->published = $this->skipped = $this->failed = 0; + $launcher = $this->option('launcher'); - $tag = $this->option('tag'); + $tags = $this->parseTags($this->option('tag')); $force = (bool) $this->option('force'); - if ($launcher) { - $this->publishLauncher($launcher, $force); - return Command::SUCCESS; - } - - if ($tag) { - $this->publishTag($tag, $force); - return Command::SUCCESS; + if ($launcher || !empty($tags)) { + return $this->publishSelection($launcher ?: null, $tags, $force); } return $this->publishInteractively($force); }); } + /** + * Split the --tag value into a list of unique tag names. + * + * Accepts a single tag or a comma separated list: --tag=a,b + * + * @param mixed $value + * @return array + */ + protected function parseTags(mixed $value): array + { + $tags = []; + + foreach ((array) $value as $item) { + foreach (explode(',', (string) $item) as $tag) { + $tag = trim($tag); + + if ($tag !== '') { + $tags[$tag] = $tag; + } + } + } + + return array_values($tags); + } + /** * Prompt the user to choose which registered launcher to publish, * falling back to publishing everything in non-interactive environments. @@ -75,8 +118,7 @@ protected function publishInteractively(bool $force): int } if (!$this->isInteractive()) { - $this->publishAll($force); - return Command::SUCCESS; + return $this->publishAll($force); } $choices = array_merge(['All'], $launchers); @@ -84,13 +126,10 @@ protected function publishInteractively(bool $force): int $choice = $this->choice('Which launcher would you like to publish?', $choices, 'All'); if ($choice === 'All') { - $this->publishAll($force); - return Command::SUCCESS; + return $this->publishAll($force); } - $this->publishLauncher($choice, $force); - - return Command::SUCCESS; + return $this->publishSelection($choice, [], $force); } /** @@ -111,57 +150,228 @@ protected function publishableLaunchers(): array return $launchers; } - protected function publishLauncher(string $launcher, bool $force = false) + /** + * Publish what the --launcher and/or --tag options select. + * + * @param string|null $launcher + * @param array $tags + * @param bool $force + * @return int + */ + protected function publishSelection(?string $launcher, array $tags, bool $force = false): int { - $launcherClass = $this->app->getLauncher($launcher); + $launchers = $this->app->getLaunchers(); - if (!$launcherClass) { - $this->displayError("Unable to locate launcher: {$launcher}"); - return; + if ($launcher !== null) { + $instance = $this->app->getLauncher($launcher); + + if (!$instance) { + $this->displayError("Unable to locate launcher: {$launcher}"); + $this->listPublishableLaunchers(); + + return Command::FAILURE; + } + + $launchers = [$instance]; } - $paths = $launcherClass->pathsToPublish($launcher); - $this->publishPaths($paths, $force); + $batches = []; - $this->newLine(); - $this->displaySuccess("Published assets for launcher: {$launcher}"); - } + if (empty($tags)) { + $batches[] = ["launcher [{$launcher}]", $launchers[0]->pathsToPublish()]; + } else { + $missing = []; - protected function publishTag(string $tag, bool $force = false) - { - $paths = []; + foreach ($tags as $tag) { + $paths = []; - foreach ($this->app->getLaunchers() as $launcher) { - $providerPaths = $launcher->pathsToPublish(null, $tag); - if (!empty($providerPaths)) { - $paths = array_merge($paths, $providerPaths); + foreach ($launchers as $candidate) { + $paths = array_merge($paths, $candidate->pathsToPublish(null, $tag)); + } + + if (empty($paths)) { + $missing[] = $tag; + continue; + } + + $batches[] = ["tag [{$tag}]", $paths]; + } + + if (!empty($missing)) { + $this->reportUnknownTags($missing, $launchers, $launcher); + + return Command::FAILURE; } } - if (empty($paths)) { - $this->displayError("Unable to locate tag: {$tag}"); - return; + $batches = array_values(array_filter($batches, fn(array $batch) => !empty($batch[1]))); + + if (empty($batches)) { + $this->displayWarning("Launcher [{$launcher}] has nothing to publish."); + + return Command::SUCCESS; } - $this->publishPaths($paths, $force); + $this->publishBatches($batches, $force); - $this->newLine(); - $this->displaySuccess("Published assets for tag: {$tag}"); + $scope = count($batches) === 1 + ? 'for ' . $batches[0][0] + : 'for ' . count($batches) . ' tags'; + + return $this->summarize($scope); } - protected function publishAll(bool $force = false) + /** + * Publish every launcher's registered paths. + * + * @param bool $force + * @return int + */ + protected function publishAll(bool $force = false): int { - $publishedCount = 0; + $batches = []; + foreach ($this->app->getLaunchers() as $launcher) { $paths = $launcher->pathsToPublish(); + if (!empty($paths)) { - $this->publishPaths($paths, $force); - $publishedCount++; + $batches[] = ['launcher [' . get_class($launcher) . ']', $paths]; } } + $this->publishBatches($batches, $force); + + $count = count($batches); + + return $this->summarize('from ' . $count . ' ' . ($count === 1 ? 'launcher' : 'launchers')); + } + + /** + * Publish each labelled group of paths under its own heading. + * + * @param array $batches + * @param bool $force + * @return void + */ + protected function publishBatches(array $batches, bool $force = false): void + { + foreach ($batches as [$label, $paths]) { + $this->line('Publishing ' . OutputFormatter::escape($label) . ''); + $this->publishPaths($paths, $force); + $this->newLine(); + } + } + + /** + * Print the final result and return the matching exit code. + * + * @param string $scope + * @return int + */ + protected function summarize(string $scope): int + { + if ($this->failed > 0) { + $this->displayError($this->countOf($this->failed) . " could not be published {$scope}."); + + return Command::FAILURE; + } + + if ($this->published > 0) { + $message = 'Published ' . $this->countOf($this->published) . " {$scope}."; + + if ($this->skipped > 0) { + $message .= " Skipped {$this->skipped} existing (use --force to overwrite)."; + } + + $this->displaySuccess($message); + + return Command::SUCCESS; + } + + if ($this->skipped > 0) { + $this->displayInfo( + "Nothing published {$scope}: {$this->skipped} existing " + . ($this->skipped === 1 ? 'file was' : 'files were') + . ' left as is (use --force to overwrite).' + ); + + return Command::SUCCESS; + } + + $this->displayInfo("Nothing to publish {$scope}."); + + return Command::SUCCESS; + } + + /** + * Explain that a tag is unknown and point at the tags that do exist. + * + * @param array $missing + * @param array $launchers + * @param string|null $launcher + * @return void + */ + protected function reportUnknownTags(array $missing, array $launchers, ?string $launcher): void + { + $available = []; + + foreach ($launchers as $candidate) { + $available = array_merge($available, $candidate->publishTags()); + } + + $available = array_values(array_unique($available)); + sort($available); + + foreach ($missing as $tag) { + $where = $launcher !== null ? " in launcher [{$launcher}]" : ''; + + $this->displayError("No publishable resources found for tag [{$tag}]{$where}."); + + $similar = array_values(array_filter( + $available, + fn(string $name) => str_contains($name, $tag) || levenshtein($name, $tag) <= 3 + )); + + if (!empty($similar)) { + $this->line('Did you mean: ' . implode(', ', array_map( + [OutputFormatter::class, 'escape'], + $similar + )) . '?'); + $this->newLine(); + } + } + + if (!empty($available)) { + $this->line('Available tags:'); + + foreach ($available as $name) { + $this->line(' ' . OutputFormatter::escape($name)); + } + + $this->newLine(); + } + } + + /** + * List the launchers that have something to publish. + * + * @return void + */ + protected function listPublishableLaunchers(): void + { + $launchers = $this->publishableLaunchers(); + + if (empty($launchers)) { + return; + } + + $this->line('Publishable launchers:'); + + foreach ($launchers as $name) { + $this->line(' ' . OutputFormatter::escape($name)); + } + $this->newLine(); - $this->displaySuccess("Published assets from {$publishedCount} launchers"); } protected function publishPaths(array $paths, bool $force = false) @@ -178,8 +388,11 @@ protected function publishPaths(array $paths, bool $force = false) protected function publishDirectory(string $from, string $to, bool $force = false) { - if (!is_dir($to)) { - mkdir($to, 0755, true); + if (!is_dir($to) && !@mkdir($to, 0755, true) && !is_dir($to)) { + $this->report('FAILED', 'red', "cannot create directory {$this->displayPath($to)}"); + $this->failed++; + + return; } $iterator = new RecursiveIteratorIterator( @@ -191,29 +404,96 @@ protected function publishDirectory(string $from, string $to, bool $force = fals $target = $to . DIRECTORY_SEPARATOR . $iterator->getSubPathName(); if ($item->isDir()) { - if (!is_dir($target)) { - mkdir($target, 0755); + if (!is_dir($target) && !@mkdir($target, 0755, true) && !is_dir($target)) { + $this->report('FAILED', 'red', "cannot create directory {$this->displayPath($target)}"); + $this->failed++; } } else { $this->publishFile($item->getPathname(), $target, $force); } } - $this->newLine(); } protected function publishFile(string $from, string $to, bool $force = false) { - if (file_exists($to) && !$force) { - $this->displayWarning("Skipping: File already exists at {$to}"); + if (!is_file($from)) { + $this->report('MISSING', 'red', "source file not found: {$this->displayPath($from)}"); + $this->failed++; + + return; + } + + $exists = file_exists($to); + + if ($exists && !$force) { + $this->report('SKIPPED', 'yellow', $this->displayPath($to) . ' already exists'); + $this->skipped++; + return; } $directory = dirname($to); - if (!is_dir($directory)) { - mkdir($directory, 0755, true); + + if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) { + $this->report('FAILED', 'red', "cannot create directory {$this->displayPath($directory)}"); + $this->failed++; + + return; } - copy($from, $to); - $this->line("→ {$to}"); + if (!@copy($from, $to)) { + $this->report('FAILED', 'red', "cannot write {$this->displayPath($to)}"); + $this->failed++; + + return; + } + + $this->report( + $exists ? 'OVERWRITTEN' : 'COPIED', + 'green', + $this->displayPath($from) . ' → ' . $this->displayPath($to) + ); + $this->published++; + } + + /** + * Write one aligned status line for a file. + * + * @param string $label + * @param string $color + * @param string $message + * @return void + */ + protected function report(string $label, string $color, string $message): void + { + $this->line(sprintf(' %s %s', $color, str_pad($label, 11), $message)); + } + + /** + * Show a path relative to the application root and safe for console markup. + * + * @param string $path + * @return string + */ + protected function displayPath(string $path): string + { + $base = rtrim(base_path(), '/\\') . DIRECTORY_SEPARATOR; + + if (str_starts_with($path, $base)) { + $path = substr($path, strlen($base)); + } + + return OutputFormatter::escape($path); + } + + /** + * Format a file count, e.g. "1 file" or "3 files". + * + * @param int $count + * @return string + */ + protected function countOf(int $count): string + { + return $count . ' ' . ($count === 1 ? 'file' : 'files'); } } diff --git a/src/Phaseolies/Launchers/ServiceLauncher.php b/src/Phaseolies/Launchers/ServiceLauncher.php index 1fb2ef42..a7ff8e0d 100644 --- a/src/Phaseolies/Launchers/ServiceLauncher.php +++ b/src/Phaseolies/Launchers/ServiceLauncher.php @@ -203,6 +203,16 @@ public function pathsToPublish(string|null $provider = null, string|null $group return array_merge($this->publishes, $this->publishGroups); } + /** + * Get the tags (groups) this launcher registered publishable paths for. + * + * @return array + */ + public function publishTags(): array + { + return array_map('strval', array_keys($this->publishGroups)); + } + /** * Get the paths for the provider or group. * @@ -212,15 +222,12 @@ public function pathsToPublish(string|null $provider = null, string|null $group */ protected function pathsForProviderOrGroup(string|null $provider, string|null $group): ?array { - if ($provider && $group) { + if ($group) { return $this->publishGroups[$group] ?? []; - } elseif ($group && isset($this->publishGroups[$group])) { - return $this->publishGroups[$group]; - } elseif ($provider && isset($this->publishes[$provider])) { + } + + if ($provider && isset($this->publishes[$provider])) { return [$provider => $this->publishes[$provider]]; - } elseif ($group || $provider) { - // Unknown tag/launcher must not fall through to "publish everything". - return []; } return null; diff --git a/tests/Console/CommandBehaviorCoverageTest.php b/tests/Console/CommandBehaviorCoverageTest.php index ee15df56..0f7327cc 100644 --- a/tests/Console/CommandBehaviorCoverageTest.php +++ b/tests/Console/CommandBehaviorCoverageTest.php @@ -843,7 +843,11 @@ public function testVendorPublishCommandCopiesFilesAndSkipsExistingTargets(): vo $this->invokeMethod($command, 'publishFile', [$sourceFile, $targetFile, false]); $this->assertFileExists($targetFile); - $this->assertContains('Skipping: File already exists at ' . $targetFile, $command->capturedWarnings); + $output = $this->capturedOutput($command); + $this->assertStringContainsString('COPIED', $output); + $this->assertStringContainsString('SKIPPED', $output); + $this->assertStringContainsString('published/config/demo.php', str_replace('\\', '/', $output)); + $this->assertStringContainsString('already exists', $output); } public function testVendorPublishCommandWarnsWhenNoLaunchersArePublishable(): void @@ -881,7 +885,7 @@ public function testVendorPublishCommandFallsBackToPublishingAllWhenNonInteracti $this->assertSame(0, $result); $this->assertEmpty($command->capturedChoiceQuestions); - $this->assertContains('Published assets from 1 launchers', $command->capturedSuccesses); + $this->assertContains('Published 1 file from 1 launcher.', $command->capturedSuccesses); } public function testVendorPublishCommandPublishesAllWhenChoiceIsAll(): void @@ -905,7 +909,7 @@ public function testVendorPublishCommandPublishesAllWhenChoiceIsAll(): void $this->assertSame('All', $command->capturedChoiceQuestions[0]['default']); $this->assertContains(get_class($launcherA), $command->capturedChoiceQuestions[0]['choices']); $this->assertContains(get_class($launcherB), $command->capturedChoiceQuestions[0]['choices']); - $this->assertContains('Published assets from 2 launchers', $command->capturedSuccesses); + $this->assertContains('Published 2 files from 2 launchers.', $command->capturedSuccesses); } public function testVendorPublishCommandPublishesOnlyTheChosenLauncher(): void @@ -928,8 +932,267 @@ public function testVendorPublishCommandPublishesOnlyTheChosenLauncher(): void $result = $command->handle(); $this->assertSame(0, $result); - $this->assertContains('Published assets for launcher: ' . get_class($launcherA), $command->capturedSuccesses); - $this->assertNotContains('Published assets for launcher: ' . get_class($launcherB), $command->capturedSuccesses); + $this->assertContains('Published 1 file for launcher [' . get_class($launcherA) . '].', $command->capturedSuccesses); + $this->assertFileExists(Env::path('published/alpha/config.php')); + $this->assertFileDoesNotExist(Env::path('published/beta/config.php')); + } + + public function testVendorPublishTagPublishesOnlyTheMatchingGroupAndPrintsNoOtherPackage(): void + { + [$command, $alpha, $beta] = $this->makeTaggedCommand(); + $command->fakeOptions['tag'] = 'alpha-config'; + + $result = $command->handle(); + + $output = $this->capturedOutput($command); + $this->assertSame(0, $result); + $this->assertFileExists(Env::path('published/alpha/config.php')); + $this->assertFileDoesNotExist(Env::path('published/beta/config.php')); + $this->assertStringContainsString('alpha/config.php', str_replace('\\', '/', $output)); + $this->assertStringNotContainsString('beta', $output); + $this->assertContains('Published 1 file for tag [alpha-config].', $command->capturedSuccesses); + } + + public function testVendorPublishUnknownTagFailsWritesNothingAndSuggestsSimilarTags(): void + { + [$command] = $this->makeTaggedCommand(); + $command->fakeOptions['tag'] = 'alpha'; + + $result = $command->handle(); + + $output = $this->capturedOutput($command); + $this->assertSame(1, $result); + $this->assertContains('No publishable resources found for tag [alpha].', $command->capturedErrors); + $this->assertStringContainsString('Did you mean', $output); + $this->assertStringContainsString('alpha-config', $output); + $this->assertSame([], $command->capturedSuccesses); + $this->assertFileDoesNotExist(Env::path('published/alpha/config.php')); + $this->assertFileDoesNotExist(Env::path('published/beta/config.php')); + } + + public function testVendorPublishUnknownTagWithNoSimilarNameListsAvailableTags(): void + { + [$command] = $this->makeTaggedCommand(); + $command->fakeOptions['tag'] = 'zzzzzzzzzz'; + + $result = $command->handle(); + + $output = $this->capturedOutput($command); + $this->assertSame(1, $result); + $this->assertStringNotContainsString('Did you mean', $output); + $this->assertStringContainsString('Available tags:', $output); + $this->assertStringContainsString('alpha-config', $output); + $this->assertStringContainsString('beta-config', $output); + } + + public function testVendorPublishCommaSeparatedTagsPublishEachTag(): void + { + [$command] = $this->makeTaggedCommand(); + $command->fakeOptions['tag'] = 'alpha-config, beta-config'; + + $result = $command->handle(); + + $this->assertSame(0, $result); + $this->assertFileExists(Env::path('published/alpha/config.php')); + $this->assertFileExists(Env::path('published/beta/config.php')); + $this->assertContains('Published 2 files for 2 tags.', $command->capturedSuccesses); + } + + public function testVendorPublishOneUnknownTagAmongValidOnesPublishesNothing(): void + { + [$command] = $this->makeTaggedCommand(); + $command->fakeOptions['tag'] = 'alpha-config,nope'; + + $result = $command->handle(); + + $this->assertSame(1, $result); + $this->assertFileDoesNotExist(Env::path('published/alpha/config.php')); + } + + public function testVendorPublishLauncherPublishesEverythingItRegisteredIncludingGroups(): void + { + [$command, $alpha] = $this->makeTaggedCommand(); + $command->fakeOptions['launcher'] = get_class($alpha); + + $result = $command->handle(); + + $this->assertSame(0, $result); + $this->assertFileExists(Env::path('published/alpha/config.php')); + $this->assertFileExists(Env::path('published/alpha/plain.php')); + $this->assertFileDoesNotExist(Env::path('published/beta/config.php')); + $this->assertContains( + 'Published 2 files for launcher [' . get_class($alpha) . '].', + $command->capturedSuccesses + ); + } + + public function testVendorPublishLauncherWithTagPublishesThatTagFromThatLauncherOnly(): void + { + [$command, $alpha] = $this->makeTaggedCommand(); + $command->fakeOptions['launcher'] = get_class($alpha); + $command->fakeOptions['tag'] = 'alpha-config'; + + $result = $command->handle(); + + $this->assertSame(0, $result); + $this->assertFileExists(Env::path('published/alpha/config.php')); + $this->assertFileDoesNotExist(Env::path('published/alpha/plain.php')); + } + + public function testVendorPublishLauncherWithATagItDoesNotOwnFails(): void + { + [$command, $alpha] = $this->makeTaggedCommand(); + $command->fakeOptions['launcher'] = get_class($alpha); + $command->fakeOptions['tag'] = 'beta-config'; + + $result = $command->handle(); + + $this->assertSame(1, $result); + $this->assertFileDoesNotExist(Env::path('published/beta/config.php')); + $this->assertStringContainsString('in launcher [' . get_class($alpha) . ']', $command->capturedErrors[0]); + } + + public function testVendorPublishUnknownLauncherFailsAndListsPublishableLaunchers(): void + { + [$command, $alpha] = $this->makeTaggedCommand(); + $command->fakeOptions['launcher'] = 'App\\Nope\\Launcher'; + + $result = $command->handle(); + + $this->assertSame(1, $result); + $this->assertContains('Unable to locate launcher: App\\Nope\\Launcher', $command->capturedErrors); + $this->assertStringContainsString(get_class($alpha), $this->capturedOutput($command)); + $this->assertSame([], $command->capturedSuccesses); + } + + public function testVendorPublishSkipsExistingFilesUnlessForced(): void + { + [$command, $alpha] = $this->makeTaggedCommand(); + $command->fakeOptions['tag'] = 'alpha-config'; + $target = Env::path('published/alpha/config.php'); + mkdir(dirname($target), 0755, true); + file_put_contents($target, 'user edited'); + + $this->assertSame(0, $command->handle()); + $this->assertSame('user edited', file_get_contents($target)); + $this->assertStringContainsString('Nothing published', $command->capturedInfos[0]); + $this->assertStringContainsString('--force', $command->capturedInfos[0]); + + [$forced] = $this->makeTaggedCommand(); + $forced->fakeOptions['tag'] = 'alpha-config'; + $forced->fakeOptions['force'] = true; + + $this->assertSame(0, $forced->handle()); + $this->assertSame('assertStringContainsString('OVERWRITTEN', $this->capturedOutput($forced)); + } + + public function testVendorPublishReportsSkippedCountNextToPublishedCount(): void + { + [$command] = $this->makeTaggedCommand(); + $command->fakeOptions['launcher'] = FakePublishableLauncherAlpha::class; + $existing = Env::path('published/alpha/plain.php'); + mkdir(dirname($existing), 0755, true); + file_put_contents($existing, 'keep'); + + $this->assertSame(0, $command->handle()); + $this->assertSame('keep', file_get_contents($existing)); + $this->assertStringContainsString( + 'Published 1 file for launcher [' . FakePublishableLauncherAlpha::class . ']. Skipped 1 existing', + $command->capturedSuccesses[0] + ); + } + + public function testVendorPublishMissingSourceFileFailsInsteadOfPrintingAFalseSuccess(): void + { + $appStub = $this->createStub(Application::class); + $launcher = new FakePublishableLauncherAlpha($appStub); + $launcher->publishes([Env::path('vendor/gone.php') => Env::path('published/gone.php')], 'gone'); + $appStub->method('getLaunchers')->willReturn([$launcher]); + Env::$appInstance = $appStub; + + $command = new class extends VendorPublishCommand + { + use InteractsWithFakeCommandIO; + }; + $command->fakeOptions['tag'] = 'gone'; + + $result = $command->handle(); + + $this->assertSame(1, $result); + $this->assertStringContainsString('MISSING', $this->capturedOutput($command)); + $this->assertFileDoesNotExist(Env::path('published/gone.php')); + $this->assertSame([], $command->capturedSuccesses); + } + + public function testVendorPublishDirectoryTagCopiesNestedFiles(): void + { + $appStub = $this->createStub(Application::class); + $launcher = new FakePublishableLauncherAlpha($appStub); + $sourceDir = Env::path('vendor/pkg/assets'); + mkdir($sourceDir . '/css', 0755, true); + file_put_contents($sourceDir . '/app.js', 'js'); + file_put_contents($sourceDir . '/css/app.css', 'css'); + $launcher->publishes([$sourceDir => Env::path('public/vendor/pkg')], 'pkg-assets'); + $appStub->method('getLaunchers')->willReturn([$launcher]); + Env::$appInstance = $appStub; + + $command = new class extends VendorPublishCommand + { + use InteractsWithFakeCommandIO; + }; + $command->fakeOptions['tag'] = 'pkg-assets'; + + $this->assertSame(0, $command->handle()); + $this->assertSame('js', file_get_contents(Env::path('public/vendor/pkg/app.js'))); + $this->assertSame('css', file_get_contents(Env::path('public/vendor/pkg/css/app.css'))); + $this->assertContains('Published 2 files for tag [pkg-assets].', $command->capturedSuccesses); + } + + /** + * Two launchers: alpha registers a tagged config plus an untagged file, beta a tagged config. + * + * @return array{0: VendorPublishCommand, 1: ServiceLauncher, 2: ServiceLauncher} + */ + private function makeTaggedCommand(): array + { + $appStub = $this->createStub(Application::class); + + $alpha = new FakePublishableLauncherAlpha($appStub); + $beta = new FakePublishableLauncherBeta($appStub); + + foreach ([['alpha', $alpha, 'alpha-config'], ['beta', $beta, 'beta-config']] as [$label, $launcher, $tag]) { + $from = Env::path("vendor/{$label}/config.php"); + @mkdir(dirname($from), 0755, true); + file_put_contents($from, 'publishes([$from => Env::path("published/{$label}/config.php")], $tag); + } + + $plain = Env::path('vendor/alpha/plain.php'); + file_put_contents($plain, 'publishes([$plain => Env::path('published/alpha/plain.php')]); + + $appStub->method('getLaunchers')->willReturn([$alpha, $beta]); + $appStub->method('getLauncher')->willReturnCallback( + fn(string $class) => match ($class) { + get_class($alpha) => $alpha, + get_class($beta) => $beta, + default => null, + } + ); + Env::$appInstance = $appStub; + + $command = new class extends VendorPublishCommand + { + use InteractsWithFakeCommandIO; + }; + + return [$command, $alpha, $beta]; + } + + private function capturedOutput(object $command): string + { + return implode("\n", array_map(fn(array $line) => $line[0], $command->capturedLines)); } private function makeFakePublishableLauncher(object $app, string $label, string $className = FakePublishableLauncherAlpha::class): ServiceLauncher diff --git a/tests/Launchers/ServiceLauncherTest.php b/tests/Launchers/ServiceLauncherTest.php index 8333e348..96e6711a 100644 --- a/tests/Launchers/ServiceLauncherTest.php +++ b/tests/Launchers/ServiceLauncherTest.php @@ -236,6 +236,40 @@ public function testPathsToPublishWithUnknownProviderAndGroupReturnsEmpty() $this->assertSame([], $paths); } + public function testPathsToPublishWithLauncherClassNameStillReturnsEverythingRegistered() + { + $this->provider->publishes(['/src/a.php' => '/dest/a.php']); + $this->provider->publishes(['/src/b.php' => '/dest/b.php'], 'config'); + + // vendor:publish --launcher= passes the class name as the provider. + $paths = $this->provider->pathsToPublish(TestServiceLauncher::class); + + $this->assertSame([ + '/src/a.php' => '/dest/a.php', + 'config' => ['/src/b.php' => '/dest/b.php'], + ], $paths); + } + + public function testPathsToPublishWithKnownGroupReturnsOnlyThatGroup() + { + $this->provider->publishes(['/src/a.php' => '/dest/a.php']); + $this->provider->publishes(['/src/b.php' => '/dest/b.php'], 'config'); + + $this->assertSame(['/src/b.php' => '/dest/b.php'], $this->provider->pathsToPublish(null, 'config')); + $this->assertSame([], $this->provider->pathsToPublish(null, 'other')); + } + + public function testPublishTagsListsRegisteredGroupsOnly() + { + $this->assertSame([], $this->provider->publishTags()); + + $this->provider->publishes(['/src/a.php' => '/dest/a.php']); + $this->provider->publishes(['/src/b.php' => '/dest/b.php'], ['config', 'shared']); + $this->provider->publishes(['/src/c.php' => '/dest/c.php'], 'assets'); + + $this->assertSame(['config', 'shared', 'assets'], $this->provider->publishTags()); + } + public function testPathsToPublishWithProviderOnly() { $reflection = new \ReflectionClass($this->provider);