diff --git a/.github/wiki b/.github/wiki index 170a0a7222..20540e53c7 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 170a0a722292d33915ba20cd74da638e429ca0e6 +Subproject commit 20540e53c7df86308a195c7b1fdb75b31571ea13 diff --git a/CHANGELOG.md b/CHANGELOG.md index 459f9b15b0..95ef9925f6 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] +### Added + +- Add a standalone DevTools `self-update` command plus global `--working-dir` and `--auto-update` binary options for local or global installations (#272) + ## [1.23.0] - 2026-04-26 ### Added diff --git a/README.md b/README.md index fb441f6cd5..3a6c284ce1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ composer dev-tools # Automatically fix code standards issues where applicable composer dev-tools:fix + +# Run the standalone binary from another project directory +vendor/bin/dev-tools --working-dir=/path/to/project tests + +# Update the installed DevTools package +vendor/bin/dev-tools self-update ``` You can also run individual commands for specific development tasks: @@ -288,6 +294,7 @@ skills they depend on. | `composer codeowners` | Generates managed `.github/CODEOWNERS` content from local repository metadata. | | `composer gitattributes` | Manages export-ignore rules in `.gitattributes`. | | `composer dev-tools:sync` | Updates scripts, CODEOWNERS, funding metadata, workflow stubs, `.editorconfig`, `.gitignore`, `.gitattributes`, wiki setup, packaged skills, and packaged agents. | +| `vendor/bin/dev-tools self-update` / `composer dev-tools:self-update` | Updates the local DevTools package, or the Composer global installation when the active binary is globally installed. | ## šŸ”Œ Integration diff --git a/docs/_static/mascot-banner.png b/docs/_static/mascot-banner.png index f6d3842256..2914ca1355 100644 Binary files a/docs/_static/mascot-banner.png and b/docs/_static/mascot-banner.png differ diff --git a/docs/_static/mascot.png b/docs/_static/mascot.png deleted file mode 100644 index 74d19f822d..0000000000 Binary files a/docs/_static/mascot.png and /dev/null differ diff --git a/docs/api/commands.rst b/docs/api/commands.rst index 6fa37a06b9..a96d3033e9 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -1,13 +1,14 @@ Command Classes =============== -All public CLI commands extend ``Composer\Command\BaseCommand``. Most command -classes are resolved lazily through ``DevToolsCommandLoader`` and receive -their collaborators from the shared ``DevToolsServiceProvider`` container, -while orchestration commands such as ``standards`` dispatch other commands -through the console application itself. The architecture also relies on -``ProcessBuilder`` and ``ProcessQueue`` for fluent process management where -subprocess execution is needed. +All public CLI commands are Symfony Console commands resolved lazily through +``DevToolsCommandLoader``. Command classes receive their collaborators from +the shared ``DevToolsServiceProvider`` container, while orchestration commands +such as ``standards`` dispatch other commands through the console application +itself. Composer integration uses proxy commands to expose that same Symfony +command set without forcing each command to extend Composer's ``BaseCommand``. +The architecture also relies on ``ProcessBuilder`` and ``ProcessQueue`` for +fluent process management where subprocess execution is needed. .. list-table:: :header-rows: 1 @@ -95,3 +96,6 @@ subprocess execution is needed. * - ``FastForward\DevTools\Console\Command\UpdateComposerJsonCommand`` - ``update-composer-json`` - Updates the composer.json file to match the packaged schema. + * - ``FastForward\DevTools\Console\Command\SelfUpdateCommand`` + - ``dev-tools:self-update`` + - Updates the local or global DevTools installation through Composer. diff --git a/docs/api/composer-integration.rst b/docs/api/composer-integration.rst index a43ae90a89..d08a0bbce5 100644 --- a/docs/api/composer-integration.rst +++ b/docs/api/composer-integration.rst @@ -31,7 +31,11 @@ Composer Plugin Classes - Registers the command provider and runs ``dev-tools:sync`` after Composer install and update. * - ``FastForward\DevTools\Composer\Capability\DevToolsCommandProvider`` - - Instantiates and returns the available command classes. + - Exposes Symfony command instances to Composer through proxy commands + while filtering names and aliases already registered by Composer or the + root project's scripts. + * - ``FastForward\DevTools\Composer\Command\ProxyCommand`` + - Adapts one Symfony command to Composer's command provider contract. * - ``FastForward\DevTools\Console\DevTools`` - Console application used by the local binary. diff --git a/docs/commands/index.rst b/docs/commands/index.rst index 69e80094c3..ae1a63998f 100644 --- a/docs/commands/index.rst +++ b/docs/commands/index.rst @@ -20,6 +20,7 @@ Detailed documentation for each dev-tools command. agents skills sync + self-update funding codeowners gitignore diff --git a/docs/commands/self-update.rst b/docs/commands/self-update.rst new file mode 100644 index 0000000000..31f1ae78ee --- /dev/null +++ b/docs/commands/self-update.rst @@ -0,0 +1,53 @@ +self-update +=========== + +``self-update`` updates the installed ``fast-forward/dev-tools`` package +through Composer. + +Usage +----- + +.. code-block:: bash + + vendor/bin/dev-tools self-update + composer dev-tools:self-update + +When the standalone ``dev-tools`` binary is itself loaded from Composer's +global installation, ``self-update`` automatically targets +``composer global update fast-forward/dev-tools``. Local project +installations update the current project by default. + +Global runtime options +---------------------- + +The standalone DevTools binary also accepts Composer-like global runtime +options before the command name: + +.. code-block:: bash + + vendor/bin/dev-tools --working-dir=/path/to/project tests + vendor/bin/dev-tools --auto-update tests + +``--working-dir`` (or ``-d``) switches the process directory before resolving +paths, managed files, or command defaults. This lets a globally installed +binary operate on another project without first changing shell directories. +Composer executions can use Composer's own ``--working-dir``/``-d`` option. + +``--auto-update`` runs the self-update flow before the requested command. The +same behavior MAY be enabled with ``FAST_FORWARD_AUTO_UPDATE=1``. When the +active ``dev-tools`` binary is already installed globally, auto-update also +targets the global installation by default. Auto-update failures are reported +as warnings and do not block the requested command. + +Version freshness check +----------------------- + +When DevTools runs from an installed package, the binary checks Composer +metadata for the latest stable ``fast-forward/dev-tools`` release. If a newer +stable version is available, DevTools prints a warning recommending +``dev-tools self-update``. This check is best-effort: network, Composer, or +metadata failures are ignored so the requested command can continue normally. +The check is skipped automatically in CI environments, including GitHub +Actions, so freshly installed consumer workflows do not spend time querying +release metadata. Set ``FAST_FORWARD_SKIP_VERSION_CHECK=1`` to disable the +warning in other non-interactive contexts. diff --git a/docs/index.rst b/docs/index.rst index c8c99de9ca..43af70897e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,7 +21,7 @@ Documentation .. container:: col-lg-5 text-center - .. image:: _static/mascot.png + .. image:: _static/mascot-banner.png :alt: Fast Forward DevTools mascot :class: img-fluid w-100 rounded-4 shadow-sm border border-light-subtle bg-body-tertiary p-2 diff --git a/docs/internals/architecture.rst b/docs/internals/architecture.rst index 8fb3f63d7e..d7c679d256 100644 --- a/docs/internals/architecture.rst +++ b/docs/internals/architecture.rst @@ -94,5 +94,8 @@ command list: - Git ignore, Git attributes, license, resource diffing, and coverage summary services support consumer sync and reporting workflows. * - ``Shared infrastructure`` - - ``LoggerInterface``, ``ClockInterface``, and Twig's - ``LoaderInterface`` provide reusable runtime infrastructure. + - ``LoggerInterface``, ``ClockInterface``, + ``RuntimeEnvironmentInterface``, and Twig's ``LoaderInterface`` + provide reusable runtime infrastructure, including centralized checks + for GitHub Actions, generic CI, Composer test runs, and truthy + environment flags. diff --git a/src/Composer/Capability/DevToolsCommandProvider.php b/src/Composer/Capability/DevToolsCommandProvider.php index 76501fdfb8..d8014ca62b 100644 --- a/src/Composer/Capability/DevToolsCommandProvider.php +++ b/src/Composer/Capability/DevToolsCommandProvider.php @@ -21,19 +21,32 @@ use Composer\Plugin\Capability\CommandProvider; use FastForward\DevTools\Composer\Command\ProxyCommand; +use FastForward\DevTools\Composer\DevToolsPluginInterface; use FastForward\DevTools\Console\DevTools; +use Symfony\Component\Console\Command\Command; /** * Provides a registry of custom dev-tools commands mapped for Composer integration. * This capability struct MUST implement the defined `CommandProvider`. */ -final class DevToolsCommandProvider implements CommandProvider +final readonly class DevToolsCommandProvider implements CommandProvider { /** * @var string the namespace prefix for dev-tools console commands to be registered as Composer commands */ private const string COMMAND_NAMESPACE = 'FastForward\DevTools\Console\Command'; + private ?DevToolsPluginInterface $plugin; + + /** + * @param array $constructorArguments the Composer capability constructor arguments + */ + public function __construct(array $constructorArguments = []) + { + $plugin = $constructorArguments['plugin'] ?? null; + $this->plugin = $plugin instanceof DevToolsPluginInterface ? $plugin : null; + } + /** * {@inheritDoc} */ @@ -55,9 +68,38 @@ public function getCommands() continue; } - $commands[] = new ProxyCommand($command); + if ($this->isRegisteredCommand($command->getName())) { + continue; + } + + $commands[] = new ProxyCommand($command, $this->getComposerAliases($command)); } return $commands; } + + /** + * Returns command aliases that may be safely exposed to Composer. + * + * @param Command $command the Symfony command being proxied + * + * @return list + */ + private function getComposerAliases(Command $command): array + { + return array_values(array_filter( + $command->getAliases(), + fn(string $alias): bool => ! $this->isRegisteredCommand($alias), + )); + } + + /** + * Detects names already owned by Composer's active command surface. + * + * @param string|null $name the command name or alias being evaluated + */ + private function isRegisteredCommand(?string $name): bool + { + return $this->plugin?->isRegisteredCommand($name) ?? false; + } } diff --git a/src/Composer/Command/ProxyCommand.php b/src/Composer/Command/ProxyCommand.php index e9ab2c4df3..a83a46af3f 100644 --- a/src/Composer/Command/ProxyCommand.php +++ b/src/Composer/Command/ProxyCommand.php @@ -31,14 +31,16 @@ final class ProxyCommand extends BaseCommand { /** * @param Command $command the Symfony command adapted for Composer plugin execution + * @param list|null $aliases the optional alias list exposed to Composer */ public function __construct( private readonly Command $command, + ?array $aliases = null, ) { parent::__construct($this->command->getName()); $this - ->setAliases($this->command->getAliases()) + ->setAliases($aliases ?? $this->command->getAliases()) ->setDescription($this->command->getDescription()) ->setHelp($this->command->getHelp()) ->setDefinition(clone $this->command->getDefinition()) @@ -46,10 +48,12 @@ public function __construct( } /** - * @param InputInterface $input - * @param OutputInterface $output + * Executes the proxied Symfony command through Composer's command contract. * - * @return int + * @param InputInterface $input the Composer command input + * @param OutputInterface $output the Composer command output + * + * @return int the proxied command status code */ protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Composer/DevToolsPluginInterface.php b/src/Composer/DevToolsPluginInterface.php new file mode 100644 index 0000000000..d0f83ab931 --- /dev/null +++ b/src/Composer/DevToolsPluginInterface.php @@ -0,0 +1,93 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Composer; + +use Composer\Plugin\PluginInterface; + +/** + * Defines DevTools-specific Composer plugin conventions. + */ +interface DevToolsPluginInterface extends PluginInterface +{ + /** + * @var list composer command names and aliases that DevTools MUST NOT override + */ + public const array COMPOSER_COMMAND_NAMES = [ + '_complete', + 'about', + 'archive', + 'audit', + 'browse', + 'bump', + 'cc', + 'check-platform-reqs', + 'clear-cache', + 'clearcache', + 'completion', + 'config', + 'create-project', + 'depends', + 'diagnose', + 'dump-autoload', + 'dumpautoload', + 'exec', + 'fund', + 'global', + 'help', + 'home', + 'i', + 'info', + 'init', + 'install', + 'licenses', + 'list', + 'outdated', + 'prohibits', + 'r', + 'reinstall', + 'remove', + 'repo', + 'repository', + 'require', + 'rm', + 'run', + 'run-script', + 'search', + 'self-update', + 'selfupdate', + 'show', + 'status', + 'suggests', + 'u', + 'uninstall', + 'update', + 'upgrade', + 'validate', + 'why', + 'why-not', + ]; + + /** + * Detects whether a command name or alias is already registered in Composer's command surface. + * + * @param string|null $name the command name or alias being evaluated + */ + public function isRegisteredCommand(?string $name): bool; +} diff --git a/src/Composer/Plugin.php b/src/Composer/Plugin.php index 70b538db15..e57b8e0d49 100644 --- a/src/Composer/Plugin.php +++ b/src/Composer/Plugin.php @@ -21,11 +21,10 @@ use Composer\Composer; use Composer\EventDispatcher\EventSubscriberInterface; -use Composer\Script\Event; use Composer\IO\IOInterface; use Composer\Plugin\Capability\CommandProvider; use Composer\Plugin\Capable; -use Composer\Plugin\PluginInterface; +use Composer\Script\Event; use Composer\Script\ScriptEvents; use FastForward\DevTools\Composer\Capability\DevToolsCommandProvider; @@ -33,8 +32,10 @@ * Implements the lifecycle of the Composer dev-tools extension framework. * This plugin class MUST initialize and coordinate custom script registrations securely. */ -final class Plugin implements Capable, EventSubscriberInterface, PluginInterface +final class Plugin implements Capable, DevToolsPluginInterface, EventSubscriberInterface { + private ?Composer $composer = null; + /** * Resolves the implemented Composer capabilities structure. * @@ -84,6 +85,29 @@ public function runSyncCommand(Event $event): void ->execute('vendor/bin/dev-tools dev-tools:sync'); } + /** + * Detects whether a command name or alias is already registered in Composer's command surface. + * + * @param string|null $name the command name or alias being evaluated + */ + public function isRegisteredCommand(?string $name): bool + { + return null !== $name && \in_array($name, $this->getReservedCommandNames(), true); + } + + /** + * Returns command names and aliases that DevTools plugin commands MUST NOT override. + * + * @return list + */ + private function getReservedCommandNames(): array + { + return array_values(array_unique([ + ...self::COMPOSER_COMMAND_NAMES, + ...$this->getRootScriptCommandNames(), + ])); + } + /** * Handles activation lifecycle events for the Composer session. * @@ -96,7 +120,7 @@ public function runSyncCommand(Event $event): void */ public function activate(Composer $composer, IOInterface $io): void { - // No activation logic needed for this plugin + $this->composer = $composer; } /** @@ -111,7 +135,7 @@ public function activate(Composer $composer, IOInterface $io): void */ public function deactivate(Composer $composer, IOInterface $io): void { - // No deactivation logic needed for this plugin + $this->composer = null; } /** @@ -126,6 +150,30 @@ public function deactivate(Composer $composer, IOInterface $io): void */ public function uninstall(Composer $composer, IOInterface $io): void { - // No uninstall logic needed for this plugin + $this->composer = null; + } + + /** + * Returns custom Composer script command names from the active root package. + * + * @return list + */ + private function getRootScriptCommandNames(): array + { + if (! $this->composer instanceof Composer) { + return []; + } + + $names = []; + + foreach (array_keys($this->composer->getPackage()->getScripts()) as $script) { + if (\defined(ScriptEvents::class . '::' . str_replace('-', '_', strtoupper($script)))) { + continue; + } + + $names[] = $script; + } + + return $names; } } diff --git a/src/Console/Command/SelfUpdateCommand.php b/src/Console/Command/SelfUpdateCommand.php new file mode 100644 index 0000000000..e7166de71d --- /dev/null +++ b/src/Console/Command/SelfUpdateCommand.php @@ -0,0 +1,121 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Console\Command; + +use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; +use FastForward\DevTools\Reflection\ClassReflection; +use FastForward\DevTools\SelfUpdate\SelfUpdateRunnerInterface; +use FastForward\DevTools\SelfUpdate\SelfUpdateScopeResolverInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Updates the installed DevTools package through Composer. + */ +#[AsCommand( + name: 'dev-tools:self-update', + description: 'Updates the installed fast-forward/dev-tools package.', + aliases: ['self-update', 'selfupdate'], +)] +final class SelfUpdateCommand extends Command +{ + use LogsCommandResults; + + /** + * @param SelfUpdateRunnerInterface $selfUpdateRunner the runner that executes Composer's update command + * @param SelfUpdateScopeResolverInterface $scopeResolver resolves whether the active binary is globally installed + * @param LoggerInterface $logger the output-aware logger + */ + public function __construct( + private readonly SelfUpdateRunnerInterface $selfUpdateRunner, + private readonly SelfUpdateScopeResolverInterface $scopeResolver, + private readonly LoggerInterface $logger, + ) { + parent::__construct(); + } + + /** + * Returns the command name and aliases declared through AsCommand. + * + * @return list + */ + public static function getCommandNames(): array + { + static $commandNames = null; + + if (null !== $commandNames) { + return $commandNames; + } + + $arguments = ClassReflection::getAttributeArguments(self::class, AsCommand::class); + $commandNames = [$arguments['name'], ...$arguments['aliases']]; + + return $commandNames = array_values(array_filter( + $commandNames, + static fn(string $commandName): bool => '' !== $commandName, + )); + } + + /** + * Configures the self-update command. + */ + protected function configure(): void + { + $this->setHelp( + 'This command updates fast-forward/dev-tools through Composer. When DevTools is running from' + . ' Composer global, the command updates that global installation automatically; otherwise it updates' + . ' the current project installation.' + ); + } + + /** + * Executes the Composer update flow. + * + * @param InputInterface $input the command input + * @param OutputInterface $output the command output + * + * @return int the command status code + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $global = $this->scopeResolver->isGlobalInstallation(); + + $this->logger->info('Updating DevTools installation...', [ + 'input' => $input, + 'global' => $global, + ]); + + $statusCode = $this->selfUpdateRunner->update($global, $output); + + if (self::SUCCESS === $statusCode) { + return $this->success('DevTools self-update completed successfully.', $input, [ + 'global' => $global, + ]); + } + + return $this->failure('DevTools self-update failed.', $input, [ + 'global' => $global, + 'status_code' => $statusCode, + ]); + } +} diff --git a/src/Console/CommandLoader/DevToolsCommandLoader.php b/src/Console/CommandLoader/DevToolsCommandLoader.php index 22d033220e..cfd2e3cd86 100644 --- a/src/Console/CommandLoader/DevToolsCommandLoader.php +++ b/src/Console/CommandLoader/DevToolsCommandLoader.php @@ -20,8 +20,8 @@ namespace FastForward\DevTools\Console\CommandLoader; use FastForward\DevTools\Filesystem\FinderFactoryInterface; +use FastForward\DevTools\Reflection\ClassReflection; use Psr\Container\ContainerInterface; -use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -81,25 +81,17 @@ private function getCommandMap(FinderFactoryInterface $finderFactory): array foreach ($commandsDirectory as $file) { $class = $namespace . $file->getBasename('.php'); - $reflection = new ReflectionClass($class); - if (! $reflection->isInstantiable()) { + if (! ClassReflection::isInstantiableSubclassOf($class, Command::class)) { continue; } - if (! $reflection->isSubclassOf(Command::class)) { - continue; - } - - $attribute = $reflection->getAttributes(AsCommand::class)[0] ?? null; + $arguments = ClassReflection::getAttributeArguments($class, AsCommand::class); - if (null === $attribute) { + if (null === $arguments) { continue; } - $arguments = $attribute->getArguments(); - $commandName = $arguments['name'] ?? $arguments[0] ?? ''; - $aliases = $arguments['aliases'] ?? $arguments[2] ?? []; - $commandNames = [$commandName, ...((array) $aliases)]; + $commandNames = [$arguments['name'], ...((array) $arguments['aliases'])]; foreach ($commandNames as $commandName) { if (! \is_string($commandName)) { diff --git a/src/Console/DevTools.php b/src/Console/DevTools.php index 1e2c20668e..b52cee8d89 100644 --- a/src/Console/DevTools.php +++ b/src/Console/DevTools.php @@ -19,12 +19,24 @@ namespace FastForward\DevTools\Console; +use FastForward\DevTools\Console\Command\SelfUpdateCommand; use Override; +use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\SelfUpdate\SelfUpdateRunnerInterface; +use FastForward\DevTools\SelfUpdate\SelfUpdateScopeResolverInterface; +use FastForward\DevTools\SelfUpdate\VersionCheckNotifierInterface; +use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcherInterface; use FastForward\DevTools\ServiceProvider\DevToolsServiceProvider; use DI\Container; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; /** * Wraps the fast-forward console tooling suite conceptually as an isolated application instance. @@ -52,9 +64,20 @@ final class DevTools extends Application * It SHALL instruct the runner to treat the `standards` command generically as its default endpoint. * * @param CommandLoaderInterface $commandLoader the command loader responsible for providing command instances + * @param WorkingDirectorySwitcherInterface $workingDirectorySwitcher switches the process working directory + * @param VersionCheckNotifierInterface $versionCheckNotifier emits non-blocking version freshness warnings + * @param SelfUpdateRunnerInterface $selfUpdateRunner runs explicit or automatic self-update flows + * @param SelfUpdateScopeResolverInterface $selfUpdateScopeResolver resolves whether the active binary is global + * @param EnvironmentInterface $environment reads environment flags for optional auto-update behavior */ - public function __construct(CommandLoaderInterface $commandLoader) - { + public function __construct( + CommandLoaderInterface $commandLoader, + private readonly WorkingDirectorySwitcherInterface $workingDirectorySwitcher, + private readonly VersionCheckNotifierInterface $versionCheckNotifier, + private readonly SelfUpdateRunnerInterface $selfUpdateRunner, + private readonly SelfUpdateScopeResolverInterface $selfUpdateScopeResolver, + private readonly EnvironmentInterface $environment, + ) { parent::__construct('Fast Forward Dev Tools'); $this->setDefaultCommand('dev-tools:standards'); @@ -72,6 +95,59 @@ public function getHelp(): string return self::LOGO . "\n\n" . parent::getHelp(); } + /** + * Returns the application-level input definition with DevTools runtime options. + * + * @return InputDefinition the global application input definition + */ + #[Override] + protected function getDefaultInputDefinition(): InputDefinition + { + $definition = parent::getDefaultInputDefinition(); + + $definition->addOption(new InputOption( + name: 'working-dir', + shortcut: 'd', + mode: InputOption::VALUE_REQUIRED, + description: 'Run DevTools as if it was started in the given directory.', + )); + + $definition->addOption(new InputOption( + name: 'auto-update', + mode: InputOption::VALUE_NONE, + description: 'Update fast-forward/dev-tools before running the requested command.', + )); + + return $definition; + } + + /** + * Runs the application after applying global runtime options. + * + * @param InputInterface $input the application input + * @param OutputInterface $output the application output + * + * @return int the application status code + */ + #[Override] + public function doRun(InputInterface $input, OutputInterface $output): int + { + try { + $this->workingDirectorySwitcher->switchTo($this->getWorkingDirectoryOption($input)); + } catch (Throwable $throwable) { + $output->writeln(\sprintf('%s', $throwable->getMessage())); + + return Command::FAILURE; + } + + if (! $this->isSelfUpdateCommand($input)) { + $this->runAutoUpdateWhenRequested($input, $output); + $this->versionCheckNotifier->notify($output); + } + + return parent::doRun($input, $output); + } + /** * Create DevTools instance from container. * @@ -94,4 +170,64 @@ public static function getContainer(): ContainerInterface return self::$container; } + + /** + * Resolves the raw working-directory option before command parsing. + * + * @param InputInterface $input the application input + */ + private function getWorkingDirectoryOption(InputInterface $input): ?string + { + $workingDirectory = $input->getParameterOption(['--working-dir', '-d'], null, true); + + return \is_string($workingDirectory) ? $workingDirectory : null; + } + + /** + * Runs an explicit automatic update without letting failures block the requested command. + * + * @param InputInterface $input the application input + * @param OutputInterface $output the application output + */ + private function runAutoUpdateWhenRequested(InputInterface $input, OutputInterface $output): void + { + $autoUpdateMode = $this->environment->get('FAST_FORWARD_AUTO_UPDATE', ''); + + if (! $input->hasParameterOption('--auto-update', true) && ! $this->isTruthyAutoUpdateMode($autoUpdateMode)) { + return; + } + + try { + $global = $this->selfUpdateScopeResolver->isGlobalInstallation(); + $statusCode = $this->selfUpdateRunner->update($global, $output); + } catch (Throwable) { + $output->writeln('DevTools auto-update failed; continuing with the requested command.'); + + return; + } + + if (Command::SUCCESS !== $statusCode) { + $output->writeln('DevTools auto-update failed; continuing with the requested command.'); + } + } + + /** + * Detects whether the current invocation targets the self-update command. + * + * @param InputInterface $input the application input + */ + private function isSelfUpdateCommand(InputInterface $input): bool + { + return \in_array($input->getFirstArgument(), SelfUpdateCommand::getCommandNames(), true); + } + + /** + * Interprets environment values that enable auto-update. + * + * @param string|null $mode the FAST_FORWARD_AUTO_UPDATE value + */ + private function isTruthyAutoUpdateMode(?string $mode): bool + { + return null !== $mode && \in_array(strtolower($mode), ['1', 'true', 'yes', 'on'], true); + } } diff --git a/src/Console/Output/GithubActionOutput.php b/src/Console/Output/GithubActionOutput.php index 8d21d1c561..2a60b5f91e 100644 --- a/src/Console/Output/GithubActionOutput.php +++ b/src/Console/Output/GithubActionOutput.php @@ -19,7 +19,7 @@ namespace FastForward\DevTools\Console\Output; -use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; use Symfony\Component\Console\Output\ConsoleOutputInterface; /** @@ -35,11 +35,11 @@ final class GithubActionOutput /** * @param ConsoleOutputInterface $output the console output used to emit workflow commands - * @param EnvironmentInterface $environment reads runtime environment flags + * @param RuntimeEnvironmentInterface $environment resolves runtime environment capabilities */ public function __construct( private readonly ConsoleOutputInterface $output, - private readonly EnvironmentInterface $environment, + private readonly RuntimeEnvironmentInterface $environment, ) {} /** @@ -247,22 +247,8 @@ private function emit(string $command, string $message = '', array $properties = */ private function supportsWorkflowCommands(): bool { - return $this->isTruthyEnvironmentFlag('GITHUB_ACTIONS') - && ! $this->isTruthyEnvironmentFlag('COMPOSER_TESTS_ARE_RUNNING'); - } - - /** - * Determines whether an environment flag is set to a truthy value. - * - * @param string $name the environment variable name - * - * @return bool true when the environment variable is truthy - */ - private function isTruthyEnvironmentFlag(string $name): bool - { - $value = $this->environment->get($name, ''); - - return null !== $value && '' !== $value && '0' !== $value; + return $this->environment->isGithubActions() + && ! $this->environment->isComposerTestRun(); } /** diff --git a/src/Environment/RuntimeEnvironment.php b/src/Environment/RuntimeEnvironment.php new file mode 100644 index 0000000000..b45cf0e829 --- /dev/null +++ b/src/Environment/RuntimeEnvironment.php @@ -0,0 +1,71 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Environment; + +/** + * Resolves common runtime-environment flags used by DevTools integrations. + */ +final readonly class RuntimeEnvironment implements RuntimeEnvironmentInterface +{ + /** + * @param EnvironmentInterface $environment reads raw process environment variables + */ + public function __construct( + private EnvironmentInterface $environment, + ) {} + + /** + * Returns whether a truthy environment flag is enabled. + * + * @param string $name the environment variable name + */ + public function isEnabled(string $name): bool + { + return \in_array(strtolower((string) $this->environment->get($name, '')), ['1', 'true', 'yes', 'on'], true); + } + + /** + * Returns whether the current process runs in GitHub Actions. + */ + public function isGithubActions(): bool + { + return $this->isEnabled('GITHUB_ACTIONS'); + } + + /** + * Returns whether the current process runs in a CI environment. + */ + public function isCi(): bool + { + if ($this->isGithubActions()) { + return true; + } + + return $this->isEnabled('CI'); + } + + /** + * Returns whether the Composer test suite runtime flag is enabled. + */ + public function isComposerTestRun(): bool + { + return $this->isEnabled('COMPOSER_TESTS_ARE_RUNNING'); + } +} diff --git a/src/Environment/RuntimeEnvironmentInterface.php b/src/Environment/RuntimeEnvironmentInterface.php new file mode 100644 index 0000000000..852a652ad2 --- /dev/null +++ b/src/Environment/RuntimeEnvironmentInterface.php @@ -0,0 +1,50 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Environment; + +/** + * Answers common runtime-environment questions from process environment flags. + */ +interface RuntimeEnvironmentInterface +{ + /** + * Returns whether a truthy environment flag is enabled. + * + * @param string $name the environment variable name + * + * @return bool true when the environment variable is enabled + */ + public function isEnabled(string $name): bool; + + /** + * Returns whether the current process runs in GitHub Actions. + */ + public function isGithubActions(): bool; + + /** + * Returns whether the current process runs in a CI environment. + */ + public function isCi(): bool; + + /** + * Returns whether the Composer test suite runtime flag is enabled. + */ + public function isComposerTestRun(): bool; +} diff --git a/src/Process/ProcessQueue.php b/src/Process/ProcessQueue.php index 5f239e4ff7..0a03bf6ddc 100644 --- a/src/Process/ProcessQueue.php +++ b/src/Process/ProcessQueue.php @@ -22,7 +22,7 @@ use Closure; use FastForward\DevTools\Console\Output\GithubActionOutput; use FastForward\DevTools\Console\Output\OutputCapabilityDetectorInterface; -use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; use ReflectionProperty; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\ConsoleOutputInterface; @@ -73,13 +73,13 @@ final class ProcessQueue implements ProcessQueueInterface /** * @param GithubActionOutput $githubActionOutput wraps grouped queue output in GitHub Actions logs when supported * @param ProcessEnvironmentConfiguratorInterface $environmentConfigurator - * @param EnvironmentInterface $environment reads runtime environment flags + * @param RuntimeEnvironmentInterface $environment resolves runtime environment capabilities * @param OutputCapabilityDetectorInterface $outputCapabilityDetector detects ANSI-capable output */ public function __construct( private readonly GithubActionOutput $githubActionOutput, private readonly ProcessEnvironmentConfiguratorInterface $environmentConfigurator, - private readonly EnvironmentInterface $environment, + private readonly RuntimeEnvironmentInterface $environment, private readonly OutputCapabilityDetectorInterface $outputCapabilityDetector, ) {} @@ -404,7 +404,7 @@ private function runInOutputSection(string $label, OutputInterface $output, Clos private function shouldRenderLocalSection(OutputInterface $output): bool { return $this->outputCapabilityDetector->supportsAnsi($output) - && null === $this->environment->get('GITHUB_ACTIONS'); + && ! $this->environment->isGithubActions(); } /** diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php new file mode 100644 index 0000000000..e9fe49c8ca --- /dev/null +++ b/src/Reflection/ClassReflection.php @@ -0,0 +1,80 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Reflection; + +use ReflectionClass; +use ReflectionMethod; + +/** + * Centralizes small reflection lookups used by DevTools runtime metadata. + * + * This helper keeps command discovery code focused on command behavior instead of + * raw reflection boilerplate. + */ +final class ClassReflection +{ + /** + * Detects whether a class can be instantiated as a subclass of another class. + * + * @param class-string $className the class being inspected + * @param class-string $parentClass the required parent class or interface + * + * @return bool true when the class is instantiable and extends or implements the expected parent + */ + public static function isInstantiableSubclassOf(string $className, string $parentClass): bool + { + $reflection = new ReflectionClass($className); + + return $reflection->isInstantiable() && $reflection->isSubclassOf($parentClass); + } + + /** + * Returns the first matching attribute arguments normalized by constructor parameter name. + * + * Positional arguments are mapped to their constructor parameter names so callers do not + * need to understand how the attribute was declared at the call site. + * + * @param class-string $className the class being inspected + * @param class-string $attributeClass the attribute class being read + * + * @return array|null the normalized argument map, or null when the attribute is absent + */ + public static function getAttributeArguments(string $className, string $attributeClass): ?array + { + $reflection = new ReflectionClass($className); + $attribute = $reflection->getAttributes($attributeClass)[0] ?? null; + + if (null === $attribute) { + return null; + } + + $arguments = $attribute->getArguments(); + $constructor = new ReflectionMethod($attributeClass, '__construct'); + $normalizedArguments = []; + + foreach ($constructor->getParameters() as $parameter) { + $normalizedArguments[$parameter->getName()] = $arguments[$parameter->getName()] + ?? $arguments[$parameter->getPosition()] + ?? ($parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null); + } + + return $normalizedArguments; + } +} diff --git a/src/SelfUpdate/ComposerSelfUpdateRunner.php b/src/SelfUpdate/ComposerSelfUpdateRunner.php new file mode 100644 index 0000000000..cd1425be34 --- /dev/null +++ b/src/SelfUpdate/ComposerSelfUpdateRunner.php @@ -0,0 +1,64 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +use FastForward\DevTools\Process\ProcessBuilderInterface; +use FastForward\DevTools\Process\ProcessQueueInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Updates DevTools through the active Composer executable. + */ +final readonly class ComposerSelfUpdateRunner implements SelfUpdateRunnerInterface +{ + private const string PACKAGE = 'fast-forward/dev-tools'; + + /** + * @param ProcessBuilderInterface $processBuilder the process builder used to assemble Composer update commands + * @param ProcessQueueInterface $processQueue the queue used to execute the update process + */ + public function __construct( + private ProcessBuilderInterface $processBuilder, + private ProcessQueueInterface $processQueue, + ) {} + + /** + * Updates the installed DevTools package. + * + * @param bool $global whether the update should target Composer's global project + * @param OutputInterface $output the command output used by the update process + * + * @return int the Composer process status code + */ + public function update(bool $global, OutputInterface $output): int + { + $command = $global ? 'composer global update' : 'composer update'; + $label = $global ? 'Updating global DevTools installation' : 'Updating project DevTools installation'; + + $this->processQueue->add( + process: $this->processBuilder + ->withArgument(self::PACKAGE) + ->build($command), + label: $label, + ); + + return $this->processQueue->run($output); + } +} diff --git a/src/SelfUpdate/ComposerSelfUpdateScopeResolver.php b/src/SelfUpdate/ComposerSelfUpdateScopeResolver.php new file mode 100644 index 0000000000..f01855f9c0 --- /dev/null +++ b/src/SelfUpdate/ComposerSelfUpdateScopeResolver.php @@ -0,0 +1,93 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Path\DevToolsPathResolver; +use Symfony\Component\Filesystem\Path; + +/** + * Detects Composer global DevTools installations from known Composer home paths. + */ +final readonly class ComposerSelfUpdateScopeResolver implements SelfUpdateScopeResolverInterface +{ + private const string PACKAGE_PATH = 'vendor/fast-forward/dev-tools'; + + /** + * @param EnvironmentInterface $environment reads Composer home environment values + * @param string|null $packagePath the DevTools package path; defaults to the active package root + */ + public function __construct( + private EnvironmentInterface $environment, + private ?string $packagePath = null, + ) {} + + /** + * Returns whether DevTools is running from Composer's global installation. + */ + public function isGlobalInstallation(): bool + { + $packagePath = Path::canonicalize($this->packagePath ?? DevToolsPathResolver::getPackagePath()); + + foreach ($this->getComposerHomeCandidates() as $composerHome) { + $globalPackagePath = Path::canonicalize(Path::join($composerHome, self::PACKAGE_PATH)); + + if ($packagePath === $globalPackagePath || str_starts_with( + $packagePath, + $globalPackagePath . \DIRECTORY_SEPARATOR + )) { + return true; + } + } + + return false; + } + + /** + * Returns candidate Composer home directories for supported platforms. + * + * @return list + */ + private function getComposerHomeCandidates(): array + { + $candidates = []; + $composerHome = $this->environment->get('COMPOSER_HOME'); + + if (null !== $composerHome && '' !== $composerHome) { + $candidates[] = $composerHome; + } + + $home = $this->environment->get('HOME'); + + if (null !== $home && '' !== $home) { + $candidates[] = Path::join($home, '.composer'); + $candidates[] = Path::join($home, '.config/composer'); + $candidates[] = Path::join($home, 'Library/Application Support/Composer'); + } + + $appData = $this->environment->get('APPDATA'); + + if (null !== $appData && '' !== $appData) { + $candidates[] = Path::join($appData, 'Composer'); + } + + return array_values(array_unique($candidates)); + } +} diff --git a/src/SelfUpdate/ComposerVersionChecker.php b/src/SelfUpdate/ComposerVersionChecker.php new file mode 100644 index 0000000000..46b3398bff --- /dev/null +++ b/src/SelfUpdate/ComposerVersionChecker.php @@ -0,0 +1,118 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +use Composer\InstalledVersions; +use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Process\ProcessBuilderInterface; +use JsonException; +use Symfony\Component\Process\Process; + +use function Safe\preg_match; +use function Safe\json_decode; + +/** + * Resolves DevTools freshness through Composer metadata without coupling callers to Composer commands. + */ +final readonly class ComposerVersionChecker implements VersionCheckerInterface +{ + private const string PACKAGE = 'fast-forward/dev-tools'; + + private const int TIMEOUT_SECONDS = 5; + + /** + * @param ProcessBuilderInterface $processBuilder the process builder used to query Composer metadata + */ + public function __construct( + private ProcessBuilderInterface $processBuilder, + ) {} + + /** + * Returns version information when it can be resolved without blocking command execution. + */ + public function check(): ?VersionCheckResult + { + if (DevToolsPathResolver::isRepositoryCheckout()) { + return null; + } + + $currentVersion = InstalledVersions::getPrettyVersion(self::PACKAGE) + ?? InstalledVersions::getVersion(self::PACKAGE); + + if (null === $currentVersion) { + return null; + } + + $latestVersion = $this->resolveLatestStableVersion(); + + if (null === $latestVersion) { + return null; + } + + return new VersionCheckResult($currentVersion, $latestVersion); + } + + /** + * Resolves the latest stable DevTools version available to Composer. + */ + private function resolveLatestStableVersion(): ?string + { + $process = $this->processBuilder + ->withArgument(self::PACKAGE) + ->withArgument('--available') + ->withArgument('--format=json') + ->withArgument('--no-interaction') + ->build('composer show'); + + $process->setTimeout(self::TIMEOUT_SECONDS); + + if (Process::SUCCESS !== $process->run()) { + return null; + } + + try { + $payload = json_decode($process->getOutput(), true); + } catch (JsonException) { + return null; + } + + if (! \is_array($payload)) { + return null; + } + + $versions = $payload['versions'] ?? null; + + if (! \is_array($versions)) { + return null; + } + + foreach ($versions as $version) { + if (! \is_string($version)) { + continue; + } + + if (1 === preg_match('/^v?\d+\.\d+\.\d+$/', $version)) { + return $version; + } + } + + return null; + } +} diff --git a/src/SelfUpdate/SelfUpdateRunnerInterface.php b/src/SelfUpdate/SelfUpdateRunnerInterface.php new file mode 100644 index 0000000000..5cdf06eb61 --- /dev/null +++ b/src/SelfUpdate/SelfUpdateRunnerInterface.php @@ -0,0 +1,38 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Runs the Composer command that updates the installed DevTools package. + */ +interface SelfUpdateRunnerInterface +{ + /** + * Updates the installed DevTools package. + * + * @param bool $global whether the update should target Composer's global project + * @param OutputInterface $output the command output used by the update process + * + * @return int the Composer process status code + */ + public function update(bool $global, OutputInterface $output): int; +} diff --git a/src/SelfUpdate/SelfUpdateScopeResolverInterface.php b/src/SelfUpdate/SelfUpdateScopeResolverInterface.php new file mode 100644 index 0000000000..188ce3e3ba --- /dev/null +++ b/src/SelfUpdate/SelfUpdateScopeResolverInterface.php @@ -0,0 +1,31 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +/** + * Resolves whether self-update SHOULD target the local project or Composer global project. + */ +interface SelfUpdateScopeResolverInterface +{ + /** + * Returns whether DevTools is running from Composer's global installation. + */ + public function isGlobalInstallation(): bool; +} diff --git a/src/SelfUpdate/VersionCheckNotifier.php b/src/SelfUpdate/VersionCheckNotifier.php new file mode 100644 index 0000000000..e50531d12b --- /dev/null +++ b/src/SelfUpdate/VersionCheckNotifier.php @@ -0,0 +1,79 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; +use Throwable; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Emits update warnings while ensuring version checks never block the requested command. + */ +final readonly class VersionCheckNotifier implements VersionCheckNotifierInterface +{ + /** + * @param VersionCheckerInterface $versionChecker the checker used to resolve latest release metadata + * @param RuntimeEnvironmentInterface $environment resolves runtime environment capabilities + */ + public function __construct( + private VersionCheckerInterface $versionChecker, + private RuntimeEnvironmentInterface $environment, + ) {} + + /** + * Warns when a newer stable DevTools version is available. + * + * @param OutputInterface $output the command output receiving a non-blocking warning + */ + public function notify(OutputInterface $output): void + { + if ($this->shouldSkipVersionCheck()) { + return; + } + + try { + $result = $this->versionChecker->check(); + } catch (Throwable) { + return; + } + + if (! $result instanceof VersionCheckResult || ! $result->isOutdated()) { + return; + } + + $output->writeln(\sprintf( + 'DevTools %s is available; current version is %s. Run "dev-tools self-update" to update.', + $result->getLatestVersion(), + $result->getCurrentVersion(), + )); + } + + /** + * Returns whether DevTools SHOULD skip the best-effort version check. + */ + private function shouldSkipVersionCheck(): bool + { + if ($this->environment->isCi()) { + return true; + } + + return $this->environment->isEnabled('FAST_FORWARD_SKIP_VERSION_CHECK'); + } +} diff --git a/src/SelfUpdate/VersionCheckNotifierInterface.php b/src/SelfUpdate/VersionCheckNotifierInterface.php new file mode 100644 index 0000000000..7a4d3fe8f2 --- /dev/null +++ b/src/SelfUpdate/VersionCheckNotifierInterface.php @@ -0,0 +1,35 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Emits non-blocking DevTools freshness notices for interactive binary runs. + */ +interface VersionCheckNotifierInterface +{ + /** + * Warns when a newer stable DevTools version is available. + * + * @param OutputInterface $output the command output receiving a non-blocking warning + */ + public function notify(OutputInterface $output): void; +} diff --git a/src/SelfUpdate/VersionCheckResult.php b/src/SelfUpdate/VersionCheckResult.php new file mode 100644 index 0000000000..53c0c9fda6 --- /dev/null +++ b/src/SelfUpdate/VersionCheckResult.php @@ -0,0 +1,69 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +/** + * Describes the installed and latest known DevTools versions. + */ +final readonly class VersionCheckResult +{ + /** + * @param string $currentVersion the currently installed DevTools version + * @param string $latestVersion the latest stable DevTools version known to Composer + */ + public function __construct( + private string $currentVersion, + private string $latestVersion, + ) {} + + /** + * Returns the currently installed DevTools version. + */ + public function getCurrentVersion(): string + { + return $this->currentVersion; + } + + /** + * Returns the latest stable DevTools version known to Composer. + */ + public function getLatestVersion(): string + { + return $this->latestVersion; + } + + /** + * Detects whether the installed version is older than the latest stable version. + */ + public function isOutdated(): bool + { + return version_compare($this->normalize($this->currentVersion), $this->normalize($this->latestVersion), '<'); + } + + /** + * Normalizes common Composer tag prefixes before version comparison. + * + * @param string $version the version string returned by Composer metadata + */ + private function normalize(string $version): string + { + return ltrim($version, 'v'); + } +} diff --git a/src/SelfUpdate/VersionCheckerInterface.php b/src/SelfUpdate/VersionCheckerInterface.php new file mode 100644 index 0000000000..bf8fcfa241 --- /dev/null +++ b/src/SelfUpdate/VersionCheckerInterface.php @@ -0,0 +1,31 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +/** + * Checks whether the installed DevTools package is behind the latest stable release. + */ +interface VersionCheckerInterface +{ + /** + * Returns version information when it can be resolved without blocking command execution. + */ + public function check(): ?VersionCheckResult; +} diff --git a/src/SelfUpdate/WorkingDirectorySwitcher.php b/src/SelfUpdate/WorkingDirectorySwitcher.php new file mode 100644 index 0000000000..afd32b2942 --- /dev/null +++ b/src/SelfUpdate/WorkingDirectorySwitcher.php @@ -0,0 +1,52 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +use InvalidArgumentException; + +use function Safe\chdir; +use function Safe\realpath; + +/** + * Applies Composer-like working-directory switching for the standalone binary. + */ +final class WorkingDirectorySwitcher implements WorkingDirectorySwitcherInterface +{ + /** + * Switches to the provided working directory when one is configured. + * + * @param string|null $workingDirectory the target working directory, or null when no switch is requested + */ + public function switchTo(?string $workingDirectory): void + { + if (null === $workingDirectory || '' === $workingDirectory) { + return; + } + + if (! is_dir($workingDirectory)) { + throw new InvalidArgumentException(\sprintf( + 'The working directory "%s" does not exist.', + $workingDirectory + )); + } + + chdir(realpath($workingDirectory)); + } +} diff --git a/src/SelfUpdate/WorkingDirectorySwitcherInterface.php b/src/SelfUpdate/WorkingDirectorySwitcherInterface.php new file mode 100644 index 0000000000..643a39c11d --- /dev/null +++ b/src/SelfUpdate/WorkingDirectorySwitcherInterface.php @@ -0,0 +1,33 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\SelfUpdate; + +/** + * Switches the process working directory before command execution starts. + */ +interface WorkingDirectorySwitcherInterface +{ + /** + * Switches to the provided working directory when one is configured. + * + * @param string|null $workingDirectory the target working directory, or null when no switch is requested + */ + public function switchTo(?string $workingDirectory): void; +} diff --git a/src/ServiceProvider/DevToolsServiceProvider.php b/src/ServiceProvider/DevToolsServiceProvider.php index caba841fed..4a833b99b5 100644 --- a/src/ServiceProvider/DevToolsServiceProvider.php +++ b/src/ServiceProvider/DevToolsServiceProvider.php @@ -45,6 +45,8 @@ use FastForward\DevTools\Console\Output\OutputCapabilityDetectorInterface; use FastForward\DevTools\Environment\Environment; use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; use FastForward\DevTools\Filesystem\FinderFactory; use FastForward\DevTools\Filesystem\FinderFactoryInterface; use FastForward\DevTools\Filesystem\Filesystem; @@ -83,6 +85,16 @@ use FastForward\DevTools\Process\ProcessQueue; use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Process\XdebugDisablingProcessEnvironmentConfigurator; +use FastForward\DevTools\SelfUpdate\ComposerSelfUpdateRunner; +use FastForward\DevTools\SelfUpdate\ComposerSelfUpdateScopeResolver; +use FastForward\DevTools\SelfUpdate\ComposerVersionChecker; +use FastForward\DevTools\SelfUpdate\SelfUpdateRunnerInterface; +use FastForward\DevTools\SelfUpdate\SelfUpdateScopeResolverInterface; +use FastForward\DevTools\SelfUpdate\VersionCheckerInterface; +use FastForward\DevTools\SelfUpdate\VersionCheckNotifier; +use FastForward\DevTools\SelfUpdate\VersionCheckNotifierInterface; +use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcher; +use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcherInterface; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Psr\Clock\SystemClock; @@ -105,6 +117,7 @@ use Twig\Loader\LoaderInterface; use function DI\create; +use function DI\factory; use function DI\get; /** @@ -123,6 +136,7 @@ public function getFactories(): array return [ // Process EnvironmentInterface::class => get(Environment::class), + RuntimeEnvironmentInterface::class => get(RuntimeEnvironment::class), ExtensionInterface::class => get(Extension::class), OutputCapabilityDetectorInterface::class => get(OutputCapabilityDetector::class), ProcessBuilderInterface::class => get(ProcessBuilder::class), @@ -133,6 +147,13 @@ public function getFactories(): array ]), ProcessQueueInterface::class => get(ProcessQueue::class), + // Self-update + SelfUpdateRunnerInterface::class => get(ComposerSelfUpdateRunner::class), + SelfUpdateScopeResolverInterface::class => get(ComposerSelfUpdateScopeResolver::class), + VersionCheckerInterface::class => get(ComposerVersionChecker::class), + VersionCheckNotifierInterface::class => get(VersionCheckNotifier::class), + WorkingDirectorySwitcherInterface::class => get(WorkingDirectorySwitcher::class), + // Filesystem FinderFactoryInterface::class => get(FinderFactory::class), FilesystemInterface::class => get(Filesystem::class), @@ -150,10 +171,12 @@ public function getFactories(): array GitClientInterface::class => get(GitClient::class), // Symfony Components - FileLocatorInterface::class => create(FileLocator::class)->constructor([ - WorkingProjectPathResolver::getProjectPath(), - DevToolsPathResolver::getPackagePath(), - ]), + FileLocatorInterface::class => factory( + static fn(): FileLocator => new FileLocator([ + WorkingProjectPathResolver::getProjectPath(), + DevToolsPathResolver::getPackagePath(), + ]) + ), // PSR LoggerInterface::class => get(OutputFormatLogger::class), @@ -169,7 +192,7 @@ public function getFactories(): array ->method('setFormatter', get(LogLevelOutputFormatter::class)), GithubActionOutput::class => create(GithubActionOutput::class)->constructor( get(ConsoleOutputInterface::class), - get(EnvironmentInterface::class) + get(RuntimeEnvironmentInterface::class) ), ContextProcessorInterface::class => create(CompositeContextProcessor::class)->constructor([ get(CommandInputProcessor::class), diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index 6f5a5a9c70..3d4e21d7d7 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -21,6 +21,7 @@ use FastForward\DevTools\Composer\Capability\DevToolsCommandProvider; use FastForward\DevTools\Composer\Command\ProxyCommand; +use FastForward\DevTools\Composer\DevToolsPluginInterface; use FastForward\DevTools\Console\Command\FixtureWithoutAsCommand; use FastForward\DevTools\Console\DevTools; use PHPUnit\Framework\Attributes\CoversClass; @@ -43,6 +44,8 @@ final class DevToolsCommandProviderTest extends TestCase private ObjectProphecy $devTools; + private ObjectProphecy $plugin; + private DevToolsCommandProvider $commandProvider; /** @@ -52,6 +55,7 @@ protected function setUp(): void { $this->container = $this->prophesize(ContainerInterface::class); $this->devTools = $this->prophesize(DevTools::class); + $this->plugin = $this->prophesize(DevToolsPluginInterface::class); $this->container->get(DevTools::class) ->willReturn($this->devTools->reveal()) @@ -60,7 +64,30 @@ protected function setUp(): void $this->devTools->all() ->willReturn([])->shouldBeCalledOnce(); - $this->commandProvider = new DevToolsCommandProvider(); + $this->plugin->isRegisteredCommand(null) + ->willReturn(false); + $this->plugin->isRegisteredCommand('agents') + ->willReturn(false); + $this->plugin->isRegisteredCommand('reports:tests') + ->willReturn(false); + $this->plugin->isRegisteredCommand('tests') + ->willReturn(false); + $this->plugin->isRegisteredCommand('phpunit') + ->willReturn(false); + $this->plugin->isRegisteredCommand('dev-tools:standards') + ->willReturn(false); + $this->plugin->isRegisteredCommand('standards') + ->willReturn(false); + $this->plugin->isRegisteredCommand('dev-tools:self-update') + ->willReturn(false); + $this->plugin->isRegisteredCommand('self-update') + ->willReturn(true); + $this->plugin->isRegisteredCommand('install') + ->willReturn(true); + + $this->commandProvider = new DevToolsCommandProvider([ + 'plugin' => $this->plugin->reveal(), + ]); $property = new ReflectionProperty(DevTools::class, 'container'); $property->setValue(null, $this->container->reveal()); @@ -137,7 +164,7 @@ public function getCommandsWillIgnoreAliasEntriesFromApplicationAllRegistry(): v * @return void */ #[Test] - public function getCommandsWillPreserveAliasDefinitionsInProxyCommand(): void + public function getCommandsWillPreserveSafeAliasesThroughComposerPlugin(): void { $symfonyCommand = new FixtureWithoutAsCommand('dev-tools:standards'); $symfonyCommand->setAliases(['standards']); @@ -158,4 +185,51 @@ public function getCommandsWillPreserveAliasDefinitionsInProxyCommand(): void self::assertSame('dev-tools:standards', $proxyCommand->getName()); self::assertSame(['standards'], $proxyCommand->getAliases()); } + + /** + * @return void + */ + #[Test] + public function getCommandsWillNotExposeSelfUpdateAliasToComposer(): void + { + $symfonyCommand = new FixtureWithoutAsCommand('dev-tools:self-update'); + $symfonyCommand->setAliases(['self-update']); + $symfonyCommand->setDescription('Updates DevTools.'); + $symfonyCommand->setHelp(''); + $symfonyCommand->setHidden(false); + + $this->devTools->all() + ->willReturn([ + 'dev-tools:self-update' => $symfonyCommand, + 'self-update' => $symfonyCommand, + ]) + ->shouldBeCalledOnce(); + + $proxyCommand = array_values($this->commandProvider->getCommands())[0]; + + self::assertInstanceOf(ProxyCommand::class, $proxyCommand); + self::assertSame('dev-tools:self-update', $proxyCommand->getName()); + self::assertSame([], $proxyCommand->getAliases()); + } + + /** + * @return void + */ + #[Test] + public function getCommandsWillNotExposeCommandsOwnedByComposer(): void + { + $symfonyCommand = new FixtureWithoutAsCommand('install'); + $symfonyCommand->setAliases([]); + $symfonyCommand->setDescription('Conflicting command.'); + $symfonyCommand->setHelp(''); + $symfonyCommand->setHidden(false); + + $this->devTools->all() + ->willReturn([ + 'install' => $symfonyCommand, + ]) + ->shouldBeCalledOnce(); + + self::assertSame([], $this->commandProvider->getCommands()); + } } diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php index 0945f85c54..57aa37b487 100644 --- a/tests/Composer/PluginTest.php +++ b/tests/Composer/PluginTest.php @@ -21,6 +21,7 @@ use Composer\Composer; use Composer\IO\IOInterface; +use Composer\Package\RootPackageInterface; use Composer\Plugin\Capability\CommandProvider; use Composer\Script\Event as ScriptEvent; use Composer\Util\Loop; @@ -56,6 +57,11 @@ final class PluginTest extends TestCase */ private ObjectProphecy $io; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $rootPackage; + /** * @return void */ @@ -71,6 +77,11 @@ protected function setUp(): void $this->plugin = new Plugin(); $this->composer = $this->prophesize(Composer::class); $this->io = $this->prophesize(IOInterface::class); + $this->rootPackage = $this->prophesize(RootPackageInterface::class); + $this->composer->getPackage() + ->willReturn($this->rootPackage->reveal()); + $this->rootPackage->getScripts() + ->willReturn([]); $this->originalComposerEnv = (string) getenv('COMPOSER'); $this->tempComposerFile = tempnam(sys_get_temp_dir(), 'composer_test'); @@ -137,6 +148,29 @@ public function activateWillDoNothing(): void self::assertNull($this->plugin->activate($this->composer->reveal(), $this->io->reveal())); } + /** + * @return void + */ + #[Test] + public function isRegisteredCommandWillDetectReservedCommandNames(): void + { + $this->rootPackage->getScripts() + ->willReturn([ + 'custom-script' => [], + 'post-install-cmd' => [], + ]); + $this->plugin->activate($this->composer->reveal(), $this->io->reveal()); + + self::assertTrue($this->plugin->isRegisteredCommand('install')); + self::assertTrue($this->plugin->isRegisteredCommand('i')); + self::assertTrue($this->plugin->isRegisteredCommand('self-update')); + self::assertTrue($this->plugin->isRegisteredCommand('selfupdate')); + self::assertTrue($this->plugin->isRegisteredCommand('custom-script')); + self::assertFalse($this->plugin->isRegisteredCommand('post-install-cmd')); + self::assertFalse($this->plugin->isRegisteredCommand('code-style')); + self::assertFalse($this->plugin->isRegisteredCommand(null)); + } + /** * @return void */ diff --git a/tests/Console/Command/SelfUpdateCommandTest.php b/tests/Console/Command/SelfUpdateCommandTest.php new file mode 100644 index 0000000000..c3b4baa452 --- /dev/null +++ b/tests/Console/Command/SelfUpdateCommandTest.php @@ -0,0 +1,163 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Console\Command; + +use Prophecy\Argument; +use FastForward\DevTools\Console\Command\SelfUpdateCommand; +use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; +use FastForward\DevTools\Reflection\ClassReflection; +use FastForward\DevTools\SelfUpdate\SelfUpdateRunnerInterface; +use FastForward\DevTools\SelfUpdate\SelfUpdateScopeResolverInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\Attributes\UsesTrait; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Psr\Log\LoggerInterface; +use ReflectionMethod; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[CoversClass(SelfUpdateCommand::class)] +#[UsesClass(ClassReflection::class)] +#[UsesTrait(LogsCommandResults::class)] +final class SelfUpdateCommandTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $selfUpdateRunner; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $logger; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $scopeResolver; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $input; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $output; + + private SelfUpdateCommand $command; + + /** + * @return void + */ + protected function setUp(): void + { + $this->selfUpdateRunner = $this->prophesize(SelfUpdateRunnerInterface::class); + $this->scopeResolver = $this->prophesize(SelfUpdateScopeResolverInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + $this->input = $this->prophesize(InputInterface::class); + $this->output = $this->prophesize(OutputInterface::class); + $this->logger->info(Argument::cetera()) + ->will(static function (): void {}); + $this->logger->log(Argument::cetera()) + ->will(static function (): void {}); + $this->logger->error(Argument::cetera()) + ->will(static function (): void {}); + $this->command = new SelfUpdateCommand( + $this->selfUpdateRunner->reveal(), + $this->scopeResolver->reveal(), + $this->logger->reveal() + ); + } + + /** + * @return void + */ + #[Test] + public function getCommandNamesWillReturnAttributeNameAndAliases(): void + { + self::assertSame( + ['dev-tools:self-update', 'self-update', 'selfupdate'], + SelfUpdateCommand::getCommandNames() + ); + } + + /** + * @return void + */ + #[Test] + public function executeWillUpdateProjectInstallation(): void + { + $this->scopeResolver->isGlobalInstallation() + ->willReturn(false); + $this->selfUpdateRunner->update(false, $this->output->reveal()) + ->willReturn(SelfUpdateCommand::SUCCESS) + ->shouldBeCalledOnce(); + + self::assertSame(SelfUpdateCommand::SUCCESS, $this->executeCommand()); + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnFailureWhenUpdateFails(): void + { + $this->scopeResolver->isGlobalInstallation() + ->willReturn(false); + $this->selfUpdateRunner->update(false, $this->output->reveal()) + ->willReturn(SelfUpdateCommand::FAILURE) + ->shouldBeCalledOnce(); + + self::assertSame(SelfUpdateCommand::FAILURE, $this->executeCommand()); + } + + /** + * @return void + */ + #[Test] + public function executeWillUpdateGlobalInstallationWhenCurrentBinaryIsGlobal(): void + { + $this->scopeResolver->isGlobalInstallation() + ->willReturn(true); + $this->selfUpdateRunner->update(true, $this->output->reveal()) + ->willReturn(SelfUpdateCommand::SUCCESS) + ->shouldBeCalledOnce(); + + self::assertSame(SelfUpdateCommand::SUCCESS, $this->executeCommand()); + } + + /** + * @return int + */ + private function executeCommand(): int + { + $method = new ReflectionMethod($this->command, 'execute'); + + return $method->invoke($this->command, $this->input->reveal(), $this->output->reveal()); + } +} diff --git a/tests/Console/CommandLoader/DevToolsCommandLoaderTest.php b/tests/Console/CommandLoader/DevToolsCommandLoaderTest.php index 9d8d26f591..ce936fe05b 100644 --- a/tests/Console/CommandLoader/DevToolsCommandLoaderTest.php +++ b/tests/Console/CommandLoader/DevToolsCommandLoaderTest.php @@ -25,6 +25,7 @@ use FastForward\DevTools\Console\Command\TestsCommand; use FastForward\DevTools\Console\CommandLoader\DevToolsCommandLoader; use FastForward\DevTools\Filesystem\FinderFactoryInterface; +use FastForward\DevTools\Reflection\ClassReflection; use RuntimeException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -39,6 +40,7 @@ use Symfony\Component\Finder\SplFileInfo; #[CoversClass(DevToolsCommandLoader::class)] +#[UsesClass(ClassReflection::class)] #[UsesClass(AgentsCommand::class)] #[UsesClass(SyncCommand::class)] final class DevToolsCommandLoaderTest extends TestCase diff --git a/tests/Console/DevToolsTest.php b/tests/Console/DevToolsTest.php index 15406e2390..384d0cd222 100644 --- a/tests/Console/DevToolsTest.php +++ b/tests/Console/DevToolsTest.php @@ -20,10 +20,30 @@ namespace FastForward\DevTools\Tests\Console; use FastForward\DevTools\Console\CommandLoader\DevToolsCommandLoader; +use FastForward\DevTools\Console\Command\SelfUpdateCommand; use FastForward\DevTools\Console\DevTools; +use FastForward\DevTools\Console\Formatter\LogLevelOutputFormatter; +use FastForward\DevTools\Console\Output\GithubActionOutput; +use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Environment\RuntimeEnvironment; use FastForward\DevTools\Filesystem\FinderFactory; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Path\WorkingProjectPathResolver; +use FastForward\DevTools\Process\ColorPreservingProcessEnvironmentConfigurator; +use FastForward\DevTools\Process\CompositeProcessEnvironmentConfigurator; +use FastForward\DevTools\Process\ProcessBuilder; +use FastForward\DevTools\Process\ProcessQueue; +use FastForward\DevTools\Process\XdebugDisablingProcessEnvironmentConfigurator; +use FastForward\DevTools\Reflection\ClassReflection; +use FastForward\DevTools\SelfUpdate\ComposerSelfUpdateRunner; +use FastForward\DevTools\SelfUpdate\ComposerSelfUpdateScopeResolver; +use FastForward\DevTools\SelfUpdate\ComposerVersionChecker; +use FastForward\DevTools\SelfUpdate\SelfUpdateRunnerInterface; +use FastForward\DevTools\SelfUpdate\SelfUpdateScopeResolverInterface; +use FastForward\DevTools\SelfUpdate\VersionCheckNotifier; +use FastForward\DevTools\SelfUpdate\VersionCheckNotifierInterface; +use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcher; +use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcherInterface; use FastForward\DevTools\ServiceProvider\DevToolsServiceProvider; use Override; use PHPUnit\Framework\Attributes\CoversClass; @@ -41,6 +61,8 @@ use Symfony\Component\Console\Command\HelpCommand; use Symfony\Component\Console\Command\ListCommand; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; #[CoversClass(DevTools::class)] #[UsesClass(DevToolsPathResolver::class)] @@ -48,6 +70,21 @@ #[UsesClass(FinderFactory::class)] #[UsesClass(DevToolsServiceProvider::class)] #[UsesClass(WorkingProjectPathResolver::class)] +#[UsesClass(SelfUpdateCommand::class)] +#[UsesClass(ClassReflection::class)] +#[UsesClass(LogLevelOutputFormatter::class)] +#[UsesClass(GithubActionOutput::class)] +#[UsesClass(RuntimeEnvironment::class)] +#[UsesClass(ColorPreservingProcessEnvironmentConfigurator::class)] +#[UsesClass(CompositeProcessEnvironmentConfigurator::class)] +#[UsesClass(ProcessBuilder::class)] +#[UsesClass(ProcessQueue::class)] +#[UsesClass(XdebugDisablingProcessEnvironmentConfigurator::class)] +#[UsesClass(ComposerSelfUpdateRunner::class)] +#[UsesClass(ComposerSelfUpdateScopeResolver::class)] +#[UsesClass(ComposerVersionChecker::class)] +#[UsesClass(VersionCheckNotifier::class)] +#[UsesClass(WorkingDirectorySwitcher::class)] final class DevToolsTest extends TestCase { use ProphecyTrait; @@ -57,6 +94,31 @@ final class DevToolsTest extends TestCase */ private ObjectProphecy $commandLoader; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $workingDirectorySwitcher; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $versionCheckNotifier; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $selfUpdateRunner; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $selfUpdateScopeResolver; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $environment; + private DevTools $devTools; /** @@ -70,7 +132,12 @@ protected function setUp(): void ->willReturn([]); $this->commandLoader->has(Argument::type('string')) ->willReturn(false); - $this->devTools = new DevTools($this->commandLoader->reveal()); + $this->workingDirectorySwitcher = $this->prophesize(WorkingDirectorySwitcherInterface::class); + $this->versionCheckNotifier = $this->prophesize(VersionCheckNotifierInterface::class); + $this->selfUpdateRunner = $this->prophesize(SelfUpdateRunnerInterface::class); + $this->selfUpdateScopeResolver = $this->prophesize(SelfUpdateScopeResolverInterface::class); + $this->environment = $this->prophesize(EnvironmentInterface::class); + $this->devTools = $this->createDevTools(); } /** @@ -113,6 +180,19 @@ public function __construct() self::assertSame($customCommand, $this->devTools->get('custom')); } + /** + * @return void + */ + #[Test] + public function constructorWillRegisterGlobalRuntimeOptions(): void + { + $definition = $this->devTools->getDefinition(); + + self::assertTrue($definition->hasOption('working-dir')); + self::assertSame('d', $definition->getOption('working-dir')->getShortcut()); + self::assertTrue($definition->hasOption('auto-update')); + } + /** * @return void */ @@ -170,6 +250,72 @@ public function createWillReturnInstanceOfDevTools(): void self::assertSame($devTools, DevTools::create()); } + /** + * @return void + */ + #[Test] + public function isSelfUpdateCommandWillUseSelfUpdateCommandAttributeNamesAndAliases(): void + { + foreach (['dev-tools:self-update', 'self-update', 'selfupdate'] as $commandName) { + $input = $this->prophesize(InputInterface::class); + $input->getFirstArgument() + ->willReturn($commandName); + + self::assertTrue($this->invokeIsSelfUpdateCommand($input->reveal())); + } + } + + /** + * @return void + */ + #[Test] + public function isSelfUpdateCommandWillRejectOtherCommands(): void + { + $input = $this->prophesize(InputInterface::class); + $input->getFirstArgument() + ->willReturn('standards'); + + self::assertFalse($this->invokeIsSelfUpdateCommand($input->reveal())); + } + + /** + * @return void + */ + #[Test] + public function runAutoUpdateWhenRequestedWillUpdateGlobalInstallationWhenCurrentBinaryIsGlobal(): void + { + $input = $this->prophesize(InputInterface::class); + $output = $this->prophesize(OutputInterface::class); + $input->hasParameterOption('--auto-update', true) + ->willReturn(true); + $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + ->willReturn(''); + $this->selfUpdateScopeResolver->isGlobalInstallation() + ->willReturn(true); + $this->selfUpdateRunner->update(true, $output->reveal()) + ->willReturn(Command::SUCCESS) + ->shouldBeCalledOnce(); + $output->writeln(Argument::type('string')) + ->shouldNotBeCalled(); + + $this->invokeRunAutoUpdateWhenRequested($input->reveal(), $output->reveal()); + } + + /** + * @return DevTools + */ + private function createDevTools(): DevTools + { + return new DevTools( + $this->commandLoader->reveal(), + $this->workingDirectorySwitcher->reveal(), + $this->versionCheckNotifier->reveal(), + $this->selfUpdateRunner->reveal(), + $this->selfUpdateScopeResolver->reveal(), + $this->environment->reveal(), + ); + } + /** * @param DevTools $devTools * @@ -184,4 +330,28 @@ private function invokeGetDefaultCommands(DevTools $devTools): array return $commands; } + + /** + * @param InputInterface $input + * + * @return bool + */ + private function invokeIsSelfUpdateCommand(InputInterface $input): bool + { + $reflectionMethod = new ReflectionMethod($this->devTools, 'isSelfUpdateCommand'); + + return (bool) $reflectionMethod->invoke($this->devTools, $input); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return void + */ + private function invokeRunAutoUpdateWhenRequested(InputInterface $input, OutputInterface $output): void + { + $reflectionMethod = new ReflectionMethod($this->devTools, 'runAutoUpdateWhenRequested'); + $reflectionMethod->invoke($this->devTools, $input, $output); + } } diff --git a/tests/Console/Logger/OutputFormatLoggerTest.php b/tests/Console/Logger/OutputFormatLoggerTest.php index 851f8cc503..a5bf3de286 100644 --- a/tests/Console/Logger/OutputFormatLoggerTest.php +++ b/tests/Console/Logger/OutputFormatLoggerTest.php @@ -27,7 +27,7 @@ use FastForward\DevTools\Console\Logger\Processor\CommandOutputProcessor; use FastForward\DevTools\Console\Logger\Processor\CompositeContextProcessor; use FastForward\DevTools\Console\Output\GithubActionOutput; -use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -59,7 +59,7 @@ final class OutputFormatLoggerTest extends TestCase private ObjectProphecy $clock; /** - * @var ObjectProphecy + * @var ObjectProphecy */ private ObjectProphecy $environment; @@ -83,9 +83,9 @@ protected function setUp(): void $this->output = $this->prophesize(ConsoleOutputInterface::class); $this->errorOutput = $this->prophesize(OutputInterface::class); $this->clock = $this->prophesize(ClockInterface::class); - $this->environment = $this->prophesize(EnvironmentInterface::class); - $this->environment->get(Argument::type('string'), Argument::cetera()) - ->willReturn(null); + $this->environment = $this->prophesize(RuntimeEnvironmentInterface::class); + $this->environment->isGithubActions() + ->willReturn(false); $this->output->getErrorOutput() ->willReturn($this->errorOutput->reveal()); diff --git a/tests/Environment/RuntimeEnvironmentTest.php b/tests/Environment/RuntimeEnvironmentTest.php new file mode 100644 index 0000000000..6364b36fb4 --- /dev/null +++ b/tests/Environment/RuntimeEnvironmentTest.php @@ -0,0 +1,156 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Environment; + +use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(RuntimeEnvironment::class)] +final class RuntimeEnvironmentTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $environment; + + private RuntimeEnvironment $runtimeEnvironment; + + /** + * @return iterable + */ + public static function truthyEnvironmentFlagsProvider(): iterable + { + yield 'missing' => [ + 'value' => null, + 'enabled' => false, + ]; + yield 'empty' => [ + 'value' => '', + 'enabled' => false, + ]; + yield 'zero' => [ + 'value' => '0', + 'enabled' => false, + ]; + yield 'false' => [ + 'value' => 'false', + 'enabled' => false, + ]; + yield 'one' => [ + 'value' => '1', + 'enabled' => true, + ]; + yield 'true' => [ + 'value' => 'true', + 'enabled' => true, + ]; + yield 'yes' => [ + 'value' => 'yes', + 'enabled' => true, + ]; + yield 'on' => [ + 'value' => 'on', + 'enabled' => true, + ]; + } + + /** + * @return void + */ + protected function setUp(): void + { + $this->environment = $this->prophesize(EnvironmentInterface::class); + $this->runtimeEnvironment = new RuntimeEnvironment($this->environment->reveal()); + } + + /** + * @param string|null $value + * @param bool $enabled + * + * @return void + */ + #[DataProvider('truthyEnvironmentFlagsProvider')] + #[Test] + public function isEnabledWillReturnWhetherEnvironmentFlagIsTruthy(?string $value, bool $enabled): void + { + $this->environment->get('FEATURE_FLAG', '') + ->willReturn($value); + + self::assertSame($enabled, $this->runtimeEnvironment->isEnabled('FEATURE_FLAG')); + } + + /** + * @return void + */ + #[Test] + public function isGithubActionsWillReturnWhetherGithubActionsFlagIsEnabled(): void + { + $this->environment->get('GITHUB_ACTIONS', '') + ->willReturn('true'); + + self::assertTrue($this->runtimeEnvironment->isGithubActions()); + } + + /** + * @return void + */ + #[Test] + public function isCiWillReturnTrueWhenGithubActionsIsEnabled(): void + { + $this->environment->get('GITHUB_ACTIONS', '') + ->willReturn('true'); + + self::assertTrue($this->runtimeEnvironment->isCi()); + } + + /** + * @return void + */ + #[Test] + public function isCiWillReturnTrueWhenGenericCiIsEnabled(): void + { + $this->environment->get('GITHUB_ACTIONS', '') + ->willReturn(''); + $this->environment->get('CI', '') + ->willReturn('1'); + + self::assertTrue($this->runtimeEnvironment->isCi()); + } + + /** + * @return void + */ + #[Test] + public function isComposerTestRunWillReturnWhetherComposerTestsFlagIsEnabled(): void + { + $this->environment->get('COMPOSER_TESTS_ARE_RUNNING', '') + ->willReturn('true'); + + self::assertTrue($this->runtimeEnvironment->isComposerTestRun()); + } +} diff --git a/tests/Process/ProcessQueueTest.php b/tests/Process/ProcessQueueTest.php index 92488dd852..172385134c 100644 --- a/tests/Process/ProcessQueueTest.php +++ b/tests/Process/ProcessQueueTest.php @@ -22,7 +22,7 @@ use Closure; use FastForward\DevTools\Console\Output\GithubActionOutput; use FastForward\DevTools\Console\Output\OutputCapabilityDetectorInterface; -use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; use FastForward\DevTools\Process\ProcessEnvironmentConfiguratorInterface; use FastForward\DevTools\Process\ProcessQueue; use FastForward\DevTools\Process\ProcessQueueInterface; @@ -73,7 +73,7 @@ final class ProcessQueueTest extends TestCase private ObjectProphecy $environmentConfigurator; /** - * @var ObjectProphecy + * @var ObjectProphecy */ private ObjectProphecy $environment; @@ -108,9 +108,9 @@ protected function setUp(): void $this->environmentConfigurator = $this->prophesize(ProcessEnvironmentConfiguratorInterface::class); - $this->environment = $this->prophesize(EnvironmentInterface::class); - $this->environment->get('GITHUB_ACTIONS') - ->willReturn(null); + $this->environment = $this->prophesize(RuntimeEnvironmentInterface::class); + $this->environment->isGithubActions() + ->willReturn(false); $this->outputCapabilityDetector = $this->prophesize(OutputCapabilityDetectorInterface::class); $this->outputCapabilityDetector->supportsAnsi(Argument::type(OutputInterface::class)) diff --git a/tests/Reflection/ClassReflectionTest.php b/tests/Reflection/ClassReflectionTest.php new file mode 100644 index 0000000000..fc365df7d2 --- /dev/null +++ b/tests/Reflection/ClassReflectionTest.php @@ -0,0 +1,97 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Reflection; + +use FastForward\DevTools\Console\Command\SelfUpdateCommand; +use FastForward\DevTools\Reflection\ClassReflection; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; + +#[CoversClass(ClassReflection::class)] +final class ClassReflectionTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function isInstantiableSubclassOfWillReturnTrueForMatchingClass(): void + { + self::assertTrue(ClassReflection::isInstantiableSubclassOf(SelfUpdateCommand::class, Command::class)); + } + + /** + * @return void + */ + #[Test] + public function isInstantiableSubclassOfWillReturnFalseForNonMatchingClass(): void + { + self::assertFalse(ClassReflection::isInstantiableSubclassOf(self::class, Command::class)); + } + + /** + * @return void + */ + #[Test] + public function getAttributeArgumentsWillReturnArgumentsForMatchingAttribute(): void + { + self::assertSame([ + 'name' => 'dev-tools:self-update', + 'description' => 'Updates the installed fast-forward/dev-tools package.', + 'aliases' => ['self-update', 'selfupdate'], + 'hidden' => false, + 'help' => null, + 'usages' => [], + ], ClassReflection::getAttributeArguments(SelfUpdateCommand::class, AsCommand::class)); + } + + /** + * @return void + */ + #[Test] + public function getAttributeArgumentsWillReturnNullWhenAttributeDoesNotExist(): void + { + self::assertNull(ClassReflection::getAttributeArguments(self::class, AsCommand::class)); + } + + /** + * @return void + */ + #[Test] + public function getAttributeArgumentsWillNormalizePositionalArguments(): void + { + self::assertSame([ + 'name' => 'fixture', + 'description' => 'Fixture command.', + 'aliases' => ['alias'], + 'hidden' => false, + 'help' => null, + 'usages' => [], + ], ClassReflection::getAttributeArguments(FixtureCommandWithPositionalAttribute::class, AsCommand::class)); + } +} + +/** + * Fixture command that declares AsCommand through positional arguments. + */ +#[AsCommand('fixture', 'Fixture command.', ['alias'])] +final class FixtureCommandWithPositionalAttribute extends Command {} diff --git a/tests/SelfUpdate/ComposerSelfUpdateRunnerTest.php b/tests/SelfUpdate/ComposerSelfUpdateRunnerTest.php new file mode 100644 index 0000000000..6520479645 --- /dev/null +++ b/tests/SelfUpdate/ComposerSelfUpdateRunnerTest.php @@ -0,0 +1,116 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\SelfUpdate; + +use FastForward\DevTools\Process\ProcessBuilderInterface; +use FastForward\DevTools\Process\ProcessQueueInterface; +use FastForward\DevTools\SelfUpdate\ComposerSelfUpdateRunner; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +#[CoversClass(ComposerSelfUpdateRunner::class)] +final class ComposerSelfUpdateRunnerTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $processBuilder; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $processQueue; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $process; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $output; + + private ComposerSelfUpdateRunner $runner; + + /** + * @return void + */ + protected function setUp(): void + { + $this->processBuilder = $this->prophesize(ProcessBuilderInterface::class); + $this->processQueue = $this->prophesize(ProcessQueueInterface::class); + $this->process = $this->prophesize(Process::class); + $this->output = $this->prophesize(OutputInterface::class); + $this->processBuilder->withArgument('fast-forward/dev-tools') + ->willReturn($this->processBuilder->reveal()); + $this->runner = new ComposerSelfUpdateRunner( + $this->processBuilder->reveal(), + $this->processQueue->reveal(), + ); + } + + /** + * @return void + */ + #[Test] + public function updateWillRunProjectComposerUpdate(): void + { + $this->processBuilder->build('composer update') + ->willReturn($this->process->reveal()); + $this->processQueue->add( + $this->process->reveal(), + false, + false, + 'Updating project DevTools installation', + )->shouldBeCalledOnce(); + $this->processQueue->run($this->output->reveal()) + ->willReturn(ProcessQueueInterface::SUCCESS); + + self::assertSame(ProcessQueueInterface::SUCCESS, $this->runner->update(false, $this->output->reveal())); + } + + /** + * @return void + */ + #[Test] + public function updateWillRunGlobalComposerUpdate(): void + { + $this->processBuilder->build('composer global update') + ->willReturn($this->process->reveal()); + $this->processQueue->add( + $this->process->reveal(), + false, + false, + 'Updating global DevTools installation', + )->shouldBeCalledOnce(); + $this->processQueue->run($this->output->reveal()) + ->willReturn(ProcessQueueInterface::SUCCESS); + + self::assertSame(ProcessQueueInterface::SUCCESS, $this->runner->update(true, $this->output->reveal())); + } +} diff --git a/tests/SelfUpdate/ComposerSelfUpdateScopeResolverTest.php b/tests/SelfUpdate/ComposerSelfUpdateScopeResolverTest.php new file mode 100644 index 0000000000..84c87d5f87 --- /dev/null +++ b/tests/SelfUpdate/ComposerSelfUpdateScopeResolverTest.php @@ -0,0 +1,107 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\SelfUpdate; + +use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\SelfUpdate\ComposerSelfUpdateScopeResolver; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +#[CoversClass(ComposerSelfUpdateScopeResolver::class)] +final class ComposerSelfUpdateScopeResolverTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $environment; + + /** + * @return void + */ + protected function setUp(): void + { + $this->environment = $this->prophesize(EnvironmentInterface::class); + } + + /** + * @return void + */ + #[Test] + public function isGlobalInstallationWillReturnTrueWhenPackageLivesUnderComposerHome(): void + { + $this->environment->get('COMPOSER_HOME') + ->willReturn('/home/felipe/.composer'); + $this->environment->get('HOME') + ->willReturn(null); + $this->environment->get('APPDATA') + ->willReturn(null); + $resolver = new ComposerSelfUpdateScopeResolver( + $this->environment->reveal(), + '/home/felipe/.composer/vendor/fast-forward/dev-tools', + ); + + self::assertTrue($resolver->isGlobalInstallation()); + } + + /** + * @return void + */ + #[Test] + public function isGlobalInstallationWillReturnTrueWhenPackageLivesUnderDefaultComposerHome(): void + { + $this->environment->get('COMPOSER_HOME') + ->willReturn(null); + $this->environment->get('HOME') + ->willReturn('/Users/felipe'); + $this->environment->get('APPDATA') + ->willReturn(null); + $resolver = new ComposerSelfUpdateScopeResolver( + $this->environment->reveal(), + '/Users/felipe/Library/Application Support/Composer/vendor/fast-forward/dev-tools', + ); + + self::assertTrue($resolver->isGlobalInstallation()); + } + + /** + * @return void + */ + #[Test] + public function isGlobalInstallationWillReturnFalseWhenPackageLivesUnderProjectVendor(): void + { + $this->environment->get('COMPOSER_HOME') + ->willReturn('/home/felipe/.composer'); + $this->environment->get('HOME') + ->willReturn('/home/felipe'); + $this->environment->get('APPDATA') + ->willReturn(null); + $resolver = new ComposerSelfUpdateScopeResolver( + $this->environment->reveal(), + '/home/felipe/project/vendor/fast-forward/dev-tools', + ); + + self::assertFalse($resolver->isGlobalInstallation()); + } +} diff --git a/tests/SelfUpdate/VersionCheckNotifierTest.php b/tests/SelfUpdate/VersionCheckNotifierTest.php new file mode 100644 index 0000000000..f0c274e7ee --- /dev/null +++ b/tests/SelfUpdate/VersionCheckNotifierTest.php @@ -0,0 +1,146 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\SelfUpdate; + +use Prophecy\Argument; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; +use FastForward\DevTools\SelfUpdate\VersionCheckerInterface; +use FastForward\DevTools\SelfUpdate\VersionCheckNotifier; +use FastForward\DevTools\SelfUpdate\VersionCheckResult; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use RuntimeException; +use Symfony\Component\Console\Output\OutputInterface; + +#[CoversClass(VersionCheckNotifier::class)] +#[UsesClass(VersionCheckResult::class)] +final class VersionCheckNotifierTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $versionChecker; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $output; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $environment; + + private VersionCheckNotifier $notifier; + + /** + * @return void + */ + protected function setUp(): void + { + $this->versionChecker = $this->prophesize(VersionCheckerInterface::class); + $this->output = $this->prophesize(OutputInterface::class); + $this->environment = $this->prophesize(RuntimeEnvironmentInterface::class); + $this->notifier = new VersionCheckNotifier($this->versionChecker->reveal(), $this->environment->reveal()); + } + + /** + * @return void + */ + #[Test] + public function notifyWillWriteWarningWhenDevToolsIsOutdated(): void + { + $this->willRunVersionCheck(); + $this->versionChecker->check() + ->willReturn(new VersionCheckResult('1.2.0', 'v1.3.0')); + $this->output->writeln( + 'DevTools v1.3.0 is available; current version is 1.2.0. ' + . 'Run "dev-tools self-update" to update.', + )->shouldBeCalledOnce(); + + $this->notifier->notify($this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function notifyWillStaySilentWhenCheckFails(): void + { + $this->willRunVersionCheck(); + $this->versionChecker->check() + ->willThrow(new RuntimeException('network unavailable')); + $this->output->writeln(Argument::any()) + ->shouldNotBeCalled(); + + $this->notifier->notify($this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function notifyWillStaySilentInCi(): void + { + $this->environment->isCi() + ->willReturn(true); + $this->versionChecker->check() + ->shouldNotBeCalled(); + $this->output->writeln(Argument::any()) + ->shouldNotBeCalled(); + + $this->notifier->notify($this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function notifyWillStaySilentWhenVersionCheckIsDisabled(): void + { + $this->environment->isCi() + ->willReturn(false); + $this->environment->isEnabled('FAST_FORWARD_SKIP_VERSION_CHECK') + ->willReturn(true); + $this->versionChecker->check() + ->shouldNotBeCalled(); + $this->output->writeln(Argument::any()) + ->shouldNotBeCalled(); + + $this->notifier->notify($this->output->reveal()); + } + + /** + * @return void + */ + private function willRunVersionCheck(): void + { + $this->environment->isCi() + ->willReturn(false); + $this->environment->isEnabled('FAST_FORWARD_SKIP_VERSION_CHECK') + ->willReturn(false); + } +} diff --git a/tests/SelfUpdate/VersionCheckResultTest.php b/tests/SelfUpdate/VersionCheckResultTest.php new file mode 100644 index 0000000000..9d5416b9d7 --- /dev/null +++ b/tests/SelfUpdate/VersionCheckResultTest.php @@ -0,0 +1,56 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\SelfUpdate; + +use FastForward\DevTools\SelfUpdate\VersionCheckResult; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; + +#[CoversClass(VersionCheckResult::class)] +final class VersionCheckResultTest extends TestCase +{ + use ProphecyTrait; + + /** + * @return void + */ + #[Test] + public function isOutdatedWillReturnTrueWhenLatestStableVersionIsNewer(): void + { + $result = new VersionCheckResult('1.2.0', 'v1.3.0'); + + self::assertTrue($result->isOutdated()); + self::assertSame('1.2.0', $result->getCurrentVersion()); + self::assertSame('v1.3.0', $result->getLatestVersion()); + } + + /** + * @return void + */ + #[Test] + public function isOutdatedWillReturnFalseWhenVersionsMatch(): void + { + $result = new VersionCheckResult('1.3.0', 'v1.3.0'); + + self::assertFalse($result->isOutdated()); + } +} diff --git a/tests/ServiceProvider/DevToolsServiceProviderTest.php b/tests/ServiceProvider/DevToolsServiceProviderTest.php index 6ee0bddceb..aa18e58850 100644 --- a/tests/ServiceProvider/DevToolsServiceProviderTest.php +++ b/tests/ServiceProvider/DevToolsServiceProviderTest.php @@ -19,14 +19,24 @@ namespace FastForward\DevTools\Tests\ServiceProvider; +use DI\Container; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\ServiceProvider\DevToolsServiceProvider; +use Interop\Container\ServiceProviderInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -use Interop\Container\ServiceProviderInterface; +use Symfony\Component\Config\FileLocatorInterface; + +use function Safe\chdir; +use function Safe\file_put_contents; +use function Safe\getcwd; +use function Safe\mkdir; +use function Safe\rmdir; +use function Safe\tempnam; +use function Safe\unlink; #[CoversClass(DevToolsServiceProvider::class)] #[UsesClass(DevToolsPathResolver::class)] @@ -35,12 +45,39 @@ final class DevToolsServiceProviderTest extends TestCase { private DevToolsServiceProvider $provider; + private string $originalWorkingDirectory; + + private string $workspaceDirectory; + + private string $workspaceResourcePath; + /** * @return void */ protected function setUp(): void { $this->provider = new DevToolsServiceProvider(); + $this->originalWorkingDirectory = getcwd(); + $this->workspaceDirectory = tempnam(sys_get_temp_dir(), 'dev-tools-service-provider-'); + unlink($this->workspaceDirectory); + mkdir($this->workspaceDirectory); + $this->workspaceResourcePath = $this->workspaceDirectory . '/local-resource.txt'; + } + + /** + * @return void + */ + protected function tearDown(): void + { + chdir($this->originalWorkingDirectory); + + if (file_exists($this->workspaceResourcePath)) { + unlink($this->workspaceResourcePath); + } + + if (is_dir($this->workspaceDirectory)) { + rmdir($this->workspaceDirectory); + } } /** @@ -72,4 +109,21 @@ public function getFactoriesReturnFactories(): void self::assertIsArray($factories); self::assertNotEmpty($factories); } + + /** + * @return void + */ + #[Test] + public function fileLocatorResolvesWorkingProjectPathWhenTheServiceIsRequested(): void + { + $container = new Container($this->provider->getFactories()); + file_put_contents($this->workspaceResourcePath, 'fixture'); + + chdir($this->workspaceDirectory); + + $fileLocator = $container->get(FileLocatorInterface::class); + + self::assertInstanceOf(FileLocatorInterface::class, $fileLocator); + self::assertSame($this->workspaceResourcePath, $fileLocator->locate('local-resource.txt')); + } }