diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index c0fbc53..9652006 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -15,14 +15,14 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" + - "8.6" steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -36,7 +36,7 @@ jobs: run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV - name: Cache dependencies installed with composer - uses: actions/cache@v3 + uses: actions/cache@v6 with: path: ${{ env.COMPOSER_CACHE_DIR }} key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} @@ -50,6 +50,6 @@ jobs: run: vendor/bin/phpunit --colors=always --coverage-clover clover.xml - name: Send code coverage report to Codecov.io - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 9976515..2fc9924 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -4,7 +4,7 @@ on: name: Run PHPStan checks jobs: - mutation: + static-analysis: name: PHPStan ${{ matrix.php }}-${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -15,14 +15,14 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" + - "8.6" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install PHP uses: shivammathur/setup-php@v2 @@ -36,7 +36,7 @@ jobs: run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV - name: Cache dependencies installed with composer - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ env.COMPOSER_CACHE_DIR }} key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} @@ -47,4 +47,4 @@ jobs: run: composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi - name: Run static analysis with PHPStan - run: vendor/bin/phpstan analyse + run: vendor/bin/phpstan analyse --memory-limit 1G diff --git a/.gitignore b/.gitignore index be50580..f26483c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ composer.phar /vendor/ .phpcs-cache .phpunit.result.cache +/.phpunit.cache/ # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file diff --git a/.laminas-ci.json b/.laminas-ci.json index d7d1ae0..efdd72e 100644 --- a/.laminas-ci.json +++ b/.laminas-ci.json @@ -1,6 +1,6 @@ { "ignore_php_platform_requirements": { - "8.5": true + "8.6": true }, - "backwardCompatibilityCheck": true + "backwardCompatibilityCheck": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 65640b4..359af1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,495 @@ -## 3.5.0 - 2024-02-08 - -### Changed -* Removed PHP 8.1 support. - -### Added -* CHANGELOG.md - -### Deprecated -* Nothing - -### Removed -* Nothing - -### Fixed -* Nothing \ No newline at end of file +# Changelog + +All notable changes to this project are documented in this file. + +Dates before 4.0.0 are the commit dates of the tagged commits. + +## 4.0.0 - Unreleased + +### Changed + +- Minimum PHP version is now 8.3. Supported versions: 8.3, 8.4, 8.5 and 8.6. +- A command's lock now uses its resolved name, so aliases and abbreviations share one lock. Unknown commands are not locked. +- Lock errors are written to the error output with quiet verbosity, so they are shown with `-q` (use `--silent` to hide them). +- A missing or unwritable lock directory, or a lock file that cannot be opened, throws `Dot\Cli\Exception\RuntimeException` instead of a `TypeError`. +- `FileLocker::getLockFilePath()` throws `Dot\Cli\Exception\RuntimeException` when the directory or the command name is missing. +- `FileLocker::unlock()` releases a held lock even after `disable()`, and does nothing when no lock is held. +- The custom event dispatcher is read from the `Laminas\Cli\SymfonyEventDispatcher` service, the key laminas-cli uses, and must implement `Symfony\Component\EventDispatcher\EventDispatcherInterface`. +- The factories throw `Dot\Cli\Exception\InvalidConfigException` (an `Exception` and a PSR-11 `ContainerExceptionInterface`) instead of `Exception`. +- `ApplicationFactory::__invoke()` returns `Dot\Cli\Application`. +- The `MESSAGE_*` constants are typed (`public const string`). +- `config/autoload/cli.global.php` uses the command name as the key instead of `DemoCommand::getDefaultName()`, which Symfony Console 8 removed. +- Development tooling: PHPUnit 12 with strict `failOn*` flags, PHPStan level 8, GitHub Actions run PHP 8.3 to 8.6 with `actions/checkout@v7`, `actions/cache@v6` and `codecov/codecov-action@v7`, and the Laminas backward-compatibility check is disabled for this major version. +- Documentation for 4.0 is available in `docs/book/v4`, including an upgrade guide from 3.x. + +### Added + +- `Dot\Cli\Exception\ExceptionInterface`, `Dot\Cli\Exception\RuntimeException` and `Dot\Cli\Exception\InvalidConfigException`. +- `ApplicationFactory::EVENT_DISPATCHER` and `ApplicationFactory::LEGACY_EVENT_DISPATCHER`. +- `psr/container`, `symfony/console` and `symfony/event-dispatcher` as direct requirements. +- Tests with a minimal PSR-11 container and a real temporary directory, covering lock contention, aliases, abbreviations, quiet output and the event dispatcher keys. + +### Deprecated + +- The `Dot\Cli\Factory\SymfonyEventDispatcher` service key. Use `Laminas\Cli\SymfonyEventDispatcher`. It will be removed in 5.0. + +### Removed + +- PHP 8.2 support. +- `laminas/laminas-servicemanager` as a requirement. dot-cli only uses PSR-11; applications that use Laminas Service Manager require it themselves. +- `DemoCommand::$defaultName` and `DemoCommand::configure()`. The name and description come from the `#[AsCommand]` attribute. +- `mikey179/vfsstream` as a development requirement. Lock tests use a real temporary directory. + +### Fixed + +- A running command could be started again through one of its aliases or an abbreviation of its name. +- A missing lock directory caused a `TypeError` in `Application::doRun()` and a second one in `Application::__destruct()`. +- `FileLocker::unlock()` caused a `TypeError` when called before `lock()` or twice. +- When `flock()` failed without reporting contention, the command ran without a lock. +- Calling `FileLocker::lock()` twice leaked a file handle and reported that a different process held the lock. +- `FileLocker::setDirPath(null)` and `FileLocker::getLockFilePath()` without a command name caused a `TypeError`. +- With `-q`, the lock error was not shown. +- An event dispatcher registered under the laminas-cli key was ignored. +- The shipped `config/autoload/cli.global.php` failed on Symfony Console 8. + +## 3.11.1 - 2026-05-27 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- README badges. + +## 3.11.0 - 2026-05-27 + +### Changed + +- `laminas/laminas-cli` is required as `^1.15` (#49). This release was tagged as `3.11`. + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.10.0 - 2025-10-29 + +### Changed + +- Supported PHP versions are 8.2, 8.3, 8.4 and 8.5 (#47). + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- PHP 8.1 from the CI matrix (#47). + +### Fixed + +- Nothing + +## 3.9.0 - 2025-02-28 + +### Changed + +- Static analysis uses PHPStan instead of Psalm (#45). + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.8.1 - 2025-01-27 + +### Changed + +- Psalm 6.x (#43). + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.8.0 - 2025-01-24 + +### Changed + +- Nothing + +### Added + +- PHP 8.4 support (#40). + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.7.0 - 2025-01-08 + +### Changed + +- Nothing + +### Added + +- `laminas/laminas-servicemanager` 4.x support (#37). + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- `docs/book/index.md` and README badges. + +## 3.6.1 - 2024-07-04 + +### Changed + +- The package is described as a wrapper on top of laminas-cli. + +### Added + +- Lock files documentation. + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Continuous integration badge. +- Typo in a documented command. + +## 3.6.0 - 2024-05-03 + +### Changed + +- `mikey179/vfsstream` updated. + +### Added + +- Documentation for v3. + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- `mkdocs.yml` and formatting reported by the QA checks. + +## 3.5.0 - 2024-02-08 + +### Changed + +- Removed PHP 8.1 support. + +### Added + +- CHANGELOG.md + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Command name issue. + +## 3.4.2 - 2023-11-29 + +### Changed + +- Nothing + +### Added + +- PHP 8.3 support. +- Tests, bringing code coverage to 100% (#20). + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.4.1 - 2023-08-22 + +### Changed + +- Nothing + +### Added + +- Codecov and its badge (#16, #18). + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.4.0 - 2023-07-05 + +### Changed + +- Nothing + +### Added + +- Unit tests, coding standard checks and static analysis (#13). + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.3.0 - 2023-03-29 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- `laminas/laminas-dependency-plugin`. + +### Fixed + +- Nothing + +## 3.2.1 - 2023-03-09 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- The command that lists all commands (#7). + +## 3.2.0 - 2022-04-06 + +### Changed + +- `composer.json` and README updates. + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.1.0 - 2022-02-03 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- The `laminas/laminas-cli` version constraint. + +## 3.0.2 - 2021-08-13 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Issue #2. + +## 3.0.1 - 2021-05-21 + +### Changed + +- Minor improvements. + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.0.0 - 2021-05-17 + +### Changed + +- Nothing + +### Added + +- Initial 3.x release, based on laminas-cli. + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing diff --git a/README.md b/README.md index 9d247c7..3fe79bd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # dot-cli -Dotkernel component to build console applications based on [laminas-cli](https://github.com/laminas/laminas-cli). +Dotkernel component to build console applications based on [laminas-cli](https://github.com/laminas/laminas-cli), for any PSR-11 container. > dot-cli is a wrapper on top of [laminas-cli](https://github.com/laminas/laminas-cli) @@ -11,22 +11,32 @@ Documentation is available at: https://docs.dotkernel.org/dot-cli/. ## Badges ![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/dot-cli) -![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-cli/3.11.1) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-cli/4.0.0) [![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-cli)](https://github.com/dotkernel/dot-cli/issues) [![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-cli)](https://github.com/dotkernel/dot-cli/network) [![GitHub stars](https://img.shields.io/github/stars/dotkernel/dot-cli)](https://github.com/dotkernel/dot-cli/stargazers) -[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-cli)](https://github.com/dotkernel/dot-cli/blob/3.0/LICENSE) +[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-cli)](https://github.com/dotkernel/dot-cli/blob/4.0/LICENSE) -[![Build Static](https://github.com/dotkernel/dot-cli/actions/workflows/continuous-integration.yml/badge.svg?branch=3.0)](https://github.com/dotkernel/dot-cli/actions/workflows/continuous-integration.yml) +[![Build Static](https://github.com/dotkernel/dot-cli/actions/workflows/continuous-integration.yml/badge.svg?branch=4.0)](https://github.com/dotkernel/dot-cli/actions/workflows/continuous-integration.yml) [![codecov](https://codecov.io/gh/dotkernel/dot-cli/graph/badge.svg?token=0DFCK2GUBT)](https://codecov.io/gh/dotkernel/dot-cli) -[![PHPStan](https://github.com/dotkernel/dot-cli/actions/workflows/static-analysis.yml/badge.svg?branch=3.0)](https://github.com/dotkernel/dot-cli/actions/workflows/static-analysis.yml) +[![PHPStan](https://github.com/dotkernel/dot-cli/actions/workflows/static-analysis.yml/badge.svg?branch=4.0)](https://github.com/dotkernel/dot-cli/actions/workflows/static-analysis.yml) + +## Versions + +| Version | Status | PHP | Documentation | +| --- | --- | --- | --- | +| 4.x | Active | 8.3, 8.4, 8.5, 8.6 | https://docs.dotkernel.org/dot-cli/v4/overview/ | +| 3.x | Security fixes only | 8.2, 8.3, 8.4, 8.5 | https://docs.dotkernel.org/dot-cli/v3/overview/ | + +Upgrading from 3.x? Read the [upgrade guide](https://docs.dotkernel.org/dot-cli/v4/upgrading/). ## Requirements -- **PHP**: 8.2, 8.3, 8.4 or 8.5 -- **laminas/laminas-servicemanager**: >= 3.11 || >= 4.0, +- **PHP**: 8.3, 8.4, 8.5 or 8.6 - **laminas/laminas-cli**: >= 1.15 +- **symfony/console** and **symfony/event-dispatcher**: 7.4 or 8.x +- A PSR-11 container that reads `factories` and `aliases` configuration, for example `laminas/laminas-servicemanager` (require it in your application) ## Setup @@ -54,7 +64,15 @@ This is the bootstrap file you will use to execute your commands with. ### 4. Copy config file Locate in this package the following file `config/autoload/cli.global.php` then copy it to your application's `config/autoload/` directory. -This is the config file you will add your commands to. +This is the config file you will add your commands to, using the command name as the key: + +```php +'commands' => [ + 'demo:command' => Dot\Cli\Command\DemoCommand::class, +], +``` + +The lock files are written to `data/lock` by default. Make sure the directory exists and is writable. ## Testing @@ -74,14 +92,15 @@ Usage: Options: -h, --help Display help for the given command. When no command is given display help for the list command - -q, --quiet Do not output any message + --silent Do not output any message + -q, --quiet Only errors are displayed. All other output is suppressed -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: + completion Dump the shell completion script help Display help for a command list List commands demo @@ -94,18 +113,19 @@ For more information, see [laminas-cli documentation](https://docs.laminas.dev/l ## Setting up as a cronjob ```text -* * * * * /opt/plesk/php/7.4/bin/php /var/www/vhosts/example.com/httpdocs/bin/cli.php demo:command -q +* * * * * php /var/www/vhosts/example.com/httpdocs/bin/cli.php demo:command -q ``` or ```text -* * * * * cd /var/www/vhosts/example.com/httpdocs/bin && /opt/plesk/php/7.4/bin/php ./cli.php demo:command -q +* * * * * cd /var/www/vhosts/example.com/httpdocs/bin && php ./cli.php demo:command -q ``` -Adapt the command to your specifications by replacing **7.4** with your PHP version and **example.com** with your domain name. +Adapt the command to your specifications by replacing **example.com** with your domain name. -Note the **-q** (or **--quiet**) option at the end of the command - it serves as a flag to inform the Application that no output should be returned (unless it's an error). +The **-q** (or **--quiet**) option suppresses normal output, but errors are still written to stderr, including the lock error shown when the previous run of the same command has not finished yet. +Use **--silent** instead if you want no output at all. ## License diff --git a/SECURITY.md b/SECURITY.md index 54b5bcf..d442746 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,10 +2,11 @@ ## Supported Versions -| Version | Supported | PHP Version | -|---------|--------------------|----------------------------------------------------------------------------------------------------------| -| 3.x | :white_check_mark: | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-cli/3.11.1) | -| <= 2.x | :x: | | +| Version | Supported | PHP Version | +|---------|------------------------------------|----------------------------------------------------------------------------------------------------------| +| 4.x | :white_check_mark: | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-cli/4.0.0) | +| 3.x | :white_check_mark: (security only) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-cli/3.11.1) | +| <= 2.x | :x: | | ## Reporting Potential Security Issues diff --git a/composer.json b/composer.json index a02f3c0..cae7dbc 100644 --- a/composer.json +++ b/composer.json @@ -23,9 +23,11 @@ } }, "require": { - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0", "laminas/laminas-cli": "^1.15.0", - "laminas/laminas-servicemanager": "^3.11.1 || ^4.0" + "psr/container": "^1.1 || ^2.0", + "symfony/console": "^7.4 || ^8.0", + "symfony/event-dispatcher": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -34,10 +36,12 @@ }, "require-dev": { "laminas/laminas-coding-standard": "^3.0", - "mikey179/vfsstream": "^1.6.7", "phpstan/phpstan": "^2.1.17", "phpstan/phpstan-phpunit": "^2.0.6", - "phpunit/phpunit": "^10.2" + "phpunit/phpunit": "^12" + }, + "conflict": { + "symfony/event-dispatcher-contracts": "<2.5.3 || >=3.0,<3.5" }, "autoload-dev": { "psr-4": { diff --git a/config/autoload/cli.global.php b/config/autoload/cli.global.php index 2e7107e..96a3e40 100644 --- a/config/autoload/cli.global.php +++ b/config/autoload/cli.global.php @@ -10,7 +10,7 @@ 'version' => '1.0.0', 'name' => 'Dotkernel CLI', 'commands' => [ - DemoCommand::getDefaultName() => DemoCommand::class, + 'demo:command' => DemoCommand::class, ], ], FileLockerInterface::class => [ diff --git a/docs/book/v4/configuration.md b/docs/book/v4/configuration.md new file mode 100644 index 0000000..9165c00 --- /dev/null +++ b/docs/book/v4/configuration.md @@ -0,0 +1,48 @@ +# Configuration + +## Register ConfigProvider + +After installation, register `dot-cli` in your project by adding the below line to your configuration aggregator (usually: `config/config.php`): + +```php +Dot\Cli\ConfigProvider::class, +``` + +## Copy bootstrap file + +Locate file `bin/cli.php` in this package, then copy it to your application's `bin/` directory. +This is the bootstrap file you will use to execute your commands with. + +## Copy config file + +Locate in this package the following file `config/autoload/cli.global.php` then copy it to your application's `config/autoload/` directory. +Add your commands under the `commands` key, using the command name as the key and the command class as the value: + +```php +'commands' => [ + 'demo:command' => DemoCommand::class, + 'example:command' => ExampleCommand::class, +], +``` + +Use the name from the command's `#[AsCommand]` attribute. +Do not use `DemoCommand::getDefaultName()`: Symfony Console 8 removed that method, and Symfony Console 7.4 reports it as deprecated. + +Command classes that are registered in the container are fetched from it. +Command classes that are not registered are instantiated without arguments. + +## Custom event dispatcher + +By default, the application uses a new `Symfony\Component\EventDispatcher\EventDispatcher`. +To use your own, register it in the container under the key `Laminas\Cli\SymfonyEventDispatcher`, the same key laminas-cli uses. +The service must implement `Symfony\Component\EventDispatcher\EventDispatcherInterface`. + +```php +'dependencies' => [ + 'factories' => [ + 'Laminas\Cli\SymfonyEventDispatcher' => MyEventDispatcherFactory::class, + ], +], +``` + +The 3.x key `Dot\Cli\Factory\SymfonyEventDispatcher` still works, but it is deprecated and will be removed in 5.0. diff --git a/docs/book/v4/installation.md b/docs/book/v4/installation.md new file mode 100644 index 0000000..5ca6046 --- /dev/null +++ b/docs/book/v4/installation.md @@ -0,0 +1,19 @@ +# Installation + +## Requirements + +- PHP 8.3, 8.4, 8.5 or 8.6 +- `laminas/laminas-cli` 1.15 or newer +- `symfony/console` and `symfony/event-dispatcher` 7.4 or 8.x +- A PSR-11 container that reads `factories` and `aliases` configuration, for example `laminas/laminas-servicemanager` + +`dotkernel/dot-cli` no longer installs `laminas/laminas-servicemanager`. +If your application uses it, require it in your application's `composer.json`. + +## Install + +Install `dotkernel/dot-cli` by executing the following Composer command: + +```shell +composer require dotkernel/dot-cli +``` diff --git a/docs/book/v4/lock-files.md b/docs/book/v4/lock-files.md new file mode 100644 index 0000000..e40ea45 --- /dev/null +++ b/docs/book/v4/lock-files.md @@ -0,0 +1,36 @@ +# Lock Files + +When a command starts running, it takes an exclusive lock on a lock file named after the command, for example `command-demo-command.lock`. +While the lock is held, another run of the same command stops with exit code `1` and the message `The file lock is being held by a different process`. + +The lock is released when the command finishes. +If the process is killed, the operating system releases the lock, and the next run starts normally. +The lock file itself stays in the directory, which is expected: there is no need to delete it. + +The lock uses the command's real name, so its aliases and abbreviations share one lock. +For example, while `demo:command` is running, `demo:com` is rejected too. +Unknown commands are not locked. + +## Configuration + +Inside `config/autoload/cli.global.php` under the `FileLockerInterface::class` key the file locker can be disabled and the location of the lock files can be changed. + +```php +FileLockerInterface::class => [ + 'enabled' => true, + 'dirPath' => getcwd() . '/data/lock', +], +``` + +The directory in `dirPath` must exist and be writable by the user that runs the commands. +If it does not, every command fails with exit code `1` and a message like `The lock directory "..." does not exist or is not writable.` +Create it before running commands: + +```shell +mkdir -p data/lock +``` + +## Errors + +Lock errors are written to the error output (stderr), and they are shown even with the `-q` (`--quiet`) option. +Use `--silent` to suppress them. diff --git a/docs/book/v4/overview.md b/docs/book/v4/overview.md new file mode 100644 index 0000000..6b333b4 --- /dev/null +++ b/docs/book/v4/overview.md @@ -0,0 +1,21 @@ +# Overview + +> [!IMPORTANT] +> dot-cli is a wrapper on top of [laminas-cli](https://github.com/laminas/laminas-cli) + +`dotkernel/dot-cli` builds a console application from your application's PSR-11 container. +Commands are registered in configuration, and a file lock stops the same command from running twice at the same time. + +The factories only depend on `psr/container`, so the package works with any PSR-11 container that reads `factories` and `aliases` configuration, for example Laminas Service Manager. + +![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/dot-cli) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-cli/4.0.0) + +[![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-cli)](https://github.com/dotkernel/dot-cli/issues) +[![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-cli)](https://github.com/dotkernel/dot-cli/network) +[![GitHub stars](https://img.shields.io/github/stars/dotkernel/dot-cli)](https://github.com/dotkernel/dot-cli/stargazers) +[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-cli)](https://github.com/dotkernel/dot-cli/blob/4.0/LICENSE) + +[![Build Static](https://github.com/dotkernel/dot-cli/actions/workflows/continuous-integration.yml/badge.svg?branch=4.0)](https://github.com/dotkernel/dot-cli/actions/workflows/continuous-integration.yml) +[![codecov](https://codecov.io/gh/dotkernel/dot-cli/graph/badge.svg?token=0DFCK2GUBT)](https://codecov.io/gh/dotkernel/dot-cli) +[![PHPStan](https://github.com/dotkernel/dot-cli/actions/workflows/static-analysis.yml/badge.svg?branch=4.0)](https://github.com/dotkernel/dot-cli/actions/workflows/static-analysis.yml) diff --git a/docs/book/v4/setting-up-as-cronjob.md b/docs/book/v4/setting-up-as-cronjob.md new file mode 100644 index 0000000..9ee1a36 --- /dev/null +++ b/docs/book/v4/setting-up-as-cronjob.md @@ -0,0 +1,17 @@ +# Setting up as a cronjob + +```text +* * * * * php /var/www/vhosts/example.com/httpdocs/bin/cli.php demo:command -q +``` + +or + +```text +* * * * * cd /var/www/vhosts/example.com/httpdocs/bin && php ./cli.php demo:command -q +``` + +Adapt the command to your specifications by replacing **example.com** with your domain name. + +The **-q** (or **--quiet**) option suppresses normal output, but errors are still written to stderr. +This includes the lock error shown when the previous run of the same command has not finished yet (see [Lock Files](lock-files.md)), so cron reports skipped runs. +Use **--silent** instead if you want no output at all. diff --git a/docs/book/v4/testing.md b/docs/book/v4/testing.md new file mode 100644 index 0000000..298f4ab --- /dev/null +++ b/docs/book/v4/testing.md @@ -0,0 +1,35 @@ +# Testing + +Using the command line, go to your application's root directory, then type the following command: + +```shell +php ./bin/cli.php +``` + +The output should look similar to this, containing information on how to start using dot-cli: + +```text +Dotkernel CLI 1.0.0 + +Usage: + command [options] [arguments] + +Options: + -h, --help Display help for the given command. When no command is given display help for the list command + --silent Do not output any message + -q, --quiet Only errors are displayed. All other output is suppressed + -V, --version Display this application version + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output + -n, --no-interaction Do not ask any interactive question + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Available commands: + completion Dump the shell completion script + help Display help for a command + list List commands + demo + demo:command Demo command description. +``` + +As shown in `config/autoload/cli.global.php`, dot-cli includes a demo command `demo:command` that will help you understand the basics of creating a new command. +For more information, see [laminas-cli documentation](https://docs.laminas.dev/laminas-cli/). diff --git a/docs/book/v4/upgrading.md b/docs/book/v4/upgrading.md new file mode 100644 index 0000000..1b6e497 --- /dev/null +++ b/docs/book/v4/upgrading.md @@ -0,0 +1,72 @@ +# Upgrading from 3.x to 4.0 + +Version 4.0 keeps the 3.x configuration format and the public classes. +Most applications only need to update the `commands` keys in `config/autoload/cli.global.php` and require `laminas/laminas-servicemanager` themselves. + +## Changes that can break your code + +| Change | Symptom | Fix | +| --- | --- | --- | +| PHP 8.2 is no longer supported | Composer refuses to install 4.0 | Use PHP 8.3 or newer, or stay on 3.x | +| `laminas/laminas-servicemanager` is no longer required | `Class "Laminas\ServiceManager\ServiceManager" not found` if your application relied on dot-cli to install it | Run `composer require laminas/laminas-servicemanager` in your application | +| `DemoCommand::$defaultName` and `DemoCommand::configure()` were removed | A config key such as `DemoCommand::getDefaultName()` fails with `Call to undefined method` on Symfony Console 8 | Use the command name as the key, see [below](#command-keys) | +| The event dispatcher service key is now `Laminas\Cli\SymfonyEventDispatcher` | None yet: the old key still works, but it is deprecated | Rename the service key, see [below](#event-dispatcher) | +| A service under the event dispatcher key must implement `Symfony\Component\EventDispatcher\EventDispatcherInterface` | `Dot\Cli\Exception\InvalidConfigException` when the application is created | Register a Symfony event dispatcher under that key | +| Aliases and abbreviations of a command share its lock | `demo:com` or an alias is rejected while `demo:command` runs | This is intended. Give commands that must run in parallel their own names | +| Lock errors are written to stderr and are shown with `-q` | Cron output now includes `The file lock is being held by a different process` | Use `--silent`, or redirect stderr | +| A missing or unwritable lock directory throws `Dot\Cli\Exception\RuntimeException` | Commands exit with code `1` and a message instead of a `TypeError` | Create the directory, or disable the locker | +| `FileLocker::getLockFilePath()` throws `Dot\Cli\Exception\RuntimeException` without a directory or a command name | Code that called it too early gets an exception instead of a `TypeError` | Set both values first | +| The factories throw `Dot\Cli\Exception\InvalidConfigException` instead of `Exception` | None for `catch (Exception)`: the new class extends it | Optionally catch the new class, or `Psr\Container\ContainerExceptionInterface` | +| `ApplicationFactory::__invoke()` returns `Dot\Cli\Application` instead of `Symfony\Component\Console\Application` | A subclass that overrides `__invoke()` with the old return type fails to compile | Narrow the return type in your subclass | +| The `MESSAGE_*` constants are typed (`public const string`) | A subclass that redeclares one of them without a type fails to compile | Add the `string` type to the redeclared constant | + +## Command keys + +Before: + +```php +'commands' => [ + DemoCommand::getDefaultName() => DemoCommand::class, +], +``` + +After: + +```php +'commands' => [ + 'demo:command' => DemoCommand::class, +], +``` + +If one of your own commands still sets `protected static $defaultName`, move the name to the `#[AsCommand]` attribute and remove the property. + +## Event dispatcher + +Before: + +```php +'factories' => [ + 'Dot\Cli\Factory\SymfonyEventDispatcher' => MyEventDispatcherFactory::class, +], +``` + +After: + +```php +'factories' => [ + 'Laminas\Cli\SymfonyEventDispatcher' => MyEventDispatcherFactory::class, +], +``` + +The old key is checked after the new one, and will be removed in 5.0. + +## Laminas Service Manager + +dot-cli only uses the PSR-11 container interface. +If your application builds its container with Laminas Service Manager, add it to your application's requirements: + +```shell +composer require laminas/laminas-servicemanager +``` + +`Dot\Cli\ConfigProvider` still returns the same `factories` and `aliases`, so no configuration change is needed. diff --git a/docs/book/v4/usage.md b/docs/book/v4/usage.md new file mode 100644 index 0000000..875b5a4 --- /dev/null +++ b/docs/book/v4/usage.md @@ -0,0 +1,47 @@ +# Usage + +Use `src/Command/DemoCommand.php` as an example when creating a new command. +Set the name and description in the `#[AsCommand]` attribute: the command needs no `$defaultName` property and no `configure()` call to set them. + +```php +writeln('Hello from app:example'); + + return Command::SUCCESS; + } +} +``` + +Then register it in `config/autoload/cli.global.php`: + +```php +'commands' => [ + 'app:example' => App\Command\ExampleCommand::class, +], +``` + +## Running + +Using the command line, go to your application's root directory, then type the following command: + +```shell +php ./bin/cli.php demo:command +``` diff --git a/mkdocs.yml b/mkdocs.yml index df0fb86..44c5b8f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,11 +2,21 @@ docs_dir: docs/book site_dir: docs/html extra: project: Packages - current_version: v3 + current_version: v4 versions: + - v4 - v3 nav: - Home: index.md + - v4: + - Overview: v4/overview.md + - Installation: v4/installation.md + - Configuration: v4/configuration.md + - "Lock Files": v4/lock-files.md + - Testing: v4/testing.md + - Usage: v4/usage.md + - "Setting Up As Cronjob": v4/setting-up-as-cronjob.md + - Upgrading: v4/upgrading.md - v3: - Overview: v3/overview.md - Installation: v3/installation.md diff --git a/phpstan.neon b/phpstan.neon index 349be25..08d6acd 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,7 @@ includes: - vendor/phpstan/phpstan-phpunit/extension.neon parameters: - level: 5 + level: 8 paths: - src - test diff --git a/phpunit.xml b/phpunit.xml index 4a1bf00..6311b7f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,14 +1,21 @@ + cacheDirectory=".phpunit.cache" + colors="true" + failOnNotice="true" + failOnWarning="true" + failOnDeprecation="true" + failOnPhpunitDeprecation="true" + failOnPhpunitNotice="true" + failOnPhpunitWarning="true" + failOnRisky="true"> ./test - ./src diff --git a/src/Application.php b/src/Application.php index aed2143..cfb6383 100644 --- a/src/Application.php +++ b/src/Application.php @@ -6,19 +6,30 @@ use Exception; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\ExceptionInterface; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Throwable; +use function is_string; + class Application extends \Symfony\Component\Console\Application { - private FileLockerInterface $fileLocker; - - public function __construct(FileLockerInterface $fileLocker, array $config) - { - parent::__construct($config['name'] ?? 'UNKNOWN', $config['version'] ?? 'UNKNOWN'); + /** + * @param array $config + */ + public function __construct( + private readonly FileLockerInterface $fileLocker, + array $config, + ) { + $name = $config['name'] ?? null; + $version = $config['version'] ?? null; - $this->fileLocker = $fileLocker; + parent::__construct( + is_string($name) ? $name : 'UNKNOWN', + is_string($version) ? $version : 'UNKNOWN' + ); } public function __destruct() @@ -31,15 +42,38 @@ public function __destruct() */ public function doRun(InputInterface $input, OutputInterface $output): int { - $this->fileLocker->setCommandName($this->getCommandName($input)); + $this->fileLocker->setCommandName($this->resolveCommandName($input)); try { $this->fileLocker->lock(); } catch (Exception $exception) { - $output->writeln($exception->getMessage()); + $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + $errorOutput->writeln( + '' . $exception->getMessage() . '', + OutputInterface::VERBOSITY_QUIET + ); + return Command::FAILURE; } return parent::doRun($input, $output); } + + /** + * Resolve abbreviations and aliases to the real command name, so that every spelling of a command shares + * one lock. Unknown and ambiguous names are not locked: parent::doRun() reports them. + */ + private function resolveCommandName(InputInterface $input): ?string + { + $name = $this->getCommandName($input); + if ($name === null) { + return null; + } + + try { + return $this->find($name)->getName(); + } catch (ExceptionInterface) { + return null; + } + } } diff --git a/src/Command/DemoCommand.php b/src/Command/DemoCommand.php index 629228a..d598b8a 100644 --- a/src/Command/DemoCommand.php +++ b/src/Command/DemoCommand.php @@ -15,16 +15,6 @@ )] class DemoCommand extends Command { - /** @var string $defaultName */ - protected static $defaultName = 'demo:command'; - - protected function configure(): void - { - $this - ->setName(self::$defaultName) - ->setDescription('Demo command description.'); - } - protected function execute(InputInterface $input, OutputInterface $output): int { return Command::SUCCESS; diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index c1762be..b966c2c 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -9,6 +9,9 @@ class ConfigProvider { + /** + * @return array{dependencies: array>} + */ public function __invoke(): array { return [ @@ -16,6 +19,9 @@ public function __invoke(): array ]; } + /** + * @return array> + */ public function getDependencyConfig(): array { return [ diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php new file mode 100644 index 0000000..6d9465d --- /dev/null +++ b/src/Exception/ExceptionInterface.php @@ -0,0 +1,11 @@ +has('config')) { - throw new Exception(self::MESSAGE_MISSING_CONFIG); + throw new InvalidConfigException(self::MESSAGE_MISSING_CONFIG); } $config = $container->get('config'); if ( - ! array_key_exists('dot_cli', $config) + ! is_array($config) + || ! array_key_exists('dot_cli', $config) || ! is_array($config['dot_cli']) || empty($config['dot_cli']) ) { - throw new Exception(self::MESSAGE_MISSING_PACKAGE_CONFIG); + throw new InvalidConfigException(self::MESSAGE_MISSING_PACKAGE_CONFIG); } $config = $config['dot_cli']; @@ -50,27 +62,48 @@ public function __invoke(ContainerInterface $container): Application || ! is_array($config['commands']) || empty($config['commands']) ) { - throw new Exception(self::MESSAGE_MISSING_CONFIG_COMMANDS); + throw new InvalidConfigException(self::MESSAGE_MISSING_CONFIG_COMMANDS); } - $eventDispatcherServiceName = __NAMESPACE__ . '\SymfonyEventDispatcher'; - $dispatcher = $container->has($eventDispatcherServiceName) - ? $container->get($eventDispatcherServiceName) - : new EventDispatcher(); + $dispatcher = $this->getEventDispatcher($container); $dispatcher->addListener(ConsoleEvents::TERMINATE, new TerminateListener($config)); if (! $container->has(FileLockerInterface::class)) { - throw new Exception(FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER); + throw new InvalidConfigException(FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER); + } + $fileLocker = $container->get(FileLockerInterface::class); + if (! $fileLocker instanceof FileLockerInterface) { + throw new InvalidConfigException(FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER); } - $application = new \Dot\Cli\Application( - $container->get(FileLockerInterface::class), - $config - ); + $application = new Application($fileLocker, $config); $application->setCommandLoader(new ContainerCommandLoader($container, $config['commands'])); $application->setDispatcher($dispatcher); $application->setAutoExit(false); return $application; } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws InvalidConfigException + */ + private function getEventDispatcher(ContainerInterface $container): EventDispatcherInterface + { + foreach ([self::EVENT_DISPATCHER, self::LEGACY_EVENT_DISPATCHER] as $serviceName) { + if (! $container->has($serviceName)) { + continue; + } + + $dispatcher = $container->get($serviceName); + if (! $dispatcher instanceof EventDispatcherInterface) { + throw InvalidConfigException::invalidEventDispatcher(); + } + + return $dispatcher; + } + + return new EventDispatcher(); + } } diff --git a/src/Factory/FileLockerFactory.php b/src/Factory/FileLockerFactory.php index 34d1649..fc211df 100644 --- a/src/Factory/FileLockerFactory.php +++ b/src/Factory/FileLockerFactory.php @@ -4,9 +4,9 @@ namespace Dot\Cli\Factory; +use Dot\Cli\Exception\InvalidConfigException; use Dot\Cli\FileLocker; use Dot\Cli\FileLockerInterface; -use Exception; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; @@ -18,29 +18,34 @@ class FileLockerFactory { - public const MESSAGE_MISSING_FILE_LOCKER = 'Unable to find FileLocker service.'; - public const MESSAGE_MISSING_FILE_LOCKER_CONFIG = 'Missing/invalid dot-cli FileLocker config.'; - public const MESSAGE_MISSING_FILE_LOCKER_CONFIG_ENABLED = 'Missing/invalid dot-cli FileLocker config: enabled'; - public const MESSAGE_MISSING_FILE_LOCKER_CONFIG_DIR_PATH = 'Missing/invalid dot-cli FileLocker config: dirPath'; + public const string MESSAGE_MISSING_FILE_LOCKER = 'Unable to find FileLocker service.'; + public const string MESSAGE_MISSING_FILE_LOCKER_CONFIG = 'Missing/invalid dot-cli FileLocker config.'; + + public const string MESSAGE_MISSING_FILE_LOCKER_CONFIG_ENABLED + = 'Missing/invalid dot-cli FileLocker config: enabled'; + + public const string MESSAGE_MISSING_FILE_LOCKER_CONFIG_DIR_PATH + = 'Missing/invalid dot-cli FileLocker config: dirPath'; /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface - * @throws Exception + * @throws InvalidConfigException */ public function __invoke(ContainerInterface $container): FileLockerInterface { if (! $container->has('config')) { - throw new Exception(ApplicationFactory::MESSAGE_MISSING_CONFIG); + throw new InvalidConfigException(ApplicationFactory::MESSAGE_MISSING_CONFIG); } $config = $container->get('config'); if ( - ! array_key_exists(FileLockerInterface::class, $config) + ! is_array($config) + || ! array_key_exists(FileLockerInterface::class, $config) || ! is_array($config[FileLockerInterface::class]) || empty($config[FileLockerInterface::class]) ) { - throw new Exception(self::MESSAGE_MISSING_FILE_LOCKER_CONFIG); + throw new InvalidConfigException(self::MESSAGE_MISSING_FILE_LOCKER_CONFIG); } $config = $config[FileLockerInterface::class]; @@ -48,7 +53,7 @@ public function __invoke(ContainerInterface $container): FileLockerInterface ! array_key_exists('enabled', $config) || ! is_bool($config['enabled']) ) { - throw new Exception(self::MESSAGE_MISSING_FILE_LOCKER_CONFIG_ENABLED); + throw new InvalidConfigException(self::MESSAGE_MISSING_FILE_LOCKER_CONFIG_ENABLED); } if ( @@ -56,7 +61,7 @@ public function __invoke(ContainerInterface $container): FileLockerInterface || ! is_string($config['dirPath']) || empty($config['dirPath']) ) { - throw new Exception(self::MESSAGE_MISSING_FILE_LOCKER_CONFIG_DIR_PATH); + throw new InvalidConfigException(self::MESSAGE_MISSING_FILE_LOCKER_CONFIG_DIR_PATH); } return (new FileLocker()) diff --git a/src/FileLocker.php b/src/FileLocker.php index b1337c5..1d01d27 100644 --- a/src/FileLocker.php +++ b/src/FileLocker.php @@ -4,11 +4,14 @@ namespace Dot\Cli; -use Exception; +use Dot\Cli\Exception\RuntimeException; use function fclose; use function flock; use function fopen; +use function is_dir; +use function is_resource; +use function is_writable; use function rtrim; use function sprintf; use function str_replace; @@ -19,26 +22,27 @@ class FileLocker implements FileLockerInterface { - private bool $enabled; - private ?string $dirPath; - private ?string $commandName; - private mixed $lockFile; - public function __construct( - bool $enabled = false, - ?string $dirPath = null, - ?string $commandName = null, - mixed $lockFile = null, + private bool $enabled = false, + private ?string $dirPath = null, + private ?string $commandName = null, + private mixed $lockFile = null, ) { - $this->enabled = $enabled; - $this->dirPath = $dirPath; - $this->commandName = $commandName; - $this->lockFile = $lockFile; } public function initLockFile(): self { - $this->lockFile = fopen($this->getLockFilePath(), 'w+'); + $path = $this->getLockFilePath(); + $dirPath = (string) $this->dirPath; + if (! is_dir($dirPath) || ! is_writable($dirPath)) { + throw RuntimeException::lockDirectoryNotWritable($dirPath); + } + + $handle = fopen($path, 'c'); + if ($handle === false) { + throw RuntimeException::cannotOpenLockFile($path); + } + $this->lockFile = $handle; return $this; } @@ -76,9 +80,7 @@ public function getDirPath(): ?string public function setDirPath(?string $dirPath): self { - $dirPath = rtrim($dirPath, '/'); - $dirPath = rtrim($dirPath, '\\'); - $this->dirPath = $dirPath; + $this->dirPath = $dirPath === null ? null : rtrim($dirPath, '/\\'); return $this; } @@ -109,6 +111,10 @@ public function setLockFile(mixed $lockFile): self public function getLockFilePath(): string { + if ($this->dirPath === null || $this->commandName === null || $this->commandName === '') { + throw RuntimeException::missingLockFileData(); + } + return sprintf( '%s/command-%s.lock', $this->dirPath, @@ -116,39 +122,40 @@ public function getLockFilePath(): string ); } - /** - * @inheritDoc - */ public function lock(): void { - if (! $this->enabled) { + if (! $this->enabled || $this->commandName === null || $this->commandName === '') { return; } - if (empty($this->commandName)) { + if (is_resource($this->lockFile)) { return; } $this->initLockFile(); + $handle = $this->lockFile; + if (! is_resource($handle)) { + throw RuntimeException::cannotOpenLockFile($this->getLockFilePath()); + } + + if (! flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) { + fclose($handle); + $this->lockFile = null; - if (! flock($this->lockFile, LOCK_EX | LOCK_NB, $wouldBlock)) { - if ($wouldBlock) { - throw new Exception('The file lock is being held by a different process'); - } + throw $wouldBlock + ? RuntimeException::lockHeld() + : RuntimeException::cannotAcquireLock($this->getLockFilePath()); } } public function unlock(): void { - if (! $this->enabled) { - return; - } - - if (empty($this->commandName)) { + if (! is_resource($this->lockFile)) { return; } flock($this->lockFile, LOCK_UN); fclose($this->lockFile); + $this->lockFile = null; } } diff --git a/src/FileLockerInterface.php b/src/FileLockerInterface.php index f8b47e1..f21e864 100644 --- a/src/FileLockerInterface.php +++ b/src/FileLockerInterface.php @@ -4,10 +4,14 @@ namespace Dot\Cli; +use Dot\Cli\Exception\RuntimeException; use Exception; interface FileLockerInterface { + /** + * @throws RuntimeException When the lock directory is not writable or the lock file cannot be opened. + */ public function initLockFile(): self; public function enable(): self; @@ -26,13 +30,21 @@ public function getCommandName(): ?string; public function setCommandName(?string $commandName): self; + /** + * @return resource|null + */ public function getLockFile(): mixed; public function setLockFile(mixed $lockFile): self; + /** + * @throws RuntimeException When the directory or the command name is missing. + */ public function getLockFilePath(): string; /** + * Implementations may throw any Exception; Dot\Cli\Application reports it and returns Command::FAILURE. + * * @throws Exception */ public function lock(): void; diff --git a/test/ApplicationTest.php b/test/ApplicationTest.php index 37636d5..6ae3894 100644 --- a/test/ApplicationTest.php +++ b/test/ApplicationTest.php @@ -5,35 +5,46 @@ namespace DotTest\Cli; use Dot\Cli\Application; +use Dot\Cli\Exception\RuntimeException; use Dot\Cli\FileLocker; use Dot\Cli\FileLockerInterface; +use DotTest\Cli\Fixture\AliasedCommand; use Exception; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Output\StreamOutput; use Throwable; +use function fopen; +use function rewind; +use function stream_get_contents; + class ApplicationTest extends TestCase { use CommonTrait; + use TempDirectoryTrait; /** * @throws Throwable */ - public function testDoRunWillFailWithoutLockFile(): void + public function testDoRunWillFailWhenLockThrows(): void { $fileLocker = $this->createMock(FileLockerInterface::class); + $fileLocker->expects($this->once())->method('lock')->willThrowException(new Exception('locked')); - $exception = new Exception(); - $fileLocker->expects($this->once())->method('lock')->willThrowException($exception); - - $input = new StringInput('list'); $output = new BufferedOutput(); - $application = new Application($fileLocker, $this->getConfig()['dot_cli']); - $result = $application->doRun($input, $output); - $this->assertSame($result, Command::FAILURE); + $application = new Application($fileLocker, $this->dotCliConfig()); + $result = $application->doRun(new StringInput('list'), $output); + + $this->assertSame(Command::FAILURE, $result); + $this->assertStringContainsString('locked', $output->fetch()); } /** @@ -41,17 +52,159 @@ public function testDoRunWillFailWithoutLockFile(): void */ public function testDoRunWillSucceed(): void { - $config = $this->getConfig(); + $fileLocker = new FileLocker(true, $this->tempDir); + + $application = new Application($fileLocker, $this->dotCliConfig()); + $result = $application->doRun(new StringInput('list'), new BufferedOutput()); + + $this->assertSame(Command::SUCCESS, $result); + $this->assertSame('list', $fileLocker->getCommandName()); + } + + public function testNameAndVersionFallBackToUnknown(): void + { + $application = new Application(new FileLocker(), ['name' => ['not a string']]); + + $this->assertSame('UNKNOWN', $application->getName()); + $this->assertSame('UNKNOWN', $application->getVersion()); + } + + /** + * @return iterable + */ + public static function commandSpellingProvider(): iterable + { + yield 'full name' => ['fixture:command']; + yield 'abbreviation' => ['fix:com']; + yield 'alias' => ['fc']; + } + + /** + * @throws Throwable + */ + #[DataProvider('commandSpellingProvider')] + public function testEverySpellingOfACommandSharesTheLock(string $input): void + { + $holder = new FileLocker(true, $this->tempDir, 'fixture:command'); + $holder->lock(); + + $application = $this->createApplication(new FileLocker(true, $this->tempDir)); + $output = new BufferedOutput(); + $result = $application->doRun(new StringInput($input), $output); + + $this->assertSame(Command::FAILURE, $result); + $this->assertStringContainsString(RuntimeException::MESSAGE_LOCK_HELD, $output->fetch()); + $holder->unlock(); + } + + /** + * @throws Throwable + */ + public function testAliasIsLockedUnderTheResolvedName(): void + { + $fileLocker = new FileLocker(true, $this->tempDir); + $application = $this->createApplication($fileLocker); + + $output = new BufferedOutput(); + $result = $application->doRun(new StringInput('fc'), $output); + + $this->assertSame(Command::SUCCESS, $result); + $this->assertSame('fixture:command', $fileLocker->getCommandName()); + $this->assertFileExists($this->tempDir . '/command-fixture-command.lock'); + $this->assertFileDoesNotExist($this->tempDir . '/command-fc.lock'); + $this->assertStringContainsString(AliasedCommand::OUTPUT, $output->fetch()); + } + + /** + * @throws Throwable + */ + public function testUnknownCommandIsNotLocked(): void + { + $fileLocker = new FileLocker(true, $this->tempDir); + $application = $this->createApplication($fileLocker); + $application->setCatchExceptions(false); + + try { + $application->doRun(new StringInput('unknown'), new BufferedOutput()); + $this->fail('Expected CommandNotFoundException'); + } catch (CommandNotFoundException) { + } + + $this->assertNull($fileLocker->getCommandName()); + $this->assertFileDoesNotExist($this->tempDir . '/command-unknown.lock'); + } + + /** + * @throws Throwable + */ + public function testDoRunFailsCleanlyWhenLockDirectoryIsMissing(): void + { + $fileLocker = new FileLocker(true, $this->tempDir . '/missing'); + $application = $this->createApplication($fileLocker); + + $output = new BufferedOutput(); + $result = $application->doRun(new StringInput('fixture:command'), $output); + + $this->assertSame(Command::FAILURE, $result); + $this->assertStringContainsString('does not exist or is not writable', $output->fetch()); + + $application->__destruct(); + $this->assertNull($fileLocker->getLockFile()); + } + + /** + * @throws Throwable + */ + public function testLockErrorIsWrittenToStderrWhenQuiet(): void + { + $holder = new FileLocker(true, $this->tempDir, 'fixture:command'); + $holder->lock(); + + $stderr = fopen('php://memory', 'w+'); + $this->assertIsResource($stderr); + + $output = new ConsoleOutput(OutputInterface::VERBOSITY_QUIET); + $output->setErrorOutput(new StreamOutput($stderr, OutputInterface::VERBOSITY_QUIET)); + $output->setVerbosity(OutputInterface::VERBOSITY_QUIET); + + $application = $this->createApplication(new FileLocker(true, $this->tempDir)); + $result = $application->doRun(new StringInput('fixture:command -q'), $output); + + rewind($stderr); + $this->assertSame(Command::FAILURE, $result); + $this->assertStringContainsString(RuntimeException::MESSAGE_LOCK_HELD, (string) stream_get_contents($stderr)); + $holder->unlock(); + } + + public function testDestructorReleasesTheLock(): void + { + $fileLocker = new FileLocker(true, $this->tempDir, 'fixture:command'); + $fileLocker->lock(); + $this->assertIsResource($fileLocker->getLockFile()); + + $application = new Application($fileLocker, $this->dotCliConfig()); + $application->__destruct(); + + $this->assertNull($fileLocker->getLockFile()); + } + + private function createApplication(FileLockerInterface $fileLocker): Application + { + $application = new Application($fileLocker, $this->dotCliConfig()); + $application->setAutoExit(false); + $application->addCommand(new AliasedCommand()); - $fileLocker = new FileLocker( - $config[FileLockerInterface::class]['enabled'], - $config[FileLockerInterface::class]['dirPath'] - ); - $input = new StringInput('list'); - $output = new BufferedOutput(); + return $application; + } + + /** + * @return array + */ + private function dotCliConfig(): array + { + $config = $this->getConfig()['dot_cli']; + $this->assertIsArray($config); - $application = new Application($fileLocker, $this->getConfig()['dot_cli']); - $result = $application->doRun($input, $output); - $this->assertSame($result, Command::SUCCESS); + return $config; } } diff --git a/test/Command/DemoCommandTest.php b/test/Command/DemoCommandTest.php index 2ff0c22..5d1ccf7 100644 --- a/test/Command/DemoCommandTest.php +++ b/test/Command/DemoCommandTest.php @@ -5,37 +5,25 @@ namespace DotTest\Cli\Command; use Dot\Cli\Command\DemoCommand; -use DotTest\Cli\CommonTrait; use PHPUnit\Framework\TestCase; -use ReflectionException; -use ReflectionMethod; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\ArgvInput; -use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Tester\CommandTester; class DemoCommandTest extends TestCase { - use CommonTrait; - - public function testWillCreateCommand(): void + public function testNameAndDescriptionComeFromTheAttribute(): void { $command = new DemoCommand(); - $this->assertContainsOnlyInstancesOf(DemoCommand::class, [$command]); + + $this->assertSame('demo:command', $command->getName()); + $this->assertSame('Demo command description.', $command->getDescription()); } - /** - * @throws ReflectionException - */ public function testCommandWillExecute(): void { - $command = new DemoCommand(); - $reflection = new ReflectionMethod(DemoCommand::class, 'execute'); + $tester = new CommandTester(new DemoCommand()); - $result = $reflection->invoke( - $command, - new ArgvInput(), - new BufferedOutput() - ); - $this->assertSame($result, Command::SUCCESS); + $this->assertSame(Command::SUCCESS, $tester->execute([])); + $this->assertSame('', $tester->getDisplay()); } } diff --git a/test/CommonTrait.php b/test/CommonTrait.php index 1878739..9934e3e 100644 --- a/test/CommonTrait.php +++ b/test/CommonTrait.php @@ -5,33 +5,34 @@ namespace DotTest\Cli; use Dot\Cli\FileLockerInterface; -use org\bovigo\vfs\vfsStream; -use org\bovigo\vfs\vfsStreamDirectory; - -use function sprintf; +/** + * Package config for tests that only pass it around. Nothing reads or writes LOCK_DIR_PATH: + * tests that take a real lock use TempDirectoryTrait. + */ trait CommonTrait { - protected vfsStreamDirectory $fileSystem; + protected const string LOCK_DIR_PATH = '/dot-cli-test/data/lock'; + + /** @var array */ protected array $config; - protected function setup(): void + protected function setUp(): void { - $this->fileSystem = vfsStream::setup('root', 0644, [ - 'data' => [ - 'lock' => [], - ], - ]); - $this->config = $this->generateConfig( - sprintf('%s/data/lock', $this->fileSystem->url()) - ); + $this->config = $this->generateConfig(self::LOCK_DIR_PATH); } + /** + * @return array + */ protected function getConfig(): array { return $this->config; } + /** + * @return array + */ protected function generateConfig(string $dirPath): array { return [ diff --git a/test/ConfigProviderTest.php b/test/ConfigProviderTest.php index 07d36b1..22230ce 100644 --- a/test/ConfigProviderTest.php +++ b/test/ConfigProviderTest.php @@ -6,15 +6,18 @@ use Dot\Cli\Application; use Dot\Cli\ConfigProvider; +use Dot\Cli\Factory\ApplicationFactory; +use Dot\Cli\Factory\FileLockerFactory; use Dot\Cli\FileLocker; use Dot\Cli\FileLockerInterface; use PHPUnit\Framework\TestCase; class ConfigProviderTest extends TestCase { + /** @var array{dependencies: array>} */ protected array $config; - protected function setup(): void + protected function setUp(): void { $this->config = (new ConfigProvider())(); } @@ -22,18 +25,30 @@ protected function setup(): void public function testHasDependencies(): void { $this->assertArrayHasKey('dependencies', $this->config); + $this->assertSame((new ConfigProvider())->getDependencyConfig(), $this->config['dependencies']); } public function testDependenciesHasFactories(): void { - $this->assertArrayHasKey('factories', $this->config['dependencies']); - $this->assertArrayHasKey(Application::class, $this->config['dependencies']['factories']); - $this->assertArrayHasKey(FileLocker::class, $this->config['dependencies']['factories']); + $this->assertSame( + [ + Application::class => ApplicationFactory::class, + FileLocker::class => FileLockerFactory::class, + ], + $this->config['dependencies']['factories'] + ); } public function testDependenciesHasAliases(): void { - $this->assertArrayHasKey('aliases', $this->config['dependencies']); - $this->assertArrayHasKey(FileLockerInterface::class, $this->config['dependencies']['aliases']); + $this->assertSame( + [FileLockerInterface::class => FileLocker::class], + $this->config['dependencies']['aliases'] + ); + } + + public function testRegistersNoAbstractFactories(): void + { + $this->assertArrayNotHasKey('abstract_factories', $this->config['dependencies']); } } diff --git a/test/ConfigTest.php b/test/ConfigTest.php new file mode 100644 index 0000000..a018c60 --- /dev/null +++ b/test/ConfigTest.php @@ -0,0 +1,42 @@ +assertIsArray($config); + $this->assertSame(['demo:command' => DemoCommand::class], $config['dot_cli']['commands']); + $this->assertSame( + ['enabled' => true, 'dirPath' => getcwd() . '/data/lock'], + $config[FileLockerInterface::class] + ); + } + + public function testShippedCommandKeyMatchesTheCommandName(): void + { + $config = require dirname(__DIR__) . '/config/autoload/cli.global.php'; + + foreach ($config['dot_cli']['commands'] as $name => $class) { + $command = new $class(); + $this->assertInstanceOf(Command::class, $command); + $this->assertSame($name, $command->getName()); + } + } +} diff --git a/test/Exception/ExceptionTest.php b/test/Exception/ExceptionTest.php new file mode 100644 index 0000000..a07d0bf --- /dev/null +++ b/test/Exception/ExceptionTest.php @@ -0,0 +1,32 @@ +assertSame(RuntimeException::MESSAGE_LOCK_HELD, RuntimeException::lockHeld()->getMessage()); + $this->assertSame( + 'Unable to acquire the file lock "/a.lock".', + RuntimeException::cannotAcquireLock('/a.lock')->getMessage() + ); + $this->assertSame( + 'The lock directory "/a" does not exist or is not writable.', + RuntimeException::lockDirectoryNotWritable('/a')->getMessage() + ); + $this->assertSame( + 'Unable to open the lock file "/a.lock".', + RuntimeException::cannotOpenLockFile('/a.lock')->getMessage() + ); + $this->assertSame( + RuntimeException::MESSAGE_MISSING_LOCK_DATA, + RuntimeException::missingLockFileData()->getMessage() + ); + } +} diff --git a/test/Factory/ApplicationFactoryTest.php b/test/Factory/ApplicationFactoryTest.php index f33c757..0718214 100644 --- a/test/Factory/ApplicationFactoryTest.php +++ b/test/Factory/ApplicationFactoryTest.php @@ -5,142 +5,138 @@ namespace DotTest\Cli\Factory; use Dot\Cli\Application; +use Dot\Cli\Exception\InvalidConfigException; use Dot\Cli\Factory\ApplicationFactory; use Dot\Cli\Factory\FileLockerFactory; +use Dot\Cli\FileLocker; use Dot\Cli\FileLockerInterface; use DotTest\Cli\CommonTrait; -use PHPUnit\Framework\MockObject\Exception; +use DotTest\Cli\Fixture\InMemoryContainer; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\ContainerInterface; -use Psr\Container\NotFoundExceptionInterface; +use ReflectionProperty; +use stdClass; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; class ApplicationFactoryTest extends TestCase { use CommonTrait; - /** - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ public function testWillNotCreateApplicationWithoutConfig(): void { - $container = $this->createMock(ContainerInterface::class); - - $container->expects($this->once()) - ->method('has') - ->with('config') - ->willReturn(false); - - $this->expectException(\Exception::class); + $this->expectException(InvalidConfigException::class); $this->expectExceptionMessage(ApplicationFactory::MESSAGE_MISSING_CONFIG); - (new ApplicationFactory())($container); + + (new ApplicationFactory())(new InMemoryContainer()); } /** - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface + * @return iterable */ - public function testWillNotCreateApplicationWithoutPackageConfig(): void + public static function invalidConfigProvider(): iterable { - $container = $this->createMock(ContainerInterface::class); - - $container->expects($this->once()) - ->method('has') - ->with('config') - ->willReturn(true); - - $container->expects($this->once()) - ->method('get') - ->with('config') - ->willReturn([ - 'test', - ]); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage(ApplicationFactory::MESSAGE_MISSING_PACKAGE_CONFIG); - (new ApplicationFactory())($container); + yield 'config is not an array' => ['test', ApplicationFactory::MESSAGE_MISSING_PACKAGE_CONFIG]; + yield 'missing dot_cli' => [['test'], ApplicationFactory::MESSAGE_MISSING_PACKAGE_CONFIG]; + yield 'empty dot_cli' => [['dot_cli' => []], ApplicationFactory::MESSAGE_MISSING_PACKAGE_CONFIG]; + yield 'missing commands' => [['dot_cli' => ['test']], ApplicationFactory::MESSAGE_MISSING_CONFIG_COMMANDS]; + yield 'empty commands' => [ + ['dot_cli' => ['commands' => []]], + ApplicationFactory::MESSAGE_MISSING_CONFIG_COMMANDS, + ]; } - /** - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ - public function testWillNotCreateApplicationWithoutConfigCommands(): void + #[DataProvider('invalidConfigProvider')] + public function testWillNotCreateApplicationWithInvalidConfig(mixed $config, string $message): void { - $container = $this->createMock(ContainerInterface::class); - - $container->expects($this->once()) - ->method('has') - ->with('config') - ->willReturn(true); - - $container->expects($this->once()) - ->method('get') - ->with('config') - ->willReturn([ - 'dot_cli' => [ - 'test', - ], - ]); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage(ApplicationFactory::MESSAGE_MISSING_CONFIG_COMMANDS); - (new ApplicationFactory())($container); + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage($message); + + (new ApplicationFactory())(new InMemoryContainer(['config' => $config])); } - /** - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ public function testWillNotCreateApplicationWithoutFileLockerService(): void { - $container = $this->createMock(ContainerInterface::class); - - $container->method('has')->willReturnMap([ - ['config', true], - ['Dot\Cli\Factory\SymfonyEventDispatcher', false], - [FileLockerInterface::class, false], - ]); + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage(FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER); - $container->expects($this->once()) - ->method('get') - ->with('config') - ->willReturn($this->getConfig()); + (new ApplicationFactory())(new InMemoryContainer(['config' => $this->getConfig()])); + } - $this->expectException(\Exception::class); + public function testWillNotCreateApplicationWithInvalidFileLockerService(): void + { + $this->expectException(InvalidConfigException::class); $this->expectExceptionMessage(FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER); - (new ApplicationFactory())($container); + + (new ApplicationFactory())($this->createContainer()->set(FileLockerInterface::class, new stdClass())); } - /** - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - * @throws Exception - */ - public function testWillCreateApplication(): void + public function testWillCreateApplicationWithDefaultDispatcher(): void { - $container = $this->createMock(ContainerInterface::class); - $fileLocker = $this->createMock(FileLockerInterface::class); + $application = (new ApplicationFactory())($this->createContainer()); - $container->method('has')->willReturnMap([ - ['config', true], - ['Dot\Cli\Factory\SymfonyEventDispatcher', true], - [FileLockerInterface::class, true], - ]); + $this->assertSame('Dotkernel CLI', $application->getName()); + $this->assertSame('1.0.0', $application->getVersion()); + $this->assertFalse($application->isAutoExitEnabled()); + $this->assertInstanceOf(EventDispatcher::class, $this->getDispatcher($application)); + } - $container->method('get')->willReturnMap([ - ['config', $this->getConfig()], - ['Dot\Cli\Factory\SymfonyEventDispatcher', new EventDispatcher()], - [FileLockerInterface::class, $fileLocker], - ]); + public function testUsesTheLaminasDispatcherKey(): void + { + $dispatcher = new EventDispatcher(); + $container = $this->createContainer()->set('Laminas\Cli\SymfonyEventDispatcher', $dispatcher); $application = (new ApplicationFactory())($container); - $this->assertInstanceOf(Application::class, $application); + + $this->assertSame($dispatcher, $this->getDispatcher($application)); + } + + public function testUsesTheLegacyDispatcherKey(): void + { + $dispatcher = new EventDispatcher(); + $container = $this->createContainer()->set('Dot\Cli\Factory\SymfonyEventDispatcher', $dispatcher); + + $application = (new ApplicationFactory())($container); + + $this->assertSame($dispatcher, $this->getDispatcher($application)); + } + + public function testLaminasDispatcherKeyWinsOverLegacyKey(): void + { + $dispatcher = new EventDispatcher(); + $container = $this->createContainer() + ->set(ApplicationFactory::EVENT_DISPATCHER, $dispatcher) + ->set(ApplicationFactory::LEGACY_EVENT_DISPATCHER, new EventDispatcher()); + + $application = (new ApplicationFactory())($container); + + $this->assertSame($dispatcher, $this->getDispatcher($application)); + } + + public function testRejectsNonDispatcherService(): void + { + $container = $this->createContainer()->set(ApplicationFactory::EVENT_DISPATCHER, new stdClass()); + + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage(InvalidConfigException::MESSAGE_INVALID_EVENT_DISPATCHER); + + (new ApplicationFactory())($container); + } + + private function createContainer(): InMemoryContainer + { + return new InMemoryContainer([ + 'config' => $this->getConfig(), + FileLockerInterface::class => new FileLocker(), + ]); + } + + private function getDispatcher(Application $application): EventDispatcherInterface + { + $dispatcher = (new ReflectionProperty(\Symfony\Component\Console\Application::class, 'dispatcher')) + ->getValue($application); + $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + + return $dispatcher; } } diff --git a/test/Factory/FileLockerFactoryTest.php b/test/Factory/FileLockerFactoryTest.php index 0e2442f..8cc5e9a 100644 --- a/test/Factory/FileLockerFactoryTest.php +++ b/test/Factory/FileLockerFactoryTest.php @@ -4,142 +4,89 @@ namespace DotTest\Cli\Factory; +use Dot\Cli\Exception\InvalidConfigException; use Dot\Cli\Factory\ApplicationFactory; use Dot\Cli\Factory\FileLockerFactory; use Dot\Cli\FileLocker; use Dot\Cli\FileLockerInterface; use DotTest\Cli\CommonTrait; -use PHPUnit\Framework\MockObject\Exception; +use DotTest\Cli\Fixture\InMemoryContainer; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerExceptionInterface; -use Psr\Container\ContainerInterface; -use Psr\Container\NotFoundExceptionInterface; class FileLockerFactoryTest extends TestCase { use CommonTrait; - /** - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ public function testWillNotCreateServiceWithoutConfig(): void { - $container = $this->createMock(ContainerInterface::class); - - $container->expects($this->once()) - ->method('has') - ->with('config') - ->willReturn(false); - - $this->expectException(\Exception::class); + $this->expectException(InvalidConfigException::class); $this->expectExceptionMessage(ApplicationFactory::MESSAGE_MISSING_CONFIG); - (new FileLockerFactory())($container); + + (new FileLockerFactory())(new InMemoryContainer()); } /** - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface + * @return iterable */ - public function testWillNotCreateServiceWithoutFileLockerConfig(): void + public static function invalidConfigProvider(): iterable { - $container = $this->createMock(ContainerInterface::class); - - $container->expects($this->once()) - ->method('has') - ->with('config') - ->willReturn(true); - - $container->expects($this->once()) - ->method('get') - ->with('config') - ->willReturn([ - 'test', - ]); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage(FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG); - (new FileLockerFactory())($container); + yield 'config is not an array' => ['test', FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG]; + yield 'missing locker config' => [['test'], FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG]; + yield 'empty locker config' => [ + [FileLockerInterface::class => []], + FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG, + ]; + yield 'missing enabled' => [ + [FileLockerInterface::class => ['dirPath' => 'test']], + FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG_ENABLED, + ]; + yield 'enabled is not a bool' => [ + [FileLockerInterface::class => ['enabled' => 1, 'dirPath' => 'test']], + FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG_ENABLED, + ]; + yield 'missing dirPath' => [ + [FileLockerInterface::class => ['enabled' => false]], + FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG_DIR_PATH, + ]; + yield 'empty dirPath' => [ + [FileLockerInterface::class => ['enabled' => false, 'dirPath' => '']], + FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG_DIR_PATH, + ]; } - /** - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ - public function testWillNotCreateServiceWithoutFileLockerConfigEnabled(): void + #[DataProvider('invalidConfigProvider')] + public function testWillNotCreateServiceWithInvalidConfig(mixed $config, string $message): void { - $container = $this->createMock(ContainerInterface::class); - - $container->expects($this->once()) - ->method('has') - ->with('config') - ->willReturn(true); - - $container->expects($this->once()) - ->method('get') - ->with('config') - ->willReturn([ - FileLockerInterface::class => [ - 'dirPath' => 'test', - ], - ]); + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage($message); - $this->expectException(\Exception::class); - $this->expectExceptionMessage(FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG_ENABLED); - (new FileLockerFactory())($container); + (new FileLockerFactory())(new InMemoryContainer(['config' => $config])); } - /** - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ - public function testWillNotCreateServiceWithoutFileLockerConfigDirPath(): void + public function testCanCreateService(): void { - $container = $this->createMock(ContainerInterface::class); + $service = (new FileLockerFactory())(new InMemoryContainer(['config' => $this->getConfig()])); - $container->expects($this->once()) - ->method('has') - ->with('config') - ->willReturn(true); - - $container->expects($this->once()) - ->method('get') - ->with('config') - ->willReturn([ - FileLockerInterface::class => [ - 'enabled' => false, - ], - ]); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage(FileLockerFactory::MESSAGE_MISSING_FILE_LOCKER_CONFIG_DIR_PATH); - (new FileLockerFactory())($container); + $this->assertInstanceOf(FileLocker::class, $service); + $this->assertTrue($service->isEnabled()); + $this->assertSame(self::LOCK_DIR_PATH, $service->getDirPath()); } - /** - * @throws Exception - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ - public function testCanCreateService(): void + public function testTrimsTrailingSeparatorFromDirPath(): void { - $container = $this->createMock(ContainerInterface::class); + $container = new InMemoryContainer([ + 'config' => [FileLockerInterface::class => ['enabled' => true, 'dirPath' => '/var/lock/']], + ]); - $container->expects($this->once()) - ->method('has') - ->with('config') - ->willReturn(true); + $this->assertSame('/var/lock', (new FileLockerFactory())($container)->getDirPath()); + } - $container->expects($this->once()) - ->method('get') - ->with('config') - ->willReturn($this->getConfig()); + public function testConfigExceptionIsAContainerException(): void + { + $this->expectException(ContainerExceptionInterface::class); - $service = (new FileLockerFactory())($container); - $this->assertInstanceOf(FileLocker::class, $service); + (new FileLockerFactory())(new InMemoryContainer()); } } diff --git a/test/FileLockerTest.php b/test/FileLockerTest.php index a9fa075..42d53cd 100644 --- a/test/FileLockerTest.php +++ b/test/FileLockerTest.php @@ -4,170 +4,257 @@ namespace DotTest\Cli; +use Dot\Cli\Exception\RuntimeException; use Dot\Cli\FileLocker; -use Exception; -use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\TestCase; -use function sprintf; +use function chmod; +use function function_exists; +use function mkdir; +use function posix_geteuid; +use function rmdir; class FileLockerTest extends TestCase { - use CommonTrait; - - protected function setup(): void - { - $this->fileSystem = vfsStream::setup('root', 0644, [ - 'data' => [ - 'lock' => [], - ], - ]); - $this->config = $this->generateConfig( - sprintf('%s/data/lock', $this->fileSystem->url()) - ); - } + use TempDirectoryTrait; public function testAccessors(): void { $fileLocker = new FileLocker(); $this->assertFalse($fileLocker->isEnabled()); - $fileLocker->enable(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertSame($fileLocker, $fileLocker->enable()); $this->assertTrue($fileLocker->isEnabled()); - $fileLocker->disable(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertSame($fileLocker, $fileLocker->disable()); $this->assertFalse($fileLocker->isEnabled()); - $fileLocker->setEnabled(true); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertSame($fileLocker, $fileLocker->setEnabled(true)); $this->assertTrue($fileLocker->isEnabled()); - $fileLocker->setEnabled(false); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertSame($fileLocker, $fileLocker->setEnabled(false)); $this->assertFalse($fileLocker->isEnabled()); $this->assertNull($fileLocker->getDirPath()); - $fileLocker->setDirPath('test'); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertSame($fileLocker, $fileLocker->setDirPath('test')); $this->assertSame('test', $fileLocker->getDirPath()); $this->assertNull($fileLocker->getCommandName()); - $fileLocker->setCommandName('test'); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertSame($fileLocker, $fileLocker->setCommandName('test')); $this->assertSame('test', $fileLocker->getCommandName()); $this->assertNull($fileLocker->getLockFile()); - $fileLocker->setLockFile('test'); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertSame($fileLocker, $fileLocker->setLockFile('test')); $this->assertSame('test', $fileLocker->getLockFile()); $this->assertSame('test/command-test.lock', $fileLocker->getLockFilePath()); } - public function testWillInitLockFile(): void + public function testSetDirPathAcceptsNull(): void + { + $fileLocker = new FileLocker(false, 'test'); + + $fileLocker->setDirPath(null); + + $this->assertNull($fileLocker->getDirPath()); + } + + public function testSetDirPathTrimsTrailingSeparators(): void + { + $fileLocker = (new FileLocker())->setDirPath('/var/lock/\\/'); + + $this->assertSame('/var/lock', $fileLocker->getDirPath()); + } + + public function testGetLockFilePathReplacesColons(): void + { + $fileLocker = new FileLocker(true, '/var/lock', 'demo:command'); + + $this->assertSame('/var/lock/command-demo-command.lock', $fileLocker->getLockFilePath()); + } + + public function testGetLockFilePathRequiresCommandName(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(RuntimeException::MESSAGE_MISSING_LOCK_DATA); + + (new FileLocker(true, $this->tempDir))->getLockFilePath(); + } + + public function testGetLockFilePathRequiresDirPath(): void { - $config = $this->getConfig(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(RuntimeException::MESSAGE_MISSING_LOCK_DATA); - $fileLocker = new FileLocker(true, $config['dirPath'], 'test'); + (new FileLocker(true, null, 'test'))->getLockFilePath(); + } + + public function testWillInitLockFile(): void + { + $fileLocker = new FileLocker(true, $this->tempDir, 'test'); $this->assertNull($fileLocker->getLockFile()); - $fileLocker->initLockFile(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + + $this->assertSame($fileLocker, $fileLocker->initLockFile()); $this->assertIsResource($fileLocker->getLockFile()); + $fileLocker->unlock(); } - /** - * @throws Exception - */ - public function testWillNotLockWhenDisabled(): void + public function testInitLockFileThrowsWhenDirectoryIsMissing(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not exist or is not writable'); + + (new FileLocker(true, $this->tempDir . '/missing', 'test'))->initLockFile(); + } + + public function testLockThrowsWhenDirectoryIsMissing(): void { - $config = $this->getConfig(); + $fileLocker = new FileLocker(true, $this->tempDir . '/missing', 'test'); + + try { + $fileLocker->lock(); + $this->fail('Expected RuntimeException'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('does not exist or is not writable', $exception->getMessage()); + } - $fileLocker = new FileLocker(false, $config['dirPath'], 'test'); $this->assertNull($fileLocker->getLockFile()); + } + + public function testLockThrowsWhenDirectoryIsNotWritable(): void + { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('root can write to any directory'); + } + + $dir = $this->tempDir . '/read-only'; + mkdir($dir, 0555); + + try { + $this->expectException(RuntimeException::class); + (new FileLocker(true, $dir, 'test'))->lock(); + } finally { + chmod($dir, 0775); + rmdir($dir); + } + } + + public function testWillNotLockWhenDisabled(): void + { + $fileLocker = new FileLocker(false, $this->tempDir, 'test'); + $fileLocker->lock(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertNull($fileLocker->getLockFile()); + $this->assertFileDoesNotExist($this->tempDir . '/command-test.lock'); } - /** - * @throws Exception - */ - public function testWillNotLockWithoutValidCommandName(): void + public function testWillNotLockWithoutCommandName(): void { - $config = $this->getConfig(); + $fileLocker = new FileLocker(true, $this->tempDir); - $fileLocker = new FileLocker(true, $config['dirPath']); - $this->assertNull($fileLocker->getLockFile()); $fileLocker->lock(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertNull($fileLocker->getLockFile()); } - /** - * @throws Exception - */ - public function testWillLockWhenLockedAndEnabledAndHasValidCommandName(): void + public function testWillNotLockWithEmptyCommandName(): void { - $config = $this->getConfig(); + $fileLocker = new FileLocker(true, $this->tempDir, ''); + + $fileLocker->lock(); - $fileLocker = new FileLocker(true, $config['dirPath'], 'test'); $this->assertNull($fileLocker->getLockFile()); + } + + public function testWillLockWhenEnabledAndHasCommandName(): void + { + $fileLocker = new FileLocker(true, $this->tempDir, 'test'); + $fileLocker->lock(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertIsResource($fileLocker->getLockFile()); $this->assertFileExists($fileLocker->getLockFilePath()); + $fileLocker->unlock(); } - public function testWillNotUnlockWhenDisabled(): void + public function testSecondLockerIsRejected(): void { - $config = $this->getConfig(); + $first = new FileLocker(true, $this->tempDir, 'test'); + $first->lock(); - $fileLocker = new FileLocker(false, $config['dirPath'], 'test'); - $this->assertNull($fileLocker->getLockFile()); + $second = new FileLocker(true, $this->tempDir, 'test'); + try { + $second->lock(); + $this->fail('Expected RuntimeException'); + } catch (RuntimeException $exception) { + $this->assertSame(RuntimeException::MESSAGE_LOCK_HELD, $exception->getMessage()); + } + + $this->assertNull($second->getLockFile()); + $first->unlock(); + } + + public function testLockIsAvailableAgainAfterUnlock(): void + { + $first = new FileLocker(true, $this->tempDir, 'test'); + $first->lock(); + $first->unlock(); + + $second = new FileLocker(true, $this->tempDir, 'test'); + $second->lock(); + + $this->assertIsResource($second->getLockFile()); + $second->unlock(); + } + + public function testLockTwiceKeepsOneHandle(): void + { + $fileLocker = new FileLocker(true, $this->tempDir, 'test'); + $fileLocker->lock(); + $handle = $fileLocker->getLockFile(); + + $fileLocker->lock(); + + $this->assertSame($handle, $fileLocker->getLockFile()); $fileLocker->unlock(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); - $this->assertNull($fileLocker->getLockFile()); } - public function testWillNotUnlockWithoutValidCommandName(): void + public function testUnlockBeforeLockIsNoop(): void { - $config = $this->getConfig(); + $fileLocker = new FileLocker(true, $this->tempDir, 'test'); - $fileLocker = new FileLocker(false, $config['dirPath']); - $this->assertNull($fileLocker->getLockFile()); $fileLocker->unlock(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $this->assertNull($fileLocker->getLockFile()); } - public function testWillNotUnlockWhenEnabledAndWithoutValidCommandName(): void + public function testUnlockTwiceIsNoop(): void { - $config = $this->getConfig(); + $fileLocker = new FileLocker(true, $this->tempDir, 'test'); + $fileLocker->lock(); - $fileLocker = new FileLocker(true, $config['dirPath']); - $this->assertNull($fileLocker->getLockFile()); $fileLocker->unlock(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); + $fileLocker->unlock(); + $this->assertNull($fileLocker->getLockFile()); } - /** - * @throws Exception - */ - public function testWillUnlockWhenLockedAndEnabledAndHasValidCommandName(): void + public function testUnlockAfterDisableReleasesTheLock(): void { - $config = $this->getConfig(); - - $fileLocker = new FileLocker(true, $config['dirPath'], 'test'); + $fileLocker = new FileLocker(true, $this->tempDir, 'test'); $fileLocker->lock(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); - $this->assertIsResource($fileLocker->getLockFile()); - $this->assertFileExists($fileLocker->getLockFilePath()); + $fileLocker->disable(); + $fileLocker->unlock(); - $this->assertContainsOnlyInstancesOf(FileLocker::class, [$fileLocker]); - $this->assertFileIsReadable($fileLocker->getLockFilePath()); - $this->assertFileIsWritable($fileLocker->getLockFilePath()); + + $this->assertNull($fileLocker->getLockFile()); + $other = new FileLocker(true, $this->tempDir, 'test'); + $other->lock(); + $this->assertIsResource($other->getLockFile()); + $other->unlock(); } - protected function generateConfig(string $dirPath): array + public function testUnlockKeepsTheLockFile(): void { - return [ - 'enabled' => true, - 'dirPath' => $dirPath, - ]; + $fileLocker = new FileLocker(true, $this->tempDir, 'test'); + $fileLocker->lock(); + + $fileLocker->unlock(); + + $this->assertFileIsReadable($fileLocker->getLockFilePath()); + $this->assertFileIsWritable($fileLocker->getLockFilePath()); } } diff --git a/test/Fixture/AliasedCommand.php b/test/Fixture/AliasedCommand.php new file mode 100644 index 0000000..ff85c34 --- /dev/null +++ b/test/Fixture/AliasedCommand.php @@ -0,0 +1,23 @@ +writeln(self::OUTPUT); + + return Command::SUCCESS; + } +} diff --git a/test/Fixture/InMemoryContainer.php b/test/Fixture/InMemoryContainer.php new file mode 100644 index 0000000..31e4689 --- /dev/null +++ b/test/Fixture/InMemoryContainer.php @@ -0,0 +1,43 @@ + $services + */ + public function __construct(private array $services = []) + { + } + + public function set(string $id, mixed $service): self + { + $this->services[$id] = $service; + + return $this; + } + + public function get(string $id): mixed + { + if (! array_key_exists($id, $this->services)) { + throw NotFoundException::forService($id); + } + + return $this->services[$id]; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->services); + } +} diff --git a/test/Fixture/NotFoundException.php b/test/Fixture/NotFoundException.php new file mode 100644 index 0000000..05d9a50 --- /dev/null +++ b/test/Fixture/NotFoundException.php @@ -0,0 +1,18 @@ +tempDir = sys_get_temp_dir() . '/' . uniqid('dot-cli-test-', true); + mkdir($this->tempDir, 0775, true); + } + + #[After] + protected function removeTempDirectory(): void + { + if (! is_dir($this->tempDir)) { + return; + } + + foreach (glob($this->tempDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->tempDir); + } +}