diff --git a/.github/wiki b/.github/wiki index f1a8bc31a..4782e8591 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit f1a8bc31ab0d8e09146703773fceb77157c5d2ba +Subproject commit 4782e85916e367116b8278df8be71c784a2785f5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2420afefb..b6b180812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Show the DevTools ASCII logo by default on all top-level command executions, while adding a `--no-logo` global option and automatically suppressing the banner for `--json` / `--pretty-json` invocations (including automatic forwarding of `--no-logo` to internal DevTools subprocesses) to avoid banner repetition in orchestrated command queues (#277) + ### Added - Add a configurable DevTools generated artifact workspace through `--workspace-dir` and `FAST_FORWARD_WORKSPACE_DIR`, keeping explicit output/cache command options authoritative (#274) diff --git a/src/Console/DevTools.php b/src/Console/DevTools.php index 14edf2df7..2c09e1a7b 100644 --- a/src/Console/DevTools.php +++ b/src/Console/DevTools.php @@ -47,12 +47,19 @@ */ final class DevTools extends Application { + private const array RAW_OUTPUT_COMMANDS = [ + 'changelog:next-version', + 'changelog:show', + ]; + private const string LOGO = <<<'LOGO' ____ _____ _ | _ \ _____ _|_ _|__ ___ | |___ | | | |/ _ \ \ / / | |/ _ \ / _ \| / __| | |_| | __/\ V / | | (_) | (_) | \__ \ |____/ \___| \_/ |_|\___/ \___/|_|___/ + ======================================== + LOGO; /** @@ -87,17 +94,6 @@ public function __construct( $this->setCommandLoader($commandLoader); } - /** - * Gets the help message for the DevTools application, including the ASCII logo. - * - * @return string - */ - #[Override] - public function getHelp(): string - { - return self::LOGO . "\n\n" . parent::getHelp(); - } - /** * Returns the application-level input definition with DevTools runtime options. * @@ -128,6 +124,12 @@ protected function getDefaultInputDefinition(): InputDefinition description: 'Store generated DevTools artifacts in the given directory.', )); + $definition->addOption(new InputOption( + name: 'no-logo', + mode: InputOption::VALUE_NONE, + description: 'Hide the startup ASCII logo.', + )); + return $definition; } @@ -142,6 +144,12 @@ protected function getDefaultInputDefinition(): InputDefinition #[Override] public function doRun(InputInterface $input, OutputInterface $output): int { + $shouldRenderLogo = $this->shouldRenderLogo($input); + + if ($shouldRenderLogo) { + $output->writeln(self::LOGO); + } + try { $this->workingDirectorySwitcher->switchTo($this->getWorkingDirectoryOption($input)); $this->configureWorkspaceDirectory($input); @@ -151,7 +159,7 @@ public function doRun(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - if (! $this->isSelfUpdateCommand($input)) { + if ($shouldRenderLogo && ! $this->isSelfUpdateCommand($input)) { $this->runAutoUpdateWhenRequested($input, $output); $this->versionCheckNotifier->notify($output); } @@ -248,6 +256,39 @@ private function isSelfUpdateCommand(InputInterface $input): bool return \in_array($input->getFirstArgument(), SelfUpdateCommand::getCommandNames(), true); } + /** + * Whether to show the startup ASCII logo for this invocation. + * + * @param InputInterface $input + */ + private function shouldRenderLogo(InputInterface $input): bool + { + return ! $this->isLogoSuppressedByOptions($input) + && ! $this->isRawOutputCommand($input); + } + + /** + * Detects CLI flags that explicitly suppress logo output. + * + * @param InputInterface $input + */ + private function isLogoSuppressedByOptions(InputInterface $input): bool + { + return (bool) $input->getParameterOption('--no-logo', null, true) + || (bool) $input->hasParameterOption('--json', true) + || (bool) $input->hasParameterOption('--pretty-json', true); + } + + /** + * Identifies commands that must keep CLI output unprefixed by logos. + * + * @param InputInterface $input + */ + private function isRawOutputCommand(InputInterface $input): bool + { + return \in_array((string) $input->getFirstArgument(), self::RAW_OUTPUT_COMMANDS, true); + } + /** * Interprets environment values that enable auto-update. * diff --git a/src/Process/ProcessBuilder.php b/src/Process/ProcessBuilder.php index 41e61e0da..87f828358 100644 --- a/src/Process/ProcessBuilder.php +++ b/src/Process/ProcessBuilder.php @@ -19,6 +19,7 @@ namespace FastForward\DevTools\Process; +use FastForward\DevTools\Path\DevToolsPathResolver; use Symfony\Component\Process\Process; /** @@ -31,6 +32,8 @@ */ final readonly class ProcessBuilder implements ProcessBuilderInterface { + private const string NO_LOGO_ARGUMENT = '--no-logo'; + /** * Creates a new immutable process builder instance. * @@ -94,10 +97,53 @@ public function getArguments(): array */ public function build(string|array $command): Process { + if (\is_array($command)) { + $command = array_values($command); + } + if (\is_string($command)) { $command = explode(' ', $command); } + if ($this->shouldAddLogoSuppressionArgument($command)) { + $command = $this->prependLogoSuppressionArgument($command); + } + return new Process(command: [...$command, ...$this->arguments], timeout: 0); } + + /** + * @param list $command + */ + private function shouldAddLogoSuppressionArgument(array $command): bool + { + if (\in_array(self::NO_LOGO_ARGUMENT, $this->arguments, true)) { + return false; + } + + if ([] === $command) { + return false; + } + + $binary = str_replace('\\', '/', $command[0]); + $packageBinaryPath = str_replace('\\', '/', DevToolsPathResolver::getBinaryPath()); + + return $binary === $packageBinaryPath; + } + + /** + * @param list $command + * + * @return list + */ + private function prependLogoSuppressionArgument(array $command): array + { + if ([] === $command) { + return $command; + } + + $binary = array_shift($command); + + return [$binary, self::NO_LOGO_ARGUMENT, ...$command]; + } } diff --git a/tests/Console/Command/DependenciesCommandTest.php b/tests/Console/Command/DependenciesCommandTest.php index 42c26e184..aac20c26d 100644 --- a/tests/Console/Command/DependenciesCommandTest.php +++ b/tests/Console/Command/DependenciesCommandTest.php @@ -22,6 +22,7 @@ use FastForward\DevTools\Console\Command\DependenciesCommand; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Config\ComposerDependencyAnalyserConfig; +use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Process\ProcessBuilder; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; @@ -42,6 +43,7 @@ use Symfony\Component\Process\Process; #[CoversClass(DependenciesCommand::class)] +#[UsesClass(DevToolsPathResolver::class)] #[UsesClass(ProcessBuilder::class)] #[UsesTrait(LogsCommandResults::class)] final class DependenciesCommandTest extends TestCase diff --git a/tests/Console/Command/TestsCommandTest.php b/tests/Console/Command/TestsCommandTest.php index 0c6b43548..3ed20e666 100644 --- a/tests/Console/Command/TestsCommandTest.php +++ b/tests/Console/Command/TestsCommandTest.php @@ -28,6 +28,7 @@ use FastForward\DevTools\Process\ProcessBuilder; use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -48,6 +49,7 @@ #[CoversClass(TestsCommand::class)] #[UsesClass(CoverageSummary::class)] +#[UsesClass(DevToolsPathResolver::class)] #[UsesClass(ProcessBuilder::class)] #[UsesClass(ManagedWorkspace::class)] #[UsesTrait(LogsCommandResults::class)] diff --git a/tests/Console/DevToolsTest.php b/tests/Console/DevToolsTest.php index 15f453e5f..d1767bfff 100644 --- a/tests/Console/DevToolsTest.php +++ b/tests/Console/DevToolsTest.php @@ -62,8 +62,11 @@ use Symfony\Component\Console\Command\HelpCommand; use Symfony\Component\Console\Command\ListCommand; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Output\BufferedOutput; use function Safe\putenv; @@ -215,6 +218,194 @@ public function constructorWillRegisterGlobalRuntimeOptions(): void self::assertTrue($definition->hasOption('workspace-dir')); self::assertSame('w', $definition->getOption('workspace-dir')->getShortcut()); self::assertTrue($definition->hasOption('auto-update')); + self::assertTrue($definition->hasOption('no-logo')); + self::assertFalse($definition->getOption('no-logo')->acceptValue()); + } + + /** + * @return void + */ + #[Test] + public function doRunWillRenderLogoUnlessNoLogoOptionIsProvided(): void + { + $input = new ArrayInput([ + 'command' => 'list', + ]); + + $output = new BufferedOutput(); + + $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + ->willReturn(''); + $this->workingDirectorySwitcher->switchTo(null) + ->shouldBeCalledOnce(); + $this->versionCheckNotifier->notify($output) + ->shouldBeCalledOnce(); + + $result = $this->invokeDoRun($input, $output); + + self::assertSame(Command::SUCCESS, $result); + self::assertStringContainsString('_____', $output->fetch()); + } + + /** + * @return void + */ + #[Test] + public function doRunWillNotRenderLogoWhenNoLogoOptionIsSet(): void + { + $input = new ArrayInput([ + '--no-logo' => true, + 'command' => 'list', + ]); + + $output = new BufferedOutput(); + + $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + ->willReturn(''); + $this->workingDirectorySwitcher->switchTo(null) + ->shouldBeCalledOnce(); + $this->versionCheckNotifier->notify($output) + ->shouldNotBeCalled(); + + $this->invokeDoRun($input, $output); + + self::assertStringNotContainsString('_____', $output->fetch()); + } + + /** + * @return void + */ + #[Test] + public function doRunWillNotRenderLogoWhenJsonOptionIsProvided(): void + { + $command = new class extends Command { + public function __construct() + { + parent::__construct('standards'); + } + + protected function configure(): void + { + $this->addOption(name: 'json', mode: InputOption::VALUE_NONE, description: 'Emit structured JSON output.'); + $this->setCode(static fn(InputInterface $input, OutputInterface $output): int => Command::SUCCESS); + } + }; + + $this->commandLoader->has('standards') + ->willReturn(true) + ->shouldBeCalledOnce(); + $this->commandLoader->get('standards') + ->willReturn($command) + ->shouldBeCalledOnce(); + $input = new ArrayInput([ + 'command' => 'standards', + '--json' => true, + ]); + + $output = new BufferedOutput(); + + $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + ->willReturn(''); + $this->workingDirectorySwitcher->switchTo(null) + ->shouldBeCalledOnce(); + $this->versionCheckNotifier->notify($output) + ->shouldNotBeCalled(); + + $result = $this->invokeDoRun($input, $output); + + self::assertSame(Command::SUCCESS, $result); + self::assertStringNotContainsString('_____', $output->fetch()); + } + + /** + * @return void + */ + #[Test] + public function doRunWillNotRenderLogoWhenPrettyJsonOptionIsProvided(): void + { + $command = new class extends Command { + public function __construct() + { + parent::__construct('standards'); + } + + protected function configure(): void + { + $this->addOption(name: 'pretty-json', mode: InputOption::VALUE_NONE, description: 'Emit pretty JSON output.'); + $this->setCode(static fn(InputInterface $input, OutputInterface $output): int => Command::SUCCESS); + } + }; + + $this->commandLoader->has('standards') + ->willReturn(true) + ->shouldBeCalledOnce(); + $this->commandLoader->get('standards') + ->willReturn($command) + ->shouldBeCalledOnce(); + $input = new ArrayInput([ + 'command' => 'standards', + '--pretty-json' => true, + ]); + + $output = new BufferedOutput(); + + $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + ->willReturn(''); + $this->workingDirectorySwitcher->switchTo(null) + ->shouldBeCalledOnce(); + $this->versionCheckNotifier->notify($output) + ->shouldNotBeCalled(); + + $result = $this->invokeDoRun($input, $output); + + self::assertSame(Command::SUCCESS, $result); + self::assertStringNotContainsString('_____', $output->fetch()); + } + + /** + * @return void + */ + #[Test] + public function doRunWillNotRenderLogoForRawChangelogCommands(): void + { + foreach (['changelog:next-version', 'changelog:show'] as $commandName) { + $this->commandLoader->has($commandName) + ->willReturn(true) + ->shouldBeCalledOnce(); + $this->commandLoader->get($commandName) + ->willReturn( + new class($commandName) extends Command { + public function __construct(string $name) + { + parent::__construct($name); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + return Command::SUCCESS; + } + }, + ) + ->shouldBeCalledOnce(); + + $input = new ArrayInput([ + 'command' => $commandName, + ]); + + $output = new BufferedOutput(); + + $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + ->willReturn(''); + $this->workingDirectorySwitcher->switchTo(null) + ->shouldBeCalledOnce(); + $this->versionCheckNotifier->notify($output) + ->shouldNotBeCalled(); + + $result = $this->invokeDoRun($input, $output); + + self::assertSame(Command::SUCCESS, $result); + self::assertStringNotContainsString('_____', $output->fetch()); + } } /** @@ -404,4 +595,17 @@ private function invokeConfigureWorkspaceDirectory(InputInterface $input): void $reflectionMethod = new ReflectionMethod($this->devTools, 'configureWorkspaceDirectory'); $reflectionMethod->invoke($this->devTools, $input); } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + private function invokeDoRun(InputInterface $input, OutputInterface $output): int + { + $reflectionMethod = new ReflectionMethod($this->devTools, 'doRun'); + + return (int) $reflectionMethod->invoke($this->devTools, $input, $output); + } } diff --git a/tests/Process/ProcessBuilderTest.php b/tests/Process/ProcessBuilderTest.php index 4721863c5..e5077b341 100644 --- a/tests/Process/ProcessBuilderTest.php +++ b/tests/Process/ProcessBuilderTest.php @@ -19,14 +19,17 @@ namespace FastForward\DevTools\Tests\Process; +use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Process\ProcessBuilder; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Process\Process; #[CoversClass(ProcessBuilder::class)] +#[UsesClass(DevToolsPathResolver::class)] final class ProcessBuilderTest extends TestCase { use ProphecyTrait; @@ -119,4 +122,48 @@ public function buildWillReturnProcessInstanceWithArguments(): void self::assertInstanceOf(Process::class, $process); self::assertSame("'php' 'artisan' 'serve' '--verbose' '--env=dev'", $process->getCommandLine()); } + + /** + * @return void + */ + #[Test] + public function buildWillInjectNoLogoArgumentForDevToolsCommands(): void + { + $process = $this->builder + ->build(DevToolsPathResolver::getBinaryCommand('tests')); + + self::assertSame( + "'" . DevToolsPathResolver::getBinaryPath() . "' '--no-logo' 'tests'", + $process->getCommandLine(), + ); + } + + /** + * @return void + */ + #[Test] + public function buildWillKeepExistingNoLogoArgumentWhenProvidedInArguments(): void + { + $process = $this->builder + ->withArgument('--no-logo') + ->withArgument('--ansi') + ->build(DevToolsPathResolver::getBinaryCommand('tests')); + + self::assertSame( + "'" . DevToolsPathResolver::getBinaryPath() . "' 'tests' '--no-logo' '--ansi'", + $process->getCommandLine(), + ); + } + + /** + * @return void + */ + #[Test] + public function buildWillNotInjectNoLogoArgumentForNonDevToolsCommands(): void + { + $process = $this->builder + ->build('vendor/bin/phpunit'); + + self::assertSame("'vendor/bin/phpunit'", $process->getCommandLine()); + } }