diff --git a/.aiignore b/.aiignore
new file mode 100644
index 0000000..eb096a2
--- /dev/null
+++ b/.aiignore
@@ -0,0 +1,7 @@
+/.op
+/stubs
+
+.DS_Store
+.envrc
+*.log
+*.tmp
diff --git a/.gitattributes b/.gitattributes
index e1f3001..41dc454 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,18 +1,32 @@
-/.github export-ignore
-/.idea export-ignore
-/.run export-ignore
-/tests export-ignore
+/.github export-ignore
+/.idea export-ignore
+/.op export-ignore
+/.run export-ignore
+/features export-ignore
+/stubs export-ignore
+/tests export-ignore
+/var export-ignore
+/vendor export-ignore
-/.editorconfig export-ignore
-/.gitattributes export-ignore
-/.gitignore export-ignore
-/.php-cs-fixer.dist.php export-ignore
-/docker-compose.yaml export-ignore
-/phpdoc.dist.xml export-ignore
-/phpstan.dist.neon export-ignore
-/phpstan.neon.example export-ignore
-/phpunit.dist.xml export-ignore
-/README.md export-ignore
+/.aiignore export-ignore
+/.editorconfig export-ignore
+/.env export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/.php-cs-fixer.dist.php export-ignore
+/AGENTS.md export-ignore
+/behat.dist.yaml export-ignore
+/composer.json export-ignore
+/composer.lock export-ignore
+/docker-compose.yaml export-ignore
+/docker-compose.override.yaml export-ignore
+/phpdoc.dist.xml export-ignore
+/phpstan.dist.neon export-ignore
+/phpstan.neon.example export-ignore
+/phpunit.dist.xml export-ignore
+/README.md export-ignore
+
+.envrc export-ignore
*.php diff=php
*.phar -diff
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 26562b7..7f0fa2e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,14 +30,12 @@ jobs:
with:
os: ${{ matrix.os }}
- integration-tests:
- name: Integration Tests
+ feature-tests:
+ name: Feature Tests
strategy:
matrix:
- os: [ubuntu]
- composer-version: ['1.10.27', '2.2.27', 'v2']
+ os: [ubuntu, windows]
fail-fast: false
- uses: ./.github/workflows/integration-tests.yml
+ uses: ./.github/workflows/feature-tests.yml
with:
os: ${{ matrix.os }}
- composer-version: ${{ matrix.composer-version }}
diff --git a/.github/workflows/feature-tests.yml b/.github/workflows/feature-tests.yml
new file mode 100644
index 0000000..47c1c7f
--- /dev/null
+++ b/.github/workflows/feature-tests.yml
@@ -0,0 +1,40 @@
+name: Feature Tests
+
+on:
+ workflow_call:
+ inputs:
+ os:
+ description: Operating System
+ required: false
+ type: string
+ default: ubuntu
+
+permissions:
+ checks: write
+ contents: read
+ issues: read
+ pull-requests: write
+
+jobs:
+ feature-tests:
+ name: Behat
+ strategy:
+ matrix:
+ php-version: ['8.1', '8.2', '8.3', '8.4', '8.5']
+ behat-profile: ['latest', 'lowest']
+ fail-fast: false
+ runs-on: ${{ inputs.os }}-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup PHP and Install Composer Packages
+ uses: ./.github/actions/setup
+ with:
+ php-version: '${{ matrix.php-version }}'
+
+ - name: Run Docker-Composer Feature Tests
+ shell: bash
+ env:
+ XDEBUG_MODE: 'off'
+ run: behat --profile ${{ inputs.behat-profile }}
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
deleted file mode 100644
index 59cdbe1..0000000
--- a/.github/workflows/integration-tests.yml
+++ /dev/null
@@ -1,55 +0,0 @@
-name: Integration Tests
-
-on:
- workflow_call:
- inputs:
- os:
- description: Operating System
- required: false
- type: string
- default: ubuntu
- composer-version:
- description: Composer version to exercise through the integration fixture
- required: false
- type: string
- default: v2
-
-permissions:
- contents: read
-
-jobs:
- integration-tests:
- name: Docker Compose / Composer ${{ inputs.composer-version }}
- runs-on: ${{ inputs.os }}-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup PHP and Install Composer Packages
- uses: ./.github/actions/setup
- with:
- php-version: '8.3'
- composer-version: 'v2'
-
- - name: Setup Composer ${{ inputs.composer-version }} Under Test
- if: inputs.composer-version != 'v2'
- uses: shivammathur/setup-php@v2
- with:
- php-version: '8.3'
- tools: composer:${{ inputs.composer-version }}
- coverage: none
-
- - name: Show Tool Versions
- shell: bash
- run: |
- php --version
- composer --version
- docker --version
- docker compose version
-
- - name: Run Docker Composer Integration Tests
- shell: bash
- env:
- DOCKER_COMPOSER_TEST_COMPOSER_VERSION: ${{ inputs.composer-version }}
- XDEBUG_MODE: 'off'
- run: vendor/bin/phpunit --testsuite Integration
diff --git a/.gitignore b/.gitignore
index c014941..49a9154 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
/phpunit.xml
.DS_Store
+.envrc
.git
.op
Thumbs.db
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 159f3ab..c5a50bc 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -183,5 +183,8 @@
+
+
+
\ No newline at end of file
diff --git a/.idea/docker-composer.iml b/.idea/docker-composer.iml
index 73307ed..216fd94 100644
--- a/.idea/docker-composer.iml
+++ b/.idea/docker-composer.iml
@@ -4,6 +4,9 @@
+
+
+
@@ -73,6 +76,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/php.xml b/.idea/php.xml
index 621e190..e2fc9fd 100644
--- a/.idea/php.xml
+++ b/.idea/php.xml
@@ -28,26 +28,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -61,6 +90,7 @@
+
@@ -68,8 +98,14 @@
+
+
+
+
+
+
@@ -77,25 +113,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
diff --git a/.idea/scopes/Code_Style.xml b/.idea/scopes/Code_Style.xml
index 2559a3d..99ecbb3 100644
--- a/.idea/scopes/Code_Style.xml
+++ b/.idea/scopes/Code_Style.xml
@@ -1,3 +1,3 @@
-
+
\ No newline at end of file
diff --git a/.idea/scopes/Static_Analysis.xml b/.idea/scopes/Static_Analysis.xml
index 4a4a5c6..016c74c 100644
--- a/.idea/scopes/Static_Analysis.xml
+++ b/.idea/scopes/Static_Analysis.xml
@@ -1,3 +1,3 @@
-
+
\ No newline at end of file
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index c90aed6..e6868b0 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -4,6 +4,7 @@
$finder = (new PhpCsFixer\Finder())
->in([
+ __DIR__ . '/features/bootstrap',
__DIR__ . '/src',
__DIR__ . '/tests',
]);
@@ -15,4 +16,5 @@
->setRules([
'@PER-CS' => true,
'@PER-CS:risky' => true,
+ //'psr_autoloading' => true,
]);
diff --git a/AGENTS.md b/AGENTS.md
index 39c4b8d..1473255 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,23 +1,46 @@
-## General Instructions
-In all interactions and comments be extremely concise — sacrifice grammar for the sake of conciseness. Conciseness alone does not justify omitting information or intent.
+## Project Structure
+
+```
+docker-composer/
+├─╴.github/ — GitHub Actions workflows.
+├─╴config/ — Configuration files for Laravel support.
+├─╴features/ — Behat feature test suite, written in Gerkin.
+│ └─╴bootstrap/ — Behat Context classes.
+├─╴src/ — Source files organized by domain. Follows PSR-4.
+│ └─╴Laravel/ — Source code related to Laravel support.
+├─╴tests/ — Unit tests.
+└─╴vendor/ — Vendor packages, installed by Composer.
+```
+
+## Instructions
In commit messages use conventional commits and provide justification of the changes in the body.
-## Plan Mode
-Make plans extremely concise — sacrifice grammar for the sake of conciseness. Conciseness alone does not justify omitting information or intent.
+### Plan Mode
At the end of each plan, give me a list of unresolved questions to answer, if any.
+When asking the user to choose an approach, consider whether chaining multiple approaches is also a valid or even the recommended option.
-## Tests
+### Tests
When writing unit tests, create a TestCase class for each class being tested.
At the end of every task, execute these commands to ensure the quality of the code:
-- `composer style-fix`
-- `composer stan`
-- `composer test`
+- `composer cs-fix`
+- `composer check`
+
+#### Feature Tests
+When adding new behavior, write a Behat feature spec that covers it. When changing behavior, update the corresponding Behat feature spec.
-## Coding Style
+#### Coverage
+All unit tests are required to have both a branch and line coverage of 100%.
+
+### Architecture
+DRY: Don't Repeat Yourself — before adding new code, inspect existing abstractions and extend/reuse them.
+Framework integrations belong in framework-named subdirectories under `src/`.
+Do not duplicate code when a shared abstraction can cover the behavior.
+
+### Coding Style
All PHP code must adhere to PER Coding Style, which includes PSR-1: Basic Coding Standard.
-Files should _either_ declare symbols _or_ cause side-effects but not both.
+Each class must be in a file by itself.
-## PHPDoc
+### PHPDoc
Add descriptive PHPDoc comments to all Structural Elements in PHP code under `src/`. For functions and methods include the return type, and the `@param` and `@return` tags for every argument.
When writing PHPDocs, observe this format:
@@ -42,7 +65,15 @@ class Foo
/**
* The `@var` doctag is omitted if the type is unambiguous.
*/
- private Baz baz():
+ private Baz $baz;
+
+ /**
+ * The `@return` doctag is omitted if the return type is `void`.
+ */
+ public function doSomething(): void
+ {
+ // Imagine this method does something.
+ }
/**
* The first line should be a short description, no Markdown allowed here.
@@ -89,5 +120,5 @@ class Foo
}
```
-## Tools
+### Tools
If a tool, command or integration fails that one would expect to be working, do not try a different approach. Instead, investigate the problem and suggest a fix to the user.
diff --git a/README.md b/README.md
index 7687b9e..f69d11d 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,15 @@
-# docker-composer
+# Docker-Composer
-Composer plugin that ensures scripts are always executed within a Docker Compose service.
+**Docker-Composer** is a Composer plugin that ensures scripts are always executed within a Docker Compose service.
## Installation
```bash
-composer require --dev empaphy/docker-composer
composer config allow-plugins.empaphy/docker-composer true
+composer require --dev empaphy/docker-composer
```
-Composer 2.2 and newer require plugins to be allowed explicitly. Composer 1 ignores
-`allow-plugins`.
+Composer 2.2 and newer require plugins to be allowed explicitly.
## Configuration
@@ -46,7 +45,7 @@ Supported keys:
- `mode`: `exec` or `run`; defaults to `exec`.
- `compose-files`: one compose file path or a list of compose file paths.
- `project-directory`: optional Docker Compose project directory.
-- `workdir`: optional working directory inside the container.
+- `workdir`: optional working directory inside the container. When omitted, the plugin attempts to infer it.
- `exclude`: exact Composer script/event names that should run on the host.
- `service-mapping`: Docker Compose service names mapped to one script or a list of scripts.
@@ -85,6 +84,12 @@ then run normally because the plugin detects that Composer is already inside a
container. It also treats `/.dockerenv`, `/run/.containerenv`, and common cgroup
markers as container signals.
+When `workdir` is omitted, the plugin attempts to infer the active host working
+directory's container path from Docker Compose bind volumes. If no mapping is
+found, it falls back to configured service `working_dir`, probing `pwd`, then
+image `Config.WorkingDir`. Path translation only runs when a host-to-container
+mapping is known.
+
Set `DOCKER_COMPOSER_DISABLE=1` to bypass Docker redirection temporarily.
## Scope
@@ -100,3 +105,38 @@ requirements are resolved from inside the configured service:
- `composer require`
- `composer remove`
- `composer reinstall`
+
+## Laravel
+
+**Docker-Composer** also registers a Laravel service provider through package
+autodiscovery. Publish and enable the Laravel config:
+
+```bash
+php artisan vendor:publish --tag=docker-composer-config
+```
+
+```php
+return [
+ 'enabled' => env('DOCKER_COMPOSER_LARAVEL', false),
+ 'service' => 'php',
+ 'mode' => 'exec',
+ 'compose_files' => ['docker-compose.yaml'],
+ 'project_directory' => '.',
+ 'workdir' => '/usr/src/app',
+ 'exclude' => ['queue:work'],
+ 'service_mapping' => [
+ 'php-tools' => [
+ 'config:cache',
+ Illuminate\Foundation\Console\ConfigCacheCommand::class,
+ ':scripts/task.php',
+ ],
+ ],
+];
+```
+
+When enabled, Laravel CLI bootstraps run in Docker Compose unless excluded.
+Artisan commands can be mapped by command name or command class. Custom scripts
+that bootstrap Laravel can be mapped by project-relative path prefixed with `:`.
+
+The Laravel integration preserves the original CLI arguments and translates
+absolute host project paths to the configured container `workdir`.
diff --git a/behat.dist.yaml b/behat.dist.yaml
new file mode 100644
index 0000000..cf535a0
--- /dev/null
+++ b/behat.dist.yaml
@@ -0,0 +1,25 @@
+default:
+ gherkin: { cache: '%paths.base%/var/cache/behat/gherkin' }
+ testers: { rerun_cache: '%paths.base%/var/cache/behat/rerun' }
+ suites:
+ composer_plugin:
+ paths: ['%paths.base%/features/composer_plugin.feature']
+ contexts:
+ - FeatureContext
+ laravel:
+ paths: ['%paths.base%/features/laravel.feature']
+ contexts:
+ - LaravelContext
+
+lowest:
+ gherkin: { cache: '%paths.base%/var/cache/behat/gherkin' }
+ testers: { rerun_cache: '%paths.base%/var/cache/behat/rerun' }
+ suites:
+ composer_plugin:
+ paths: ['%paths.base%/features/composer_plugin.feature']
+ contexts:
+ - FeatureContext: { dependencyResolutionMode: prefer-lowest }
+ laravel:
+ paths: ['%paths.base%/features/laravel.feature']
+ contexts:
+ - LaravelContext: { dependencyResolutionMode: prefer-lowest }
diff --git a/composer.json b/composer.json
index 34b38b9..bb16a28 100644
--- a/composer.json
+++ b/composer.json
@@ -20,15 +20,21 @@
},
"require": {
"php": ">=8.1",
- "composer-plugin-api": ">=1.1"
+ "composer-plugin-api": ">=2"
},
"require-dev": {
- "composer/composer": ">=1.1",
+ "behat/behat": "^3",
+ "composer/composer": ">=2",
"empaphy/filharmonic": "^1",
+ "illuminate/console": ">=10",
+ "illuminate/support": ">=10",
"friendsofphp/php-cs-fixer": "^3",
+ "phpstan/extension-installer": "^1",
"phpstan/phpstan": "^2",
"phpstan/phpstan-phpunit": "^2",
- "phpunit/phpunit": ">=10"
+ "phpstan/phpstan-strict-rules": "^2",
+ "phpunit/phpunit": ">=10",
+ "vlucas/phpdotenv": "^5.6.1"
},
"autoload": {
"psr-4": {
@@ -46,26 +52,39 @@
"PKSA-z3gr-8qht-p93v"
: "'PHPUnit Vulnerable to Unsafe Deserialization in PHPT Code Coverage Handling' – We don't use PHPT"
}
+ },
+ "allow-plugins": {
+ "phpstan/extension-installer": true
}
},
"scripts": {
- "style-check": "XDEBUG_MODE=off php-cs-fixer check",
- "style-fix": "XDEBUG_MODE=off php-cs-fixer fix",
- "stan": "XDEBUG_MODE=off phpstan analyse --memory-limit=1G",
- "test": "XDEBUG_MODE=coverage phpunit --coverage-text",
- "test-integration": "XDEBUG_MODE=off phpunit --testsuite Integration",
- "test-unit": "XDEBUG_MODE=coverage phpunit --testsuite Unit --coverage-text"
+ "behat": [
+ "XDEBUG_MODE=off behat",
+ "XDEBUG_MODE=off behat --profile lowest"
+ ],
+ "check": ["@cs-check", "@phpstan", "@test"],
+ "cs-check": "XDEBUG_MODE=off php-cs-fixer check",
+ "cs-fix": "XDEBUG_MODE=off php-cs-fixer fix",
+ "phpstan": "XDEBUG_MODE=off phpstan analyse --memory-limit=1G",
+ "test": ["@phpunit", "@behat"],
+ "phpunit": "XDEBUG_MODE=coverage phpunit --testsuite Unit --coverage-text"
},
"scripts-descriptions": {
- "style-check": "Check coding style using `php-cs-fixer check [options] [--] [...]`",
- "style-fix": "Fix coding style using `php-cs-fixer fix [options] [--] [...]`",
- "stan": "Perform static analysis using `phpstan analyse [options] [--] [...]`",
- "test": "Run all test suites using `phpunit --coverage-text [options] [ ...]`",
- "test-integration": "Run Integration test suite using `phpunit --testsuite Integration [options] [ ...]`",
- "test-unit": "Run Unit test suite using `phpunit --testsuite Unit --coverage-text [options] [ ...]`"
+ "behat": "Run Behat feature tests using `behat [options] [--] [...]`.",
+ "check": "Perform all automated checks.",
+ "cs-check": "Check coding style using `php-cs-fixer check [options] [--] [...]`",
+ "cs-fix": "Fix coding style using `php-cs-fixer fix [options] [--] [...]`",
+ "phpstan": "Perform static analysis using `phpstan analyse [options] [--] [...]`",
+ "test": "Run all test suites.",
+ "phpunit": "Run Unit test suite using `phpunit --testsuite Unit --coverage-text [options] [ ...]`"
},
"extra": {
"class": "empaphy\\docker_composer\\DockerComposerPlugin",
+ "laravel": {
+ "providers": [
+ "empaphy\\docker_composer\\Laravel\\ServiceProvider"
+ ]
+ },
"branch-alias": {
"dev-main": "1.x-dev"
}
diff --git a/config/docker_composer.php b/config/docker_composer.php
new file mode 100644
index 0000000..f9aaf50
--- /dev/null
+++ b/config/docker_composer.php
@@ -0,0 +1,14 @@
+ env('DOCKER_COMPOSER_LARAVEL', false),
+ 'service' => null,
+ 'mode' => 'exec',
+ 'compose_files' => [],
+ 'project_directory' => null,
+ 'workdir' => null,
+ 'exclude' => [],
+ 'service_mapping' => [],
+];
diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php
new file mode 100644
index 0000000..234605c
--- /dev/null
+++ b/features/bootstrap/FeatureContext.php
@@ -0,0 +1,132 @@
+projectDirectory = $this->createProject([]);
+ }
+
+ #[Given('a Composer project configured for exec mode')]
+ public function createComposerProjectConfiguredForExecMode(): void
+ {
+ $this->projectDirectory = $this->createProject([
+ 'service' => 'php',
+ 'mode' => 'exec',
+ 'compose-files' => 'docker-compose.yaml',
+ ]);
+ }
+
+ #[Given('a Composer project configured for exec install redirection')]
+ public function createComposerProjectConfiguredForExecInstallRedirection(): void
+ {
+ $this->projectDirectory = $this->createProject([
+ 'service' => 'php',
+ 'mode' => 'exec',
+ 'compose-files' => 'docker-compose.yaml',
+ 'workdir' => '/usr/src/app',
+ ]);
+ }
+
+ #[Given('a Composer project configured with service mapping override')]
+ public function createComposerProjectConfiguredWithServiceMappingOverride(): void
+ {
+ $this->projectDirectory = $this->createProject([
+ 'service' => 'php',
+ 'service-mapping' => [
+ 'php_tools' => 'mark',
+ ],
+ 'compose-files' => 'docker-compose.yaml',
+ 'workdir' => '/usr/src/app',
+ ]);
+ }
+
+ #[Given('a Composer project configured for run mode')]
+ public function createComposerProjectConfiguredForRunMode(): void
+ {
+ $this->projectDirectory = $this->createProject([
+ 'service' => 'php',
+ 'mode' => 'run',
+ 'compose-files' => 'docker-compose.yaml',
+ 'workdir' => '/usr/src/app',
+ ]);
+ }
+
+ #[Given('a Composer project without Docker-Composer configuration')]
+ public function createComposerProjectWithoutDockerComposerConfiguration(): void
+ {
+ $this->projectDirectory = $this->createProject([]);
+ }
+
+ /**
+ * @param array $dockerComposerConfig
+ * @param list>|null $repositories
+ */
+ private function createProject(array $dockerComposerConfig, ?array $repositories = null, string $requireVersion = '*'): string
+ {
+ $projectDirectory = $this->createTemporaryProjectDirectory('docker-composer-integration-');
+
+ $composerJson = [
+ 'name' => 'empaphy/docker-composer-integration',
+ 'description' => 'Temporary docker-composer integration fixture.',
+ 'minimum-stability' => 'dev',
+ 'prefer-stable' => true,
+ 'repositories' => $repositories ?? [[
+ 'type' => 'path',
+ 'url' => dirname(__DIR__, 2),
+ 'options' => ['symlink' => false],
+ ]],
+ 'require' => [
+ 'empaphy/docker-composer' => $requireVersion,
+ ],
+ 'config' => [
+ 'allow-plugins' => [
+ 'empaphy/docker-composer' => true,
+ ],
+ ],
+ 'scripts' => [
+ 'mark' => '@php -r "file_put_contents(\'result.txt\', getenv(\'DOCKER_COMPOSER_TEST_MARK\') ?: (getenv(\'DOCKER_COMPOSER_INSIDE\') ?: \'host\'));"',
+ 'post-autoload-dump' => '@php -r "file_put_contents(\'lifecycle.txt\', getenv(\'DOCKER_COMPOSER_INSIDE\') ?: \'host\');"',
+ ],
+ 'extra' => [
+ 'docker-composer' => $dockerComposerConfig,
+ ],
+ ];
+
+ $this->writeJson($projectDirectory . '/composer.json', $composerJson);
+ file_put_contents($projectDirectory . '/docker-compose.yaml', sprintf(<<<'YAML'
+services:
+ php:
+ image: %s
+ command: ['sleep', 'infinity']
+ working_dir: /usr/src/app
+ volumes:
+ - { type: bind, source: '.', target: '/usr/src/app' }
+ php_tools:
+ image: %s
+ command: ['sleep', 'infinity']
+ environment:
+ DOCKER_COMPOSER_TEST_MARK: override
+ working_dir: /usr/src/app
+ volumes:
+ - { type: bind, source: '.', target: '/usr/src/app' }
+YAML, $this->getComposerImage(), $this->getComposerImage()));
+
+ return $projectDirectory;
+ }
+}
diff --git a/features/bootstrap/InteractsWithTemporaryProjects.php b/features/bootstrap/InteractsWithTemporaryProjects.php
new file mode 100644
index 0000000..ec9a450
--- /dev/null
+++ b/features/bootstrap/InteractsWithTemporaryProjects.php
@@ -0,0 +1,311 @@
+
+ */
+ private array $projectDirectories = [];
+
+ /**
+ * The temporary project used by the current step.
+ */
+ protected ?string $projectDirectory = null;
+
+ /**
+ * The process result captured from the latest command.
+ *
+ * @var array{stdout: string, stderr: string, exitCode: int}|null
+ */
+ protected ?array $lastCommandResult = null;
+
+ /**
+ * Dependency resolution mode selected by the active Behat profile.
+ */
+ private string $dependencyResolutionMode = 'latest';
+
+ public function __construct(string $dependencyResolutionMode = 'latest')
+ {
+ $this->setDependencyResolutionMode($dependencyResolutionMode);
+ }
+
+ #[AfterScenario]
+ public function cleanupProjects(): void
+ {
+ foreach ($this->projectDirectories as $projectDirectory) {
+ $this->runCommand(['docker', 'compose', 'down', '--volumes', '--remove-orphans'], $projectDirectory, [], false);
+ $this->removeDirectory($projectDirectory);
+ }
+
+ $this->projectDirectories = [];
+ $this->projectDirectory = null;
+ $this->lastCommandResult = null;
+ }
+
+ #[When('I run :command in the project')]
+ public function runCommandInProject(string $command): void
+ {
+ $this->lastCommandResult = $this->runShellCommand(
+ $command,
+ $this->getProjectDirectory(),
+ $this->explicitProjectCommandEnvironment(),
+ );
+ }
+
+ #[When('I run :command in the :service service of the Composer project')]
+ public function runCommandInComposerProjectService(string $command, string $service): void
+ {
+ $this->runShellCommandInProjectService($command, $service);
+ }
+
+ #[When('I run :command in the :service service of the Laravel project')]
+ public function runCommandInLaravelProjectService(string $command, string $service): void
+ {
+ $this->runShellCommandInProjectService($command, $service);
+ }
+
+ #[When('I delete the project file :path')]
+ public function deleteProjectFile(string $path): void
+ {
+ @unlink($this->getProjectDirectory() . '/' . $path);
+ }
+
+ #[Then('the project file :path should contain :expected')]
+ public function assertProjectFileShouldContain(string $path, string $expected): void
+ {
+ $filePath = $this->getProjectDirectory() . '/' . $path;
+
+ Assert::assertFileExists($filePath);
+ Assert::assertSame($expected, trim((string) file_get_contents($filePath)));
+ }
+
+ #[Then('the last command error output should contain :expected')]
+ public function assertLastCommandErrorOutputShouldContain(string $expected): void
+ {
+ Assert::assertStringContainsString($expected, $this->getLastCommandResult()['stderr']);
+ }
+
+ protected function getProjectDirectory(): string
+ {
+ if ($this->projectDirectory === null) {
+ throw new RuntimeException('No temporary project has been created for this scenario.');
+ }
+
+ return $this->projectDirectory;
+ }
+
+ /**
+ * @return array{stdout: string, stderr: string, exitCode: int}
+ */
+ protected function getLastCommandResult(): array
+ {
+ if ($this->lastCommandResult === null) {
+ throw new RuntimeException('No command has been run yet.');
+ }
+
+ return $this->lastCommandResult;
+ }
+
+ protected function createTemporaryProjectDirectory(string $prefix): string
+ {
+ $projectDirectory = $this->getTempDir() . DIRECTORY_SEPARATOR . $prefix . bin2hex(random_bytes(8));
+ if (! mkdir($projectDirectory, 0o777, true) && ! is_dir($projectDirectory)) {
+ throw new RuntimeException("Unable to create integration project directory `$projectDirectory`.");
+ }
+
+ $this->projectDirectories[] = $projectDirectory;
+
+ return $projectDirectory;
+ }
+
+ protected function getComposerImage(): string
+ {
+ return 'composer:2';
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function writeJson(string $path, array $data): void
+ {
+ $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if ($encoded === false) {
+ throw new RuntimeException(sprintf('Unable to encode "%s".', $path));
+ }
+
+ file_put_contents($path, $encoded . PHP_EOL);
+ }
+
+ /**
+ * @param list $command
+ * @param array $environment
+ *
+ * @return array{stdout: string, stderr: string, exitCode: int}
+ */
+ protected function runCommand(array $command, string $workingDirectory, array $environment = [], bool $failOnError = true): array
+ {
+ return $this->runProcess($command, $workingDirectory, $environment, $failOnError);
+ }
+
+ /**
+ * @param array $environment
+ *
+ * @return array{stdout: string, stderr: string, exitCode: int}
+ */
+ protected function runShellCommand(string $command, string $workingDirectory, array $environment = [], bool $failOnError = true): array
+ {
+ return $this->runProcess($command, $workingDirectory, $environment, $failOnError);
+ }
+
+ /**
+ * @param list|string $command
+ * @param array $environment
+ *
+ * @return array{stdout: string, stderr: string, exitCode: int}
+ */
+ private function runProcess(array|string $command, string $workingDirectory, array $environment = [], bool $failOnError = true): array
+ {
+ $descriptorSpec = [
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+ $processEnvironment = array_merge(getenv() ?: [], [
+ 'COMPOSER_CACHE_DIR' => dirname(__DIR__, 2) . '/var/cache/composer',
+ 'COMPOSER_NO_INTERACTION' => '1',
+ ], $environment);
+
+ $process = proc_open($command, $descriptorSpec, $pipes, $workingDirectory, $processEnvironment);
+ if (! is_resource($process)) {
+ throw new RuntimeException(sprintf('Unable to start command: %s', $this->formatCommand($command)));
+ }
+
+ $stdout = stream_get_contents($pipes[1]);
+ $stderr = stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ $exitCode = proc_close($process);
+
+ if ($failOnError && $exitCode !== 0) {
+ Assert::fail(sprintf(
+ "Command failed with exit code %d:\n%s\n\nSTDOUT:\n%s\n\nSTDERR:\n%s",
+ $exitCode,
+ $this->formatCommand($command),
+ $stdout,
+ $stderr,
+ ));
+ }
+
+ return [
+ 'stdout' => $stdout === false ? '' : $stdout,
+ 'stderr' => $stderr === false ? '' : $stderr,
+ 'exitCode' => $exitCode,
+ ];
+ }
+
+ private function runShellCommandInProjectService(string $command, string $service): void
+ {
+ $environment = $this->explicitProjectCommandEnvironment();
+ $dockerCommand = [
+ 'docker',
+ 'compose',
+ 'run',
+ '--rm',
+ '-T',
+ '--workdir',
+ '/usr/src/app',
+ '--env',
+ 'DOCKER_COMPOSER_INSIDE=1',
+ ];
+
+ foreach ($environment as $name => $value) {
+ $dockerCommand[] = '--env';
+ $dockerCommand[] = $name . '=' . $value;
+ }
+
+ $this->lastCommandResult = $this->runCommand(
+ array_merge($dockerCommand, [$service, 'sh', '-lc', $command]),
+ $this->getProjectDirectory(),
+ $environment,
+ );
+ }
+
+ /**
+ * @return array
+ */
+ private function explicitProjectCommandEnvironment(): array
+ {
+ if ($this->dependencyResolutionMode === 'prefer-lowest') {
+ return ['COMPOSER_PREFER_LOWEST' => '1'];
+ }
+
+ return [];
+ }
+
+ /**
+ * @param list|string $command
+ */
+ private function formatCommand(array|string $command): string
+ {
+ if (is_string($command)) {
+ return $command;
+ }
+
+ return implode(' ', $command);
+ }
+
+ private function setDependencyResolutionMode(string $dependencyResolutionMode): void
+ {
+ if (! in_array($dependencyResolutionMode, ['latest', 'prefer-lowest'], true)) {
+ throw new InvalidArgumentException(sprintf('Unsupported dependency resolution mode "%s".', $dependencyResolutionMode));
+ }
+
+ $this->dependencyResolutionMode = $dependencyResolutionMode;
+ }
+
+ private function removeDirectory(string $directory): bool
+ {
+ if (! is_dir($directory)) {
+ return true;
+ }
+
+ foreach (
+ new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST,
+ ) as $fileInfo
+ ) {
+ assert($fileInfo instanceof SplFileInfo);
+ if ($fileInfo->isDir() && ! $fileInfo->isLink()) {
+ rmdir($fileInfo->getPathname());
+ } else {
+ unlink($fileInfo->getPathname());
+ }
+ }
+
+ return rmdir($directory);
+ }
+
+ private function getTempDir(): string
+ {
+ return dirname(__DIR__, 2) . '/var/tmp/features';
+ //return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
+ }
+}
diff --git a/features/bootstrap/LaravelContext.php b/features/bootstrap/LaravelContext.php
new file mode 100644
index 0000000..3b27686
--- /dev/null
+++ b/features/bootstrap/LaravelContext.php
@@ -0,0 +1,330 @@
+projectDirectory = $this->createLaravelProject();
+ }
+
+ #[When('I configure Laravel Docker-Composer redirection')]
+ public function configureLaravelDockerComposerRedirection(): void
+ {
+ $this->writeLaravelDockerComposerConfig($this->getProjectDirectory());
+ }
+
+ #[Then('the Laravel package should autodiscover the Docker-Composer service provider')]
+ public function assertLaravelPackageAutodiscoversDockerComposerServiceProvider(): void
+ {
+ $packagesPath = $this->getProjectDirectory() . '/bootstrap/cache/packages.php';
+
+ Assert::assertFileExists($packagesPath);
+
+ $packages = require $packagesPath;
+ if (! is_array($packages)) {
+ throw new RuntimeException(sprintf('Expected "%s" to return an array.', $packagesPath));
+ }
+
+ $package = $packages['empaphy/docker-composer'] ?? null;
+ if (! is_array($package)) {
+ throw new RuntimeException('Expected the Docker-Composer package to be discovered.');
+ }
+
+ $providers = $package['providers'] ?? null;
+ if (! is_array($providers)) {
+ throw new RuntimeException('Expected the Docker-Composer package to define providers.');
+ }
+
+ Assert::assertContains('empaphy\\docker_composer\\Laravel\\ServiceProvider', $providers);
+ }
+
+ #[Then('the Laravel Docker-Composer configuration should exist')]
+ public function assertLaravelDockerComposerConfigurationShouldExist(): void
+ {
+ Assert::assertFileExists($this->getProjectDirectory() . '/config/docker_composer.php');
+ }
+
+ private function createLaravelProject(): string
+ {
+ $projectDirectory = $this->createTemporaryProjectDirectory('docker-composer-laravel-integration-');
+ foreach ([
+ 'app/Console/Commands',
+ 'app/Exceptions',
+ 'app/Console',
+ 'bootstrap/cache',
+ 'config',
+ 'scripts',
+ ] as $directory) {
+ $path = $projectDirectory . '/' . $directory;
+ if (! is_dir($path) && ! mkdir($path, 0777, true) && ! is_dir($path)) {
+ throw new RuntimeException(sprintf('Unable to create directory "%s".', $path));
+ }
+ }
+
+ $this->writeJson($projectDirectory . '/composer.json', [
+ 'name' => 'empaphy/docker-composer-laravel-integration',
+ 'description' => 'Temporary docker-composer Laravel integration fixture.',
+ 'minimum-stability' => 'dev',
+ 'prefer-stable' => true,
+ 'repositories' => [[
+ 'type' => 'path',
+ 'url' => dirname(__DIR__, 2),
+ 'options' => ['symlink' => false],
+ ]],
+ 'require' => [
+ 'laravel/framework' => '^12.0',
+ 'empaphy/docker-composer' => '*',
+ ],
+ 'autoload' => [
+ 'psr-4' => [
+ 'App\\' => 'app/',
+ ],
+ ],
+ 'config' => [
+ 'allow-plugins' => [
+ 'empaphy/docker-composer' => true,
+ ],
+ ],
+ 'scripts' => [
+ 'post-autoload-dump' => [
+ 'Illuminate\\Foundation\\ComposerScripts::postAutoloadDump',
+ '@php artisan package:discover --ansi',
+ ],
+ ],
+ ]);
+
+ file_put_contents($projectDirectory . '/artisan', <<<'PHP'
+#!/usr/bin/env php
+make(Kernel::class);
+$input = new ArgvInput();
+$status = $kernel->handle($input, new ConsoleOutput());
+$kernel->terminate($input, $status);
+
+exit($status);
+PHP);
+ chmod($projectDirectory . '/artisan', 0755);
+
+ file_put_contents($projectDirectory . '/bootstrap/app.php', <<<'PHP'
+singleton(KernelContract::class, Kernel::class);
+$app->singleton(ExceptionHandler::class, Handler::class);
+
+return $app;
+PHP);
+
+ file_put_contents($projectDirectory . '/config/app.php', <<<'PHP'
+ 'Docker-Composer Test',
+ 'env' => 'testing',
+ 'debug' => true,
+ 'url' => 'http://localhost',
+ 'timezone' => 'UTC',
+ 'locale' => 'en',
+ 'fallback_locale' => 'en',
+ 'faker_locale' => 'en_US',
+ 'key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
+ 'cipher' => 'AES-256-CBC',
+ 'providers' => ServiceProvider::defaultProviders()->toArray(),
+];
+PHP);
+
+ file_put_contents($projectDirectory . '/app/Console/Kernel.php', <<<'PHP'
+
+ */
+ protected $commands = [
+ MarkCommand::class,
+ ClassMappedCommand::class,
+ HostOnlyCommand::class,
+ ];
+}
+PHP);
+
+ file_put_contents($projectDirectory . '/app/Exceptions/Handler.php', <<<'PHP'
+make(Kernel::class)->bootstrap();
+
+file_put_contents(__DIR__ . '/../script.txt', getenv('DOCKER_COMPOSER_TEST_MARK') ?: (getenv('DOCKER_COMPOSER_INSIDE') ?: 'host'));
+PHP);
+ chmod($projectDirectory . '/scripts/bootstrap.php', 0755);
+
+ file_put_contents($projectDirectory . '/docker-compose.yaml', sprintf(<<<'YAML'
+services:
+ php:
+ image: %s
+ command: ['sleep', 'infinity']
+ working_dir: /usr/src/app
+ volumes:
+ - { type: bind, source: '.', target: '/usr/src/app' }
+ php_tools:
+ image: %s
+ command: ['sleep', 'infinity']
+ environment:
+ DOCKER_COMPOSER_TEST_MARK: mapped
+ working_dir: /usr/src/app
+ volumes:
+ - { type: bind, source: '.', target: '/usr/src/app' }
+YAML, $this->getComposerImage(), $this->getComposerImage()));
+
+ return $projectDirectory;
+ }
+
+ private function writeLaravelDockerComposerConfig(string $projectDirectory): void
+ {
+ file_put_contents($projectDirectory . '/config/docker_composer.php', <<<'PHP'
+ env('DOCKER_COMPOSER_LARAVEL', false),
+ 'service' => 'php',
+ 'mode' => 'exec',
+ 'compose_files' => 'docker-compose.yaml',
+ 'workdir' => '/usr/src/app',
+ 'exclude' => ['host-only'],
+ 'service_mapping' => [
+ 'php_tools' => [
+ App\Console\Commands\ClassMappedCommand::class,
+ ':scripts/bootstrap.php',
+ ],
+ ],
+];
+PHP);
+ }
+}
diff --git a/features/composer_plugin.feature b/features/composer_plugin.feature
new file mode 100644
index 0000000..dd4bd92
--- /dev/null
+++ b/features/composer_plugin.feature
@@ -0,0 +1,39 @@
+Feature: Composer plugin command redirection
+
+ Scenario: Exec mode redirects custom and lifecycle Composer scripts with auto-up
+ Given a Composer project configured for exec mode
+ When I run "composer install" in the project
+ And I run "docker compose down --volumes --remove-orphans" in the project
+ And I run "composer run-script mark" in the project
+ Then the project file "result.txt" should contain "1"
+ When I delete the project file "lifecycle.txt"
+ And I run "composer dump-autoload" in the project
+ Then the project file "lifecycle.txt" should contain "1"
+
+ Scenario: Install redirects after the plugin is already installed
+ Given a Composer project configured for exec install redirection
+ When I run "composer install" in the project
+ And I run "composer install" in the project
+ Then the last command error output should contain "Running composer install in Docker Compose service php."
+
+ Scenario: Service mapping override redirects to the configured service
+ Given a Composer project configured with service mapping override
+ When I run "composer install" in the project
+ And I run "composer run-script mark" in the project
+ Then the project file "result.txt" should contain "override"
+
+ Scenario: Run mode, disabled mode, inside-container behavior, and missing config behavior
+ Given a Composer project configured for run mode
+ When I run "composer install" in the project
+ And I run "composer run-script mark" in the project
+ Then the project file "result.txt" should contain "1"
+ When I delete the project file "result.txt"
+ And I run "DOCKER_COMPOSER_DISABLE=1 composer run-script mark" in the project
+ Then the project file "result.txt" should contain "host"
+ When I delete the project file "result.txt"
+ And I run "composer run-script mark" in the "php" service of the Composer project
+ Then the project file "result.txt" should contain "1"
+ Given a Composer project without Docker-Composer configuration
+ When I run "composer install" in the project
+ And I run "composer run-script mark" in the project
+ Then the project file "result.txt" should contain "host"
diff --git a/features/laravel.feature b/features/laravel.feature
new file mode 100644
index 0000000..a438bb0
--- /dev/null
+++ b/features/laravel.feature
@@ -0,0 +1,22 @@
+Feature: Laravel package integration
+
+ Scenario: Laravel autodiscovery and console redirection
+ Given a Laravel project
+ When I run "DOCKER_COMPOSER_LARAVEL=0 composer install" in the project
+ Then the Laravel package should autodiscover the Docker-Composer service provider
+ When I run "DOCKER_COMPOSER_LARAVEL=0 php artisan vendor:publish --tag=docker-composer-config --force" in the project
+ Then the Laravel Docker-Composer configuration should exist
+ When I configure Laravel Docker-Composer redirection
+ And I run "docker compose down --volumes --remove-orphans" in the project
+ And I run "DOCKER_COMPOSER_LARAVEL=true php artisan mark" in the project
+ Then the last command error output should contain "Running artisan mark in Docker Compose service php."
+ Then the project file "result.txt" should contain "1"
+ When I run "DOCKER_COMPOSER_LARAVEL=true php artisan class-map" in the project
+ Then the project file "class.txt" should contain "mapped"
+ When I run "DOCKER_COMPOSER_LARAVEL=true php scripts/bootstrap.php" in the project
+ Then the project file "script.txt" should contain "mapped"
+ When I run "DOCKER_COMPOSER_LARAVEL=true php artisan host-only" in the project
+ Then the project file "host.txt" should contain "host"
+ When I delete the project file "result.txt"
+ And I run "DOCKER_COMPOSER_LARAVEL=0 php artisan mark" in the project
+ Then the project file "result.txt" should contain "host"
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index 7e35e4f..d8ed9b5 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -1,27 +1,18 @@
-includes:
- - vendor/phpstan/phpstan-phpunit/extension.neon
-
parameters:
- checkFunctionNameCase: true
- checkInternalClassCaseSensitivity: true
- checkUninitializedProperties: true
-
+ checkUninitializedProperties: true
editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%'
-
-# ignoreErrors:
-# - identifier: missingType.generics
-# - identifier: missingType.iterableValue
-
level: 8
- reportWrongPhpDocTypeInVarTag: true
-
- resultCachePath: var/cache/phpstan/resultCache.php
- tipsOfTheDay: false
- treatPhpDocTypesAsCertain: false
-
+ parallel:
+ maximumNumberOfProcesses: 4
paths:
+ - features/bootstrap/
- src/
- tests/
+ resultCachePath: var/cache/phpstan/resultCache.php
+ strictRules:
+ disallowedShortTernary: false
+ tipsOfTheDay: false
+ treatPhpDocTypesAsCertain: false
services:
cacheStorage:
diff --git a/phpunit.dist.xml b/phpunit.dist.xml
index 74a865f..15ee496 100644
--- a/phpunit.dist.xml
+++ b/phpunit.dist.xml
@@ -7,6 +7,7 @@
beStrictAboutChangesToGlobalState="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
+ defaultTestSuite="Unit"
displayDetailsOnIncompleteTests="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
@@ -24,18 +25,12 @@
tests/Unit
-
- tests/Integration
-
src
-
- src/constants.php
-
diff --git a/src/DockerComposeCommandBuilder.php b/src/DockerComposeCommandBuilder.php
index 5da1f24..a9aeae3 100644
--- a/src/DockerComposeCommandBuilder.php
+++ b/src/DockerComposeCommandBuilder.php
@@ -26,13 +26,13 @@ class DockerComposeCommandBuilder
/**
* Builds the Docker Compose service startup command.
*
- * @param DockerComposerConfig $config
- * The Docker Composer configuration that provides service options.
+ * @param DockerComposeOptions $config
+ * The Docker-Composer configuration that provides service options.
*
* @return list
* Returns command arguments for `docker compose up -d`.
*/
- public function buildUpCommand(DockerComposerConfig $config): array
+ public function buildUpCommand(DockerComposeOptions $config): array
{
return array_merge($this->composeBase($config), [
'up',
@@ -44,13 +44,13 @@ public function buildUpCommand(DockerComposerConfig $config): array
/**
* Builds the Docker Compose running services command.
*
- * @param DockerComposerConfig $config
- * The Docker Composer configuration that provides service options.
+ * @param DockerComposeOptions $config
+ * The Docker-Composer configuration that provides service options.
*
* @return list
* Returns command arguments for `docker compose ps`.
*/
- public function buildRunningServicesCommand(DockerComposerConfig $config): array
+ public function buildRunningServicesCommand(DockerComposeOptions $config): array
{
return array_merge(
$this->composeBase($config),
@@ -61,8 +61,8 @@ public function buildRunningServicesCommand(DockerComposerConfig $config): array
/**
* Builds the Docker Compose script execution command.
*
- * @param DockerComposerConfig $config
- * The Docker Composer configuration that provides service options.
+ * @param DockerComposeOptions $config
+ * The Docker-Composer configuration that provides service options.
*
* @param ScriptEvent $event
* The Composer script event to replay inside Docker Compose.
@@ -70,39 +70,34 @@ public function buildRunningServicesCommand(DockerComposerConfig $config): array
* @param bool $interactive
* Whether the Docker command should keep TTY interaction enabled.
*
+ * @param string|null $hostPathRoot
+ * The host directory whose descendants can be translated.
+ *
+ * @param string|null $containerPathRoot
+ * The matching container directory, or `null` to leave paths unchanged.
+ *
* @return list
* Returns command arguments for `docker compose exec` or `run`.
*/
- public function buildScriptCommand(DockerComposerConfig $config, ScriptEvent $event, bool $interactive): array
- {
- $command = $this->composeBase($config);
- $command[] = $config->getMode();
-
- if ($config->getMode() === DockerComposerConfig::MODE_RUN) {
- $command[] = '--rm';
- }
-
- if (! $interactive) {
- $command[] = '-T';
- }
-
- if ($config->getWorkdir() !== null) {
- $command[] = '--workdir';
- $command[] = $config->getWorkdir();
- }
-
- $command[] = '--env';
- $command[] = 'DOCKER_COMPOSER_INSIDE=1';
- $command[] = $config->getService();
-
- return array_merge($command, $this->composerRunScriptCommand($event));
+ public function buildScriptCommand(
+ DockerComposeOptions $config,
+ ScriptEvent $event,
+ bool $interactive,
+ ?string $hostPathRoot = null,
+ ?string $containerPathRoot = null,
+ ): array {
+ return $this->buildProcessCommand(
+ $config,
+ $this->composerRunScriptCommand($event, $hostPathRoot, $containerPathRoot),
+ $interactive,
+ );
}
/**
* Builds the Docker Compose Composer command execution command.
*
- * @param DockerComposerConfig $config
- * The Docker Composer configuration that provides service options.
+ * @param DockerComposeOptions $config
+ * The Docker-Composer configuration that provides service options.
*
* @param string $commandName
* The Composer command name to replay inside Docker Compose.
@@ -113,15 +108,52 @@ public function buildScriptCommand(DockerComposerConfig $config, ScriptEvent $ev
* @param bool $interactive
* Whether the Docker command should keep TTY interaction enabled.
*
+ * @param string|null $hostPathRoot
+ * The host directory whose descendants can be translated.
+ *
+ * @param string|null $containerPathRoot
+ * The matching container directory, or `null` to leave paths unchanged.
+ *
* @return list
* Returns command arguments for `docker compose exec` or `run`.
*/
- public function buildComposerCommand(DockerComposerConfig $config, string $commandName, InputInterface $input, bool $interactive): array
+ public function buildComposerCommand(
+ DockerComposeOptions $config,
+ string $commandName,
+ InputInterface $input,
+ bool $interactive,
+ ?string $hostPathRoot = null,
+ ?string $containerPathRoot = null,
+ ): array {
+ $arguments = $this->getCommandArguments($input, $commandName);
+ if ($hostPathRoot !== null) {
+ $arguments = $this->translateProjectPaths($arguments, $hostPathRoot, $containerPathRoot);
+ }
+
+ return $this->buildProcessCommand($config, array_merge(['composer'], $arguments), $interactive);
+ }
+
+ /**
+ * Builds a Docker Compose process execution command.
+ *
+ * @param DockerComposeOptions $config
+ * The Docker Compose configuration that provides service options.
+ *
+ * @param list $processCommand
+ * The command arguments that should run inside the service.
+ *
+ * @param bool $interactive
+ * Whether the Docker command should keep TTY interaction enabled.
+ *
+ * @return list
+ * Returns command arguments for `docker compose exec` or `run`.
+ */
+ public function buildProcessCommand(DockerComposeOptions $config, array $processCommand, bool $interactive): array
{
$command = $this->composeBase($config);
$command[] = $config->getMode();
- if ($config->getMode() === DockerComposerConfig::MODE_RUN) {
+ if ($config->getMode() === DockerComposeOptions::MODE_RUN) {
$command[] = '--rm';
}
@@ -137,21 +169,150 @@ public function buildComposerCommand(DockerComposerConfig $config, string $comma
$command[] = '--env';
$command[] = 'DOCKER_COMPOSER_INSIDE=1';
$command[] = $config->getService();
- $command[] = 'composer';
- return array_merge($command, $this->getCommandArguments($input, $commandName));
+ return array_merge($command, $processCommand);
+ }
+
+ /**
+ * Builds the Docker Compose config inspection command.
+ *
+ * @param DockerComposeOptions $config
+ * The configuration that provides compose files and project directory.
+ *
+ * @return list
+ * Returns command arguments for `docker compose config --format json`.
+ */
+ public function buildConfigCommand(DockerComposeOptions $config): array
+ {
+ return array_merge($this->composeBase($config), [
+ 'config',
+ '--format',
+ 'json',
+ ]);
+ }
+
+ /**
+ * Builds a service default workdir probe for exec mode.
+ *
+ * @param DockerComposeOptions $config
+ * The Docker Compose configuration that identifies the service.
+ *
+ * @return list
+ * Returns command arguments for `docker compose exec -T pwd`.
+ */
+ public function buildExecWorkdirCommand(DockerComposeOptions $config): array
+ {
+ return array_merge($this->composeBase($config), [
+ 'exec',
+ '-T',
+ $config->getService(),
+ 'pwd',
+ ]);
+ }
+
+ /**
+ * Builds a service default workdir probe for run mode.
+ *
+ * @param DockerComposeOptions $config
+ * The Docker Compose configuration that identifies the service.
+ *
+ * @return list
+ * Returns command arguments for `docker compose run --rm -T pwd`.
+ */
+ public function buildRunWorkdirCommand(DockerComposeOptions $config): array
+ {
+ return array_merge($this->composeBase($config), [
+ 'run',
+ '--rm',
+ '-T',
+ $config->getService(),
+ 'pwd',
+ ]);
+ }
+
+ /**
+ * Builds a Docker image workdir inspection command.
+ *
+ * @param string $image
+ * The Docker image reference to inspect.
+ *
+ * @return list
+ * Returns command arguments for `docker image inspect`.
+ */
+ public function buildImageWorkdirCommand(string $image): array
+ {
+ return [
+ 'docker',
+ 'image',
+ 'inspect',
+ '--format',
+ '{{.Config.WorkingDir}}',
+ $image,
+ ];
+ }
+
+ /**
+ * Translates absolute host paths in command arguments.
+ *
+ * @param list $arguments
+ * The host command arguments.
+ *
+ * @param string $hostPathRoot
+ * The host directory whose descendants can be translated.
+ *
+ * @param string|null $containerPathRoot
+ * The matching container directory, or `null` to leave paths unchanged.
+ *
+ * @return list
+ * Returns arguments with host paths translated into container paths.
+ */
+ public function translateProjectPaths(array $arguments, string $hostPathRoot, ?string $containerPathRoot): array
+ {
+ if ($containerPathRoot === null) {
+ return $arguments;
+ }
+
+ $hostPathRoot = $this->normalizePath($hostPathRoot);
+ $containerPathRoot = rtrim($this->normalizePath($containerPathRoot), '/');
+ $translated = [];
+
+ foreach ($arguments as $argument) {
+ $prefix = '';
+ $path = $argument;
+ if (str_contains($argument, '=')) {
+ [$prefix, $path] = explode('=', $argument, 2);
+ $prefix .= '=';
+ }
+
+ $normalizedPath = $this->normalizePath($path);
+ if ($normalizedPath === $hostPathRoot) {
+ $translated[] = $prefix . $containerPathRoot;
+
+ continue;
+ }
+
+ if (str_starts_with($normalizedPath, $hostPathRoot . '/')) {
+ $translated[] = $prefix . $containerPathRoot . substr($normalizedPath, strlen($hostPathRoot));
+
+ continue;
+ }
+
+ $translated[] = $argument;
+ }
+
+ return $translated;
}
/**
* Builds the common Docker Compose command prefix.
*
- * @param DockerComposerConfig $config
+ * @param DockerComposeOptions $config
* The configuration that provides compose files and project directory.
*
* @return list
* Returns base arguments beginning with `docker compose`.
*/
- private function composeBase(DockerComposerConfig $config): array
+ private function composeBase(DockerComposeOptions $config): array
{
$command = ['docker', 'compose'];
@@ -174,11 +335,20 @@ private function composeBase(DockerComposerConfig $config): array
* @param ScriptEvent $event
* The script event whose name, flags, and arguments are replayed.
*
+ * @param string|null $hostPathRoot
+ * The host directory whose descendants can be translated.
+ *
+ * @param string|null $containerPathRoot
+ * The matching container directory, or `null` to leave paths unchanged.
+ *
* @return list
* Returns command arguments beginning with `composer run-script`.
*/
- private function composerRunScriptCommand(ScriptEvent $event): array
- {
+ private function composerRunScriptCommand(
+ ScriptEvent $event,
+ ?string $hostPathRoot = null,
+ ?string $containerPathRoot = null,
+ ): array {
$command = [
'composer',
'run-script',
@@ -198,11 +368,16 @@ private function composerRunScriptCommand(ScriptEvent $event): array
}
$command[] = '--';
+ $scriptArguments = [];
foreach ($arguments as $argument) {
- $command[] = $this->stringifyArgument($argument);
+ $scriptArguments[] = $this->stringifyArgument($argument);
}
- return $command;
+ if ($hostPathRoot !== null) {
+ $scriptArguments = $this->translateProjectPaths($scriptArguments, $hostPathRoot, $containerPathRoot);
+ }
+
+ return array_merge($command, $scriptArguments);
}
/**
@@ -396,4 +571,20 @@ private function stringifyArgument($argument): string
throw new InvalidArgumentException('Composer script arguments must be scalar values.');
}
+
+ /**
+ * Normalizes path separators and trailing slashes.
+ *
+ * @param string $path
+ * The path to normalize.
+ *
+ * @return string
+ * Returns a slash-separated path without trailing slash.
+ */
+ private function normalizePath(string $path): string
+ {
+ $path = str_replace('\\', '/', $path);
+
+ return rtrim($path, '/');
+ }
}
diff --git a/src/DockerComposeExecutionResult.php b/src/DockerComposeExecutionResult.php
new file mode 100644
index 0000000..1877086
--- /dev/null
+++ b/src/DockerComposeExecutionResult.php
@@ -0,0 +1,82 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer;
+
+/**
+ * Describes the completed Docker Compose phase and exit code.
+ */
+final class DockerComposeExecutionResult
+{
+ /**
+ * Creates a Docker Compose execution result.
+ *
+ * @param string $phase
+ * The Docker Compose phase that ran, such as `"up"` or `"exec"`.
+ *
+ * @param list $command
+ * The Docker Compose command arguments that were executed.
+ *
+ * @param int $exitCode
+ * The process exit code returned by Docker Compose.
+ */
+ public function __construct(
+ private readonly string $phase,
+ private readonly array $command,
+ private readonly int $exitCode,
+ ) {}
+
+ /**
+ * Checks whether Docker Compose completed successfully.
+ *
+ * @return bool
+ * Returns `true` when the exit code is zero.
+ */
+ public function isSuccessful(): bool
+ {
+ return $this->exitCode === 0;
+ }
+
+ /**
+ * Gets the Docker Compose phase that ran.
+ *
+ * @return string
+ * Returns the phase name.
+ */
+ public function getPhase(): string
+ {
+ return $this->phase;
+ }
+
+ /**
+ * Gets the executed Docker Compose command.
+ *
+ * @return list
+ * Returns command arguments.
+ */
+ public function getCommand(): array
+ {
+ return $this->command;
+ }
+
+ /**
+ * Gets the Docker Compose exit code.
+ *
+ * @return int
+ * Returns the process exit code.
+ */
+ public function getExitCode(): int
+ {
+ return $this->exitCode;
+ }
+}
diff --git a/src/DockerComposeOptions.php b/src/DockerComposeOptions.php
new file mode 100644
index 0000000..99115e6
--- /dev/null
+++ b/src/DockerComposeOptions.php
@@ -0,0 +1,76 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer;
+
+/**
+ * Exposes service-level options needed to run Docker Compose commands.
+ */
+interface DockerComposeOptions
+{
+ /**
+ * Selects Docker Compose exec mode.
+ *
+ * @var string
+ * Stores the mode that executes commands in an existing service.
+ */
+ public const MODE_EXEC = 'exec';
+
+ /**
+ * Selects Docker Compose run mode.
+ *
+ * @var string
+ * Stores the mode that creates a one-off service container.
+ */
+ public const MODE_RUN = 'run';
+
+ /**
+ * Gets the configured Docker Compose service name.
+ *
+ * @return string
+ * Returns the non-empty service name.
+ */
+ public function getService(): string;
+
+ /**
+ * Gets the configured Docker Compose mode.
+ *
+ * @return string
+ * Returns `"exec"` or `"run"`.
+ */
+ public function getMode(): string;
+
+ /**
+ * Gets configured Docker Compose file paths.
+ *
+ * @return list
+ * Returns paths passed to Docker Compose with `--file`.
+ */
+ public function getComposeFiles(): array;
+
+ /**
+ * Gets the configured Docker Compose project directory.
+ *
+ * @return string|null
+ * Returns the directory path, or `null` for Docker Compose defaults.
+ */
+ public function getProjectDirectory(): ?string;
+
+ /**
+ * Gets the configured service working directory.
+ *
+ * @return string|null
+ * Returns the service working directory, or `null` for service default.
+ */
+ public function getWorkdir(): ?string;
+}
diff --git a/src/DockerComposeResolvedOptions.php b/src/DockerComposeResolvedOptions.php
new file mode 100644
index 0000000..6937a98
--- /dev/null
+++ b/src/DockerComposeResolvedOptions.php
@@ -0,0 +1,89 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer;
+
+/**
+ * Wraps Docker Compose options with an inferred working directory.
+ */
+final class DockerComposeResolvedOptions implements DockerComposeOptions
+{
+ /**
+ * Creates resolved Docker Compose options.
+ *
+ * @param DockerComposeOptions $options
+ * The source options.
+ *
+ * @param string|null $workdir
+ * The resolved working directory, or `null`.
+ */
+ public function __construct(
+ private readonly DockerComposeOptions $options,
+ private readonly ?string $workdir,
+ ) {}
+
+ /**
+ * Gets the configured Docker Compose service name.
+ *
+ * @return string
+ * Returns the non-empty service name.
+ */
+ public function getService(): string
+ {
+ return $this->options->getService();
+ }
+
+ /**
+ * Gets the configured Docker Compose mode.
+ *
+ * @return string
+ * Returns `"exec"` or `"run"`.
+ */
+ public function getMode(): string
+ {
+ return $this->options->getMode();
+ }
+
+ /**
+ * Gets configured Docker Compose file paths.
+ *
+ * @return list
+ * Returns paths passed to Docker Compose with `--file`.
+ */
+ public function getComposeFiles(): array
+ {
+ return $this->options->getComposeFiles();
+ }
+
+ /**
+ * Gets the configured Docker Compose project directory.
+ *
+ * @return string|null
+ * Returns the directory path, or `null` for Docker Compose defaults.
+ */
+ public function getProjectDirectory(): ?string
+ {
+ return $this->options->getProjectDirectory();
+ }
+
+ /**
+ * Gets the resolved service working directory.
+ *
+ * @return string|null
+ * Returns the resolved service workdir, or `null`.
+ */
+ public function getWorkdir(): ?string
+ {
+ return $this->workdir;
+ }
+}
diff --git a/src/DockerComposeRunner.php b/src/DockerComposeRunner.php
new file mode 100644
index 0000000..d5002f3
--- /dev/null
+++ b/src/DockerComposeRunner.php
@@ -0,0 +1,150 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer;
+
+/**
+ * Runs Docker Compose commands and prepares exec-mode services.
+ */
+final class DockerComposeRunner
+{
+ /**
+ * Tracks services started for Docker Compose exec mode.
+ *
+ * @var array
+ */
+ private array $startedExecServices = [];
+
+ /**
+ * Creates a Docker Compose runner.
+ *
+ * @param ProcessRunner $processRunner
+ * The process runner used for Docker Compose commands.
+ *
+ * @param DockerComposeCommandBuilder $commandBuilder
+ * The command builder used for service startup and status checks.
+ */
+ public function __construct(
+ private readonly ProcessRunner $processRunner,
+ private readonly DockerComposeCommandBuilder $commandBuilder,
+ ) {}
+
+ /**
+ * Runs a prepared Docker Compose command.
+ *
+ * @param DockerComposeOptions $config
+ * The Docker Compose options for the target service.
+ *
+ * @param list $command
+ * The full Docker Compose command to execute.
+ *
+ * @param bool $interactive
+ * Whether TTY passthrough should be requested.
+ *
+ * @return DockerComposeExecutionResult
+ * Returns the completed execution result.
+ */
+ public function run(DockerComposeOptions $config, array $command, bool $interactive): DockerComposeExecutionResult
+ {
+ if ($config->getMode() === DockerComposeOptions::MODE_EXEC) {
+ $startup = $this->ensureExecServiceStarted($config);
+ if ($startup !== null && ! $startup->isSuccessful()) {
+ return $startup;
+ }
+ }
+
+ $exitCode = $this->processRunner->run($command, $interactive);
+
+ return new DockerComposeExecutionResult($config->getMode(), $command, $exitCode);
+ }
+
+ /**
+ * Ensures the configured service can receive `docker compose exec`.
+ *
+ * @param DockerComposeOptions $config
+ * The Docker Compose options that identify the service.
+ *
+ * @return DockerComposeExecutionResult|null
+ * Returns a failed startup result, or `null` when startup is complete.
+ */
+ public function ensureExecServiceStarted(DockerComposeOptions $config): ?DockerComposeExecutionResult
+ {
+ $startupKey = $this->getExecServiceStartupKey($config);
+ if (isset($this->startedExecServices[$startupKey])) {
+ return null;
+ }
+
+ if ($this->isExecServiceRunning($config)) {
+ $this->startedExecServices[$startupKey] = true;
+
+ return null;
+ }
+
+ $upCommand = $this->commandBuilder->buildUpCommand($config);
+ $exitCode = $this->processRunner->run($upCommand);
+ if ($exitCode === 0) {
+ $this->startedExecServices[$startupKey] = true;
+ }
+
+ return new DockerComposeExecutionResult('up', $upCommand, $exitCode);
+ }
+
+ /**
+ * Checks whether the configured exec-mode service is running.
+ *
+ * @param DockerComposeOptions $config
+ * The Docker Compose options that identify the service.
+ *
+ * @return bool
+ * Returns `true` when Docker Compose lists the service as running.
+ */
+ private function isExecServiceRunning(DockerComposeOptions $config): bool
+ {
+ if (! $this->processRunner instanceof OutputCapturingProcessRunner) {
+ return false;
+ }
+
+ $command = $this->commandBuilder->buildRunningServicesCommand($config);
+ $output = '';
+ if ($this->processRunner->runWithOutput($command, $output) !== 0) {
+ return false;
+ }
+
+ $services = preg_split('/\R/', trim($output)) ?: [];
+ foreach ($services as $service) {
+ if (trim($service) === $config->getService()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Builds a cache key for exec-mode service startup.
+ *
+ * @param DockerComposeOptions $config
+ * The Docker Compose options that identify the service.
+ *
+ * @return string
+ * Returns a stable serialized key for the service startup command.
+ */
+ private function getExecServiceStartupKey(DockerComposeOptions $config): string
+ {
+ return serialize([
+ $config->getService(),
+ $config->getComposeFiles(),
+ $config->getProjectDirectory(),
+ ]);
+ }
+}
diff --git a/src/DockerComposeWorkdirResolution.php b/src/DockerComposeWorkdirResolution.php
new file mode 100644
index 0000000..45fc5cc
--- /dev/null
+++ b/src/DockerComposeWorkdirResolution.php
@@ -0,0 +1,80 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer;
+
+/**
+ * Stores inferred container workdir and host directory mapping.
+ */
+final class DockerComposeWorkdirResolution
+{
+ /**
+ * Creates resolved workdir metadata.
+ *
+ * @param string|null $workdir
+ * The container working directory, or `null` when unavailable.
+ *
+ * @param string|null $containerWorkingDirectory
+ * The container path matching the host working directory, or `null`.
+ */
+ public function __construct(
+ private readonly ?string $workdir,
+ private readonly ?string $containerWorkingDirectory,
+ ) {}
+
+ /**
+ * Gets the resolved container workdir.
+ *
+ * @return string|null
+ * Returns the container working directory, or `null`.
+ */
+ public function getWorkdir(): ?string
+ {
+ return $this->workdir;
+ }
+
+ /**
+ * Gets the container working directory mapping.
+ *
+ * @return string|null
+ * Returns the container path matching the host working directory, or `null`.
+ */
+ public function getContainerWorkingDirectory(): ?string
+ {
+ return $this->containerWorkingDirectory;
+ }
+
+ /**
+ * Gets the legacy container project root mapping.
+ *
+ * @return string|null
+ * Returns the container path matching the host directory, or `null`.
+ *
+ * @deprecated Use {@see getContainerWorkingDirectory()} instead.
+ */
+ public function getContainerProjectRoot(): ?string
+ {
+ return $this->containerWorkingDirectory;
+ }
+
+ /**
+ * Checks whether host paths can be translated.
+ *
+ * @return bool
+ * Returns `true` when the host directory has a container path.
+ */
+ public function hasPathMapping(): bool
+ {
+ return $this->containerWorkingDirectory !== null;
+ }
+}
diff --git a/src/DockerComposeWorkdirResolver.php b/src/DockerComposeWorkdirResolver.php
new file mode 100644
index 0000000..c328724
--- /dev/null
+++ b/src/DockerComposeWorkdirResolver.php
@@ -0,0 +1,330 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer;
+
+/**
+ * Resolves container workdir and host directory mapping.
+ */
+final class DockerComposeWorkdirResolver
+{
+ /**
+ * Creates a Docker Compose workdir resolver.
+ *
+ * @param DockerComposeCommandBuilder $commandBuilder
+ * The builder used for discovery commands.
+ */
+ public function __construct(
+ private readonly DockerComposeCommandBuilder $commandBuilder,
+ ) {}
+
+ /**
+ * Resolves workdir and path mapping for a service.
+ *
+ * @param DockerComposeOptions $config
+ * The effective Docker Compose service options.
+ *
+ * @param string $hostWorkingDirectory
+ * The active working directory on the host.
+ *
+ * @param ProcessRunner|null $processRunner
+ * The runner used for discovery commands, or `null` to skip them.
+ *
+ * @param DockerComposeRunner|null $dockerRunner
+ * The Docker Compose runner used to prepare exec probes.
+ *
+ * @return DockerComposeWorkdirResolution
+ * Returns inferred workdir and host directory mapping.
+ */
+ public function resolve(
+ DockerComposeOptions $config,
+ string $hostWorkingDirectory,
+ ?ProcessRunner $processRunner = null,
+ ?DockerComposeRunner $dockerRunner = null,
+ ): DockerComposeWorkdirResolution {
+ $workdir = $config->getWorkdir();
+ $containerWorkingDirectory = null;
+ $service = $processRunner instanceof OutputCapturingProcessRunner
+ ? $this->readComposeService($config, $processRunner)
+ : null;
+
+ if ($service !== null) {
+ $containerWorkingDirectory = $this->inferContainerWorkingDirectory($service, $hostWorkingDirectory);
+ if ($containerWorkingDirectory !== null && $workdir === null) {
+ $workdir = $containerWorkingDirectory;
+ }
+
+ $workdir ??= $this->readServiceWorkingDir($service);
+ }
+
+ if ($containerWorkingDirectory === null && $config->getWorkdir() !== null) {
+ $containerWorkingDirectory = $config->getWorkdir();
+ }
+
+ if ($workdir === null && $processRunner instanceof OutputCapturingProcessRunner) {
+ $workdir = $this->probeContainerWorkdir($config, $processRunner, $dockerRunner);
+ }
+
+ if ($workdir === null && $processRunner instanceof OutputCapturingProcessRunner && $service !== null) {
+ $workdir = $this->inspectImageWorkdir($service, $processRunner);
+ }
+
+ return new DockerComposeWorkdirResolution($workdir, $containerWorkingDirectory);
+ }
+
+ /**
+ * Reads the target service object from Docker Compose config.
+ *
+ * @param DockerComposeOptions $config
+ * The service options.
+ *
+ * @param OutputCapturingProcessRunner $processRunner
+ * The runner used to read Docker Compose config.
+ *
+ * @return array|null
+ * Returns the service config object, or `null` when unavailable.
+ */
+ private function readComposeService(DockerComposeOptions $config, OutputCapturingProcessRunner $processRunner): ?array
+ {
+ $output = '';
+ if ($processRunner->runWithOutput($this->commandBuilder->buildConfigCommand($config), $output) !== 0) {
+ return null;
+ }
+
+ $decoded = json_decode($output, true);
+ if (! is_array($decoded)) {
+ return null;
+ }
+
+ $services = $decoded['services'] ?? null;
+ if (! is_array($services)) {
+ return null;
+ }
+
+ $service = $services[$config->getService()] ?? null;
+
+ return is_array($service) ? $service : null;
+ }
+
+ /**
+ * Infers the container working directory from service bind volumes.
+ *
+ * @param array $service
+ * The Docker Compose service config object.
+ *
+ * @param string $hostWorkingDirectory
+ * The active working directory on the host.
+ *
+ * @return string|null
+ * Returns the mapped container working directory, or `null`.
+ */
+ private function inferContainerWorkingDirectory(array $service, string $hostWorkingDirectory): ?string
+ {
+ $volumes = $service['volumes'] ?? null;
+ if (! is_array($volumes) || ! array_is_list($volumes)) {
+ return null;
+ }
+
+ $hostWorkingDirectory = $this->normalizePath($hostWorkingDirectory);
+ $bestSource = null;
+ $bestTarget = null;
+
+ foreach ($volumes as $volume) {
+ if (! is_array($volume) || ($volume['type'] ?? null) !== 'bind') {
+ continue;
+ }
+
+ $source = $volume['source'] ?? null;
+ $target = $volume['target'] ?? null;
+ if (! is_string($source) || $source === '' || ! is_string($target) || $target === '') {
+ continue;
+ }
+
+ $source = $this->normalizePath($source);
+ $target = $this->normalizePath($target);
+ if ($source === $hostWorkingDirectory) {
+ return $target;
+ }
+
+ if ($this->isPathAncestor($source, $hostWorkingDirectory) && ($bestSource === null || strlen($source) > strlen($bestSource))) {
+ $bestSource = $source;
+ $bestTarget = $target;
+ }
+ }
+
+ if ($bestSource === null || $bestTarget === null) {
+ return null;
+ }
+
+ return $this->appendPath($bestTarget, substr($hostWorkingDirectory, strlen($bestSource)));
+ }
+
+ /**
+ * Reads a service-level Docker Compose working directory.
+ *
+ * @param array $service
+ * The Docker Compose service config object.
+ *
+ * @return string|null
+ * Returns the configured `working_dir`, or `null`.
+ */
+ private function readServiceWorkingDir(array $service): ?string
+ {
+ $workingDir = $service['working_dir'] ?? null;
+
+ return is_string($workingDir) && $workingDir !== '' ? $workingDir : null;
+ }
+
+ /**
+ * Probes the service process for its default working directory.
+ *
+ * @param DockerComposeOptions $config
+ * The service options.
+ *
+ * @param OutputCapturingProcessRunner $processRunner
+ * The runner used for discovery commands.
+ *
+ * @param DockerComposeRunner|null $dockerRunner
+ * The Docker runner used to prepare exec services.
+ *
+ * @return string|null
+ * Returns the probed working directory, or `null`.
+ */
+ private function probeContainerWorkdir(
+ DockerComposeOptions $config,
+ OutputCapturingProcessRunner $processRunner,
+ ?DockerComposeRunner $dockerRunner,
+ ): ?string {
+ if ($config->getMode() === DockerComposeOptions::MODE_EXEC) {
+ if ($dockerRunner === null) {
+ return null;
+ }
+
+ $startup = $dockerRunner->ensureExecServiceStarted($config);
+ if ($startup !== null && ! $startup->isSuccessful()) {
+ return null;
+ }
+
+ return $this->runWorkdirProbe($processRunner, $this->commandBuilder->buildExecWorkdirCommand($config));
+ }
+
+ return $this->runWorkdirProbe($processRunner, $this->commandBuilder->buildRunWorkdirCommand($config));
+ }
+
+ /**
+ * Reads image default workdir from Docker image metadata.
+ *
+ * @param array $service
+ * The Docker Compose service config object.
+ *
+ * @param OutputCapturingProcessRunner $processRunner
+ * The runner used for Docker image inspection.
+ *
+ * @return string|null
+ * Returns image `Config.WorkingDir`, or `null`.
+ */
+ private function inspectImageWorkdir(array $service, OutputCapturingProcessRunner $processRunner): ?string
+ {
+ $image = $service['image'] ?? null;
+ if (! is_string($image) || $image === '') {
+ return null;
+ }
+
+ return $this->runWorkdirProbe($processRunner, $this->commandBuilder->buildImageWorkdirCommand($image));
+ }
+
+ /**
+ * Runs a command that prints one workdir path.
+ *
+ * @param OutputCapturingProcessRunner $processRunner
+ * The runner used for discovery commands.
+ *
+ * @param list $command
+ * The command to execute.
+ *
+ * @return string|null
+ * Returns trimmed command output, or `null`.
+ */
+ private function runWorkdirProbe(OutputCapturingProcessRunner $processRunner, array $command): ?string
+ {
+ $output = '';
+ if ($processRunner->runWithOutput($command, $output) !== 0) {
+ return null;
+ }
+
+ $output = trim($output);
+
+ return $output !== '' ? $output : null;
+ }
+
+ /**
+ * Checks whether one path is an ancestor of another.
+ *
+ * @param string $ancestor
+ * The possible ancestor path.
+ *
+ * @param string $path
+ * The possible descendant path.
+ *
+ * @return bool
+ * Returns `true` when __path__ is below __ancestor__.
+ */
+ private function isPathAncestor(string $ancestor, string $path): bool
+ {
+ $prefix = $ancestor === '/' ? '/' : $ancestor . '/';
+
+ return str_starts_with($path, $prefix);
+ }
+
+ /**
+ * Appends a normalized suffix to a container path.
+ *
+ * @param string $base
+ * The base container path.
+ *
+ * @param string $suffix
+ * The host suffix being mapped into the container.
+ *
+ * @return string
+ * Returns a slash-separated container path.
+ */
+ private function appendPath(string $base, string $suffix): string
+ {
+ $suffix = ltrim(str_replace('\\', '/', $suffix), '/');
+ if ($suffix === '') {
+ return $base;
+ }
+
+ if ($base === '/') {
+ return '/' . $suffix;
+ }
+
+ return $base . '/' . $suffix;
+ }
+
+ /**
+ * Normalizes path separators and trailing slashes.
+ *
+ * @param string $path
+ * The path to normalize.
+ *
+ * @return string
+ * Returns a slash-separated path.
+ */
+ private function normalizePath(string $path): string
+ {
+ $path = rtrim(str_replace('\\', '/', $path), '/');
+
+ return $path === '' ? '/' : $path;
+ }
+}
diff --git a/src/DockerComposerConfig.php b/src/DockerComposerConfig.php
index 9b42db3..91d2102 100644
--- a/src/DockerComposerConfig.php
+++ b/src/DockerComposerConfig.php
@@ -1,7 +1,7 @@
@@ -18,15 +18,15 @@
use LogicException;
/**
- * Parses and exposes Docker Composer configuration from Composer metadata.
+ * Parses and exposes Docker-Composer configuration from Composer metadata.
*/
-final class DockerComposerConfig
+final class DockerComposerConfig implements DockerComposeOptions
{
/**
* Names the Composer extra key used by this plugin.
*
* @var string
- * Stores the `extra` object key containing Docker Composer settings.
+ * Stores the `extra` object key containing Docker-Composer settings.
*/
public const EXTRA_KEY = 'docker-composer';
@@ -36,7 +36,7 @@ final class DockerComposerConfig
* @var string
* Stores the mode that executes scripts in an existing service container.
*/
- public const MODE_EXEC = 'exec';
+ public const MODE_EXEC = DockerComposeOptions::MODE_EXEC;
/**
* Selects Docker Compose run mode.
@@ -44,7 +44,7 @@ final class DockerComposerConfig
* @var string
* Stores the mode that creates a one-off service container for scripts.
*/
- public const MODE_RUN = 'run';
+ public const MODE_RUN = DockerComposeOptions::MODE_RUN;
/**
* Lists supported configuration keys.
@@ -178,7 +178,7 @@ private function __construct(
* The Composer instance that owns the package metadata.
*
* @return self
- * Returns parsed Docker Composer configuration.
+ * Returns parsed Docker-Composer configuration.
*
* @throws InvalidArgumentException
* Thrown when `extra.docker-composer` has an invalid shape or value.
diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php
index f85cb93..5755dbe 100644
--- a/src/DockerComposerPlugin.php
+++ b/src/DockerComposerPlugin.php
@@ -51,7 +51,7 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface
private ?IOInterface $io = null;
/**
- * Stores parsed Docker Composer configuration.
+ * Stores parsed Docker-Composer configuration.
*/
private ?DockerComposerConfig $config = null;
@@ -70,6 +70,16 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface
*/
private DockerComposeCommandBuilder $commandBuilder;
+ /**
+ * Runs Docker Compose commands for the active process runner.
+ */
+ private ?DockerComposeRunner $dockerRunner = null;
+
+ /**
+ * Resolves container workdir and host directory mapping.
+ */
+ private DockerComposeWorkdirResolver $workdirResolver;
+
/**
* Tracks whether the missing configuration warning was written.
*/
@@ -85,15 +95,6 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface
*/
private bool $duplicateServiceMappingWarningsWritten = false;
- /**
- * Tracks services started for Docker Compose exec mode.
- *
- * Stores startup keys for services already started during this process.
- *
- * @var array
- */
- private array $startedExecServices = [];
-
/**
* Creates a Composer plugin with optional collaborators.
*
@@ -105,15 +106,20 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface
*
* @param DockerComposeCommandBuilder|null $commandBuilder
* The command builder, or `null` for the default builder.
+ *
+ * @param DockerComposeWorkdirResolver|null $workdirResolver
+ * The workdir resolver, or `null` for the default resolver.
*/
public function __construct(
?ProcessRunner $processRunner = null,
?ContainerDetector $containerDetector = null,
?DockerComposeCommandBuilder $commandBuilder = null,
+ ?DockerComposeWorkdirResolver $workdirResolver = null,
) {
$this->processRunner = $processRunner;
$this->containerDetector = $containerDetector ?? new EnvironmentContainerDetector();
$this->commandBuilder = $commandBuilder ?? new DockerComposeCommandBuilder();
+ $this->workdirResolver = $workdirResolver ?? new DockerComposeWorkdirResolver($this->commandBuilder);
}
/**
@@ -351,7 +357,7 @@ private function registerScriptListeners(Composer $composer): void
* The script event used to lazily access Composer.
*
* @return DockerComposerConfig
- * Returns parsed Docker Composer configuration.
+ * Returns parsed Docker-Composer configuration.
*/
private function getConfig(ScriptEvent $event): DockerComposerConfig
{
@@ -488,7 +494,7 @@ private function writeCommandRedirectNotice(IOInterface $io, string $commandName
* The Composer script event being executed.
*
* @param DockerComposerConfig $config
- * The Docker Composer configuration used to build commands.
+ * The Docker-Composer configuration used to build commands.
*
* @return void
* Returns nothing.
@@ -499,25 +505,18 @@ private function writeCommandRedirectNotice(IOInterface $io, string $commandName
private function runInDocker(ScriptEvent $event, DockerComposerConfig $config): void
{
$runner = $this->getProcessRunner($event);
-
- if ($config->getMode() === DockerComposerConfig::MODE_EXEC) {
- $startupKey = $this->getExecServiceStartupKey($config);
- if (! isset($this->startedExecServices[$startupKey])) {
- $this->ensureExecServiceStarted($runner, $config);
- $this->startedExecServices[$startupKey] = true;
- }
- }
-
+ $hostWorkingDirectory = $this->getHostWorkingDirectory();
+ $resolution = $this->resolveDockerWorkdir($config, $hostWorkingDirectory, $runner);
+ $config = new DockerComposeResolvedOptions($config, $resolution->getWorkdir());
$isInteractive = $event->getIO()->isInteractive() && $runner->supportsTty();
$scriptCommand = $this->commandBuilder->buildScriptCommand(
$config,
$event,
$isInteractive,
+ $hostWorkingDirectory,
+ $resolution->getContainerWorkingDirectory(),
);
- $exitCode = $runner->run($scriptCommand, $isInteractive);
- if ($exitCode !== 0) {
- $this->throwScriptExecutionException($runner, $exitCode, $config->getMode(), $scriptCommand);
- }
+ $this->runDockerCommand($runner, $config, $scriptCommand, $isInteractive);
}
/**
@@ -527,7 +526,7 @@ private function runInDocker(ScriptEvent $event, DockerComposerConfig $config):
* The Composer command event being executed.
*
* @param DockerComposerConfig $config
- * The Docker Composer configuration used to build commands.
+ * The Docker-Composer configuration used to build commands.
*
* @return void
* Returns nothing.
@@ -538,89 +537,19 @@ private function runInDocker(ScriptEvent $event, DockerComposerConfig $config):
private function runComposerCommandInDocker(PreCommandRunEvent $event, DockerComposerConfig $config): void
{
$runner = $this->getProcessRunnerForCommand();
-
- if ($config->getMode() === DockerComposerConfig::MODE_EXEC) {
- $startupKey = $this->getExecServiceStartupKey($config);
- if (! isset($this->startedExecServices[$startupKey])) {
- $this->ensureExecServiceStarted($runner, $config);
- $this->startedExecServices[$startupKey] = true;
- }
- }
-
+ $hostWorkingDirectory = $this->getHostWorkingDirectory();
+ $resolution = $this->resolveDockerWorkdir($config, $hostWorkingDirectory, $runner);
+ $config = new DockerComposeResolvedOptions($config, $resolution->getWorkdir());
$isInteractive = $event->getInput()->isInteractive() && $runner->supportsTty();
$command = $this->commandBuilder->buildComposerCommand(
$config,
$event->getCommand(),
$event->getInput(),
$isInteractive,
+ $hostWorkingDirectory,
+ $resolution->getContainerWorkingDirectory(),
);
- $exitCode = $runner->run($command, $isInteractive);
- if ($exitCode !== 0) {
- $this->throwScriptExecutionException($runner, $exitCode, $config->getMode(), $command);
- }
- }
-
- /**
- * Ensures the configured service can receive `docker compose exec`.
- *
- * @param ProcessRunner $runner
- * The runner used for Docker Compose commands.
- *
- * @param DockerComposerConfig $config
- * The Docker Composer configuration that identifies the service.
- *
- * @return void
- * Returns nothing.
- *
- * @throws ScriptExecutionException
- * Thrown when Docker Compose startup fails.
- */
- private function ensureExecServiceStarted(ProcessRunner $runner, DockerComposerConfig $config): void
- {
- if ($this->isExecServiceRunning($runner, $config)) {
- return;
- }
-
- $upCommand = $this->commandBuilder->buildUpCommand($config);
- $exitCode = $runner->run($upCommand);
- if ($exitCode !== 0) {
- $this->throwScriptExecutionException($runner, $exitCode, 'up', $upCommand);
- }
- }
-
- /**
- * Checks whether the configured exec-mode service is running.
- *
- * @param ProcessRunner $runner
- * The runner used for Docker Compose commands.
- *
- * @param DockerComposerConfig $config
- * The Docker Composer configuration that identifies the service.
- *
- * @return bool
- * Returns `true` when Docker Compose lists the service as running.
- */
- private function isExecServiceRunning(ProcessRunner $runner, DockerComposerConfig $config): bool
- {
- if (! $runner instanceof OutputCapturingProcessRunner) {
- return false;
- }
-
- $command = $this->commandBuilder->buildRunningServicesCommand($config);
- $output = '';
- if ($runner->runWithOutput($command, $output) !== 0) {
- return false;
- }
-
- $services = preg_split('/\R/', trim($output)) ?: [];
-
- foreach ($services as $service) {
- if (trim($service) === $config->getService()) {
- return true;
- }
- }
-
- return false;
+ $this->runDockerCommand($runner, $config, $command, $isInteractive);
}
/**
@@ -651,7 +580,7 @@ private function getProcessRunnerForCommand(): ProcessRunner
{
if ($this->processRunner === null) {
if ($this->io === null) {
- throw new ScriptExecutionException('Docker Composer plugin was not activated.', 1);
+ throw new ScriptExecutionException('Docker-Composer plugin was not activated.', 1);
}
$this->processRunner = new ComposerProcessRunner($this->io);
@@ -661,21 +590,36 @@ private function getProcessRunnerForCommand(): ProcessRunner
}
/**
- * Builds a cache key for exec-mode service startup.
+ * Resolves Docker Compose workdir metadata for execution.
*
* @param DockerComposerConfig $config
- * The Docker Composer configuration that identifies the service.
+ * The parsed Docker-Composer configuration.
+ *
+ * @param string $hostWorkingDirectory
+ * The active host working directory.
+ *
+ * @param ProcessRunner $runner
+ * The runner used for Docker commands.
+ *
+ * @return DockerComposeWorkdirResolution
+ * Returns inferred workdir and host directory mapping.
+ */
+ private function resolveDockerWorkdir(DockerComposerConfig $config, string $hostWorkingDirectory, ProcessRunner $runner): DockerComposeWorkdirResolution
+ {
+ return $this->workdirResolver->resolve($config, $hostWorkingDirectory, $runner, $this->getDockerRunner($runner));
+ }
+
+ /**
+ * Gets the active host working directory.
*
* @return string
- * Returns a stable serialized key for the service startup command.
+ * Returns the process CWD, falling back to `"."`.
*/
- private function getExecServiceStartupKey(DockerComposerConfig $config): string
+ private function getHostWorkingDirectory(): string
{
- return serialize([
- $config->getService(),
- $config->getComposeFiles(),
- $config->getProjectDirectory(),
- ]);
+ $cwd = getcwd();
+
+ return $cwd !== false ? $cwd : '.';
}
/**
@@ -716,6 +660,53 @@ private function throwScriptExecutionException(ProcessRunner $runner, int $exitC
throw new ScriptExecutionException($message, $exitCode);
}
+ /**
+ * Runs a Docker Compose command and throws when it fails.
+ *
+ * @param ProcessRunner $runner
+ * The runner used for Docker Compose commands.
+ *
+ * @param DockerComposeOptions $config
+ * The Docker Compose options that identify the target service.
+ *
+ * @param list $command
+ * The full Docker Compose command to execute.
+ *
+ * @param bool $interactive
+ * Whether TTY passthrough should be requested.
+ *
+ * @return void
+ * Returns nothing.
+ *
+ * @throws ScriptExecutionException
+ * Thrown when Docker Compose startup or execution fails.
+ */
+ private function runDockerCommand(ProcessRunner $runner, DockerComposeOptions $config, array $command, bool $interactive): void
+ {
+ $result = $this->getDockerRunner($runner)->run($config, $command, $interactive);
+ if (! $result->isSuccessful()) {
+ $this->throwScriptExecutionException($runner, $result->getExitCode(), $result->getPhase(), $result->getCommand());
+ }
+ }
+
+ /**
+ * Gets the Docker Compose runner for the active process runner.
+ *
+ * @param ProcessRunner $runner
+ * The process runner used for Docker Compose commands.
+ *
+ * @return DockerComposeRunner
+ * Returns the shared Docker Compose runner.
+ */
+ private function getDockerRunner(ProcessRunner $runner): DockerComposeRunner
+ {
+ if ($this->dockerRunner === null) {
+ $this->dockerRunner = new DockerComposeRunner($runner, $this->commandBuilder);
+ }
+
+ return $this->dockerRunner;
+ }
+
/**
* Formats command arguments for shell output.
*
diff --git a/src/Laravel/Config.php b/src/Laravel/Config.php
new file mode 100644
index 0000000..678d3f1
--- /dev/null
+++ b/src/Laravel/Config.php
@@ -0,0 +1,563 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer\Laravel;
+
+use empaphy\docker_composer\DockerComposeOptions;
+use InvalidArgumentException;
+use LogicException;
+
+/**
+ * Parses and exposes Laravel console Docker redirection configuration.
+ */
+final class Config implements DockerComposeOptions
+{
+ /**
+ * Lists supported configuration keys.
+ *
+ * @var list
+ */
+ private const KNOWN_KEYS = [
+ 'enabled',
+ 'service',
+ 'mode',
+ 'compose_files',
+ 'project_directory',
+ 'workdir',
+ 'exclude',
+ 'service_mapping',
+ ];
+
+ /**
+ * Creates immutable Laravel Docker configuration.
+ *
+ * @param bool $enabled
+ * Whether Laravel console redirection is enabled.
+ *
+ * @param string|null $service
+ * The default Docker Compose service, or `null` when missing.
+ *
+ * @param array $servicesByEntry
+ * Docker Compose services keyed by Laravel entry identifier.
+ *
+ * @param string $mode
+ * The Docker Compose mode, either `"exec"` or `"run"`.
+ *
+ * @param list $composeFiles
+ * The Docker Compose files passed with `--file`.
+ *
+ * @param string|null $projectDirectory
+ * The Docker Compose project directory, or `null` for default.
+ *
+ * @param string|null $workdir
+ * The service working directory, or `null` for service default.
+ *
+ * @param list $exclude
+ * The Laravel entries that should run on the host.
+ */
+ private function __construct(
+ private readonly bool $enabled,
+ private readonly ?string $service,
+ private readonly array $servicesByEntry,
+ private readonly string $mode,
+ private readonly array $composeFiles,
+ private readonly ?string $projectDirectory,
+ private readonly ?string $workdir,
+ private readonly array $exclude,
+ ) {}
+
+ /**
+ * Creates configuration from a Laravel config array.
+ *
+ * @param array $raw
+ * The raw `docker_composer` config array.
+ *
+ * @return self
+ * Returns parsed Laravel Docker configuration.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when the config array has an invalid shape or value.
+ */
+ public static function fromArray(array $raw): self
+ {
+ $raw = self::object($raw);
+ $unknownKeys = array_values(array_diff(array_keys($raw), self::KNOWN_KEYS));
+ if ($unknownKeys !== []) {
+ throw new InvalidArgumentException(sprintf('docker_composer contains unknown key "%s".', $unknownKeys[0]));
+ }
+
+ return new self(
+ self::enabled($raw),
+ self::optionalString($raw, 'service'),
+ self::serviceMapping($raw),
+ self::mode($raw),
+ self::composeFiles($raw),
+ self::optionalString($raw, 'project_directory'),
+ self::optionalString($raw, 'workdir'),
+ self::expandClassEntries(self::stringList($raw, 'exclude')),
+ );
+ }
+
+ /**
+ * Checks whether Laravel console redirection is enabled.
+ *
+ * @return bool
+ * Returns `true` when redirection should be considered.
+ */
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ /**
+ * Gets the configured Docker Compose service name.
+ *
+ * @return string
+ * Returns the non-empty service name.
+ *
+ * @throws LogicException
+ * Thrown when the service is requested before configuration exists.
+ */
+ public function getService(): string
+ {
+ if ($this->service === null) {
+ throw new LogicException('Docker Compose service is not configured.');
+ }
+
+ return $this->service;
+ }
+
+ /**
+ * Gets the configured Docker Compose mode.
+ *
+ * @return string
+ * Returns `"exec"` or `"run"`.
+ */
+ public function getMode(): string
+ {
+ return $this->mode;
+ }
+
+ /**
+ * Gets configured Docker Compose file paths.
+ *
+ * @return list
+ * Returns paths passed to Docker Compose with `--file`.
+ */
+ public function getComposeFiles(): array
+ {
+ return $this->composeFiles;
+ }
+
+ /**
+ * Gets the configured Docker Compose project directory.
+ *
+ * @return string|null
+ * Returns the directory path, or `null` for Docker Compose defaults.
+ */
+ public function getProjectDirectory(): ?string
+ {
+ return $this->projectDirectory;
+ }
+
+ /**
+ * Gets the configured service working directory.
+ *
+ * @return string|null
+ * Returns the service working directory, or `null` for service default.
+ */
+ public function getWorkdir(): ?string
+ {
+ return $this->workdir;
+ }
+
+ /**
+ * Checks whether any console entry name is excluded.
+ *
+ * @param ConsoleEntry $entry
+ * The Laravel console entry to inspect.
+ *
+ * @return bool
+ * Returns `true` when one entry name is excluded.
+ */
+ public function excludes(ConsoleEntry $entry): bool
+ {
+ foreach ($entry->getNames() as $name) {
+ if (in_array($name, $this->exclude, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Creates a copy configured for the matched console entry service.
+ *
+ * @param ConsoleEntry $entry
+ * The Laravel console entry to match.
+ *
+ * @return self|null
+ * Returns service-specific config, or `null` when no service applies.
+ */
+ public function forEntry(ConsoleEntry $entry): ?self
+ {
+ foreach ($entry->getNames() as $name) {
+ if (array_key_exists($name, $this->servicesByEntry)) {
+ return $this->withService($this->servicesByEntry[$name]);
+ }
+ }
+
+ if ($this->service === null) {
+ return null;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Creates a copy using a specific Docker Compose service.
+ *
+ * @param string $service
+ * The Docker Compose service to use.
+ *
+ * @return self
+ * Returns a copy with __service__ set as effective service.
+ */
+ private function withService(string $service): self
+ {
+ return new self($this->enabled, $service, $this->servicesByEntry, $this->mode, $this->composeFiles, $this->projectDirectory, $this->workdir, $this->exclude);
+ }
+
+ /**
+ * Normalizes a decoded Laravel config object.
+ *
+ * @param array $raw
+ * The raw config value.
+ *
+ * @return array
+ * Returns __raw__ with verified `string` keys.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when __raw__ contains non-`string` keys.
+ */
+ private static function object(array $raw): array
+ {
+ $normalized = [];
+ foreach ($raw as $key => $value) {
+ if (! is_string($key)) {
+ throw new InvalidArgumentException('docker_composer must be an array with string keys.');
+ }
+
+ $normalized[$key] = $value;
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * Reads the enabled flag.
+ *
+ * @param array $raw
+ * The normalized configuration array.
+ *
+ * @return bool
+ * Returns `false` when omitted, otherwise the configured boolean.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when `enabled` is not boolean-like.
+ */
+ private static function enabled(array $raw): bool
+ {
+ if (! array_key_exists('enabled', $raw)) {
+ return false;
+ }
+
+ if (is_bool($raw['enabled'])) {
+ return $raw['enabled'];
+ }
+
+ if (is_int($raw['enabled'])) {
+ if (! in_array($raw['enabled'], [0, 1], true)) {
+ throw new InvalidArgumentException('docker_composer.enabled must be a boolean.');
+ }
+
+ return $raw['enabled'] === 1;
+ }
+
+ if (is_string($raw['enabled'])) {
+ $value = filter_var($raw['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+ if ($value !== null) {
+ return $value;
+ }
+ }
+
+ throw new InvalidArgumentException('docker_composer.enabled must be a boolean.');
+ }
+
+ /**
+ * Reads an optional non-empty `string` value.
+ *
+ * @param array $raw
+ * The normalized configuration array.
+ *
+ * @param string $key
+ * The configuration key to read.
+ *
+ * @return string|null
+ * Returns the configured `string`, or `null` when omitted.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when __key__ exists but is not a non-empty `string`.
+ */
+ private static function optionalString(array $raw, string $key): ?string
+ {
+ if (! array_key_exists($key, $raw) || $raw[$key] === null) {
+ return null;
+ }
+
+ if (! is_string($raw[$key]) || $raw[$key] === '') {
+ throw new InvalidArgumentException(sprintf('docker_composer.%s must be a non-empty string.', $key));
+ }
+
+ return $raw[$key];
+ }
+
+ /**
+ * Reads the configured Docker Compose mode.
+ *
+ * @param array $raw
+ * The normalized configuration array.
+ *
+ * @return string
+ * Returns `"exec"` when omitted, otherwise `"exec"` or `"run"`.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when `mode` is not an accepted `string` value.
+ */
+ private static function mode(array $raw): string
+ {
+ if (! array_key_exists('mode', $raw)) {
+ return DockerComposeOptions::MODE_EXEC;
+ }
+
+ if (! is_string($raw['mode']) || ! in_array($raw['mode'], [DockerComposeOptions::MODE_EXEC, DockerComposeOptions::MODE_RUN], true)) {
+ throw new InvalidArgumentException('docker_composer.mode must be "exec" or "run".');
+ }
+
+ return $raw['mode'];
+ }
+
+ /**
+ * Reads Docker Compose file settings.
+ *
+ * @param array $raw
+ * The normalized configuration array.
+ *
+ * @return list
+ * Returns file paths configured by `compose_files`.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when `compose_files` is not a non-empty `string` or list.
+ */
+ private static function composeFiles(array $raw): array
+ {
+ if (! array_key_exists('compose_files', $raw) || $raw['compose_files'] === null) {
+ return [];
+ }
+
+ if (is_string($raw['compose_files'])) {
+ if ($raw['compose_files'] === '') {
+ throw new InvalidArgumentException('docker_composer.compose_files must contain non-empty strings.');
+ }
+
+ return [$raw['compose_files']];
+ }
+
+ return self::stringList($raw, 'compose_files');
+ }
+
+ /**
+ * Reads a list of non-empty `string` values.
+ *
+ * @param array $raw
+ * The normalized configuration array.
+ *
+ * @param string $key
+ * The configuration key to read.
+ *
+ * @return list
+ * Returns the configured list, or an empty list when omitted.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when __key__ is not a list of non-empty `string` values.
+ */
+ private static function stringList(array $raw, string $key): array
+ {
+ if (! array_key_exists($key, $raw) || $raw[$key] === null) {
+ return [];
+ }
+
+ if (! is_array($raw[$key]) || ! array_is_list($raw[$key])) {
+ throw new InvalidArgumentException(sprintf('docker_composer.%s must be a list of strings.', $key));
+ }
+
+ $values = [];
+ foreach ($raw[$key] as $value) {
+ if (! is_string($value) || $value === '') {
+ throw new InvalidArgumentException(sprintf('docker_composer.%s must contain only non-empty strings.', $key));
+ }
+
+ $values[] = $value;
+ }
+
+ return $values;
+ }
+
+ /**
+ * Reads service mapping settings as Laravel entry service overrides.
+ *
+ * @param array $raw
+ * The normalized configuration array.
+ *
+ * @return array
+ * Returns Docker Compose services keyed by Laravel entry identifier.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when `service_mapping` has an invalid shape.
+ */
+ private static function serviceMapping(array $raw): array
+ {
+ $key = 'service_mapping';
+ if (! array_key_exists($key, $raw) || $raw[$key] === null) {
+ return [];
+ }
+
+ if ($raw[$key] === []) {
+ return [];
+ }
+
+ if (! is_array($raw[$key]) || array_is_list($raw[$key])) {
+ throw new InvalidArgumentException(sprintf('docker_composer.%s must be an object of strings or lists of strings.', $key));
+ }
+
+ $values = [];
+ foreach ($raw[$key] as $service => $entries) {
+ if (! is_string($service) || $service === '') {
+ throw new InvalidArgumentException(sprintf('docker_composer.%s must use non-empty string keys.', $key));
+ }
+
+ if (is_string($entries)) {
+ $entries = [$entries];
+ }
+
+ if (! is_array($entries) || ! array_is_list($entries) || $entries === []) {
+ throw new InvalidArgumentException(sprintf('docker_composer.%s must contain only non-empty strings or lists of non-empty strings.', $key));
+ }
+
+ foreach ($entries as $entry) {
+ if (! is_string($entry) || $entry === '') {
+ throw new InvalidArgumentException(sprintf('docker_composer.%s must contain only non-empty strings or lists of non-empty strings.', $key));
+ }
+
+ self::addServiceMapping($values, $entry, $service, $key);
+ foreach (self::commandNamesForClass($entry) as $commandName) {
+ self::addServiceMapping($values, $commandName, $service, $key);
+ }
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Adds a service mapping entry.
+ *
+ * @param array $values
+ * The service mappings accumulated so far.
+ *
+ * @param string $entry
+ * The entry identifier to map.
+ *
+ * @param string $service
+ * The Docker Compose service to use.
+ *
+ * @param string $key
+ * The source config key used in validation messages.
+ *
+ * @return void
+ * Returns nothing.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when __entry__ already maps to a different service.
+ */
+ private static function addServiceMapping(array &$values, string $entry, string $service, string $key): void
+ {
+ if (array_key_exists($entry, $values) && $values[$entry] !== $service) {
+ throw new InvalidArgumentException(sprintf('docker_composer.%s must not assign an entry to multiple services.', $key));
+ }
+
+ $values[$entry] = $service;
+ }
+
+ /**
+ * Reads command names declared by a Laravel command class.
+ *
+ * @param string $class
+ * The possible command class name.
+ *
+ * @return list
+ * Returns command names declared through `$signature` or `$name`.
+ */
+ private static function commandNamesForClass(string $class): array
+ {
+ if (! class_exists($class)) {
+ return [];
+ }
+
+ $defaults = (new \ReflectionClass($class))->getDefaultProperties();
+ $names = [];
+ $signature = $defaults['signature'] ?? null;
+ if (is_string($signature) && trim($signature) !== '') {
+ $names[] = strtok(trim($signature), " \t\r\n") ?: '';
+ }
+
+ $name = $defaults['name'] ?? null;
+ if (is_string($name) && $name !== '') {
+ $names[] = $name;
+ }
+
+ return array_values(array_filter(array_unique($names), static fn(string $name): bool => $name !== ''));
+ }
+
+ /**
+ * Adds command names declared by class-string entries.
+ *
+ * @param list $entries
+ * The configured entry identifiers.
+ *
+ * @return list
+ * Returns entries plus command names derived from command classes.
+ */
+ private static function expandClassEntries(array $entries): array
+ {
+ $expanded = $entries;
+ foreach ($entries as $entry) {
+ foreach (self::commandNamesForClass($entry) as $commandName) {
+ $expanded[] = $commandName;
+ }
+ }
+
+ return array_values(array_unique($expanded));
+ }
+}
diff --git a/src/Laravel/ConsoleEntry.php b/src/Laravel/ConsoleEntry.php
new file mode 100644
index 0000000..48a94d6
--- /dev/null
+++ b/src/Laravel/ConsoleEntry.php
@@ -0,0 +1,167 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer\Laravel;
+
+/**
+ * Carries Laravel console entry identifiers and replay arguments.
+ */
+final class ConsoleEntry
+{
+ /**
+ * Stores entry identifiers used for service mapping and exclusion.
+ *
+ * @var list
+ */
+ private array $names;
+
+ /**
+ * Stores raw CLI arguments to replay in Docker.
+ *
+ * @var list
+ */
+ private array $arguments;
+
+ /**
+ * Stores the entry name shown in redirect notices.
+ */
+ private string $displayName;
+
+ /**
+ * Creates a Laravel console entry.
+ *
+ * @param list $names
+ * The entry identifiers that can match configuration entries.
+ *
+ * @param list $arguments
+ * The raw CLI arguments to replay inside Docker.
+ *
+ * @param string $displayName
+ * The entry name shown in redirect notices.
+ */
+ private function __construct(array $names, array $arguments, string $displayName)
+ {
+ $this->names = array_values(array_unique($names));
+ $this->arguments = $arguments;
+ $this->displayName = $displayName;
+ }
+
+ /**
+ * Creates context for an Artisan command.
+ *
+ * @param string|null $commandName
+ * The Artisan command name, or `null` when unavailable.
+ *
+ * @param class-string|null $commandClass
+ * The Artisan command class, or `null` when unavailable.
+ *
+ * @param list $arguments
+ * The raw CLI arguments to replay inside Docker.
+ *
+ * @return self
+ * Returns context for Artisan command matching.
+ */
+ public static function artisan(?string $commandName, ?string $commandClass, array $arguments): self
+ {
+ $names = [];
+ $displayName = $arguments[0] ?? 'artisan';
+ if ($commandName !== null && $commandName !== '') {
+ $names[] = $commandName;
+ $displayName = 'artisan ' . $commandName;
+ }
+
+ if ($commandClass !== null && $commandClass !== '') {
+ $names[] = $commandClass;
+ }
+
+ return new self($names, $arguments, $displayName);
+ }
+
+ /**
+ * Creates context for a custom Laravel bootstrap script.
+ *
+ * @param string $scriptName
+ * The script identifier, such as `":scripts/task.php"`.
+ *
+ * @param list $arguments
+ * The raw CLI arguments to replay inside Docker.
+ *
+ * @return self
+ * Returns context for script matching.
+ */
+ public static function script(string $scriptName, array $arguments): self
+ {
+ return new self([$scriptName], $arguments, $scriptName);
+ }
+
+ /**
+ * Creates a script identifier for a CLI entrypoint.
+ *
+ * @param string $entrypoint
+ * The first CLI argument.
+ *
+ * @param string $projectRoot
+ * The absolute Laravel project root.
+ *
+ * @return string
+ * Returns the script identifier prefixed with `:`.
+ */
+ public static function scriptName(string $entrypoint, string $projectRoot): string
+ {
+ $entrypoint = str_replace('\\', '/', $entrypoint);
+ $projectRoot = rtrim(str_replace('\\', '/', $projectRoot), '/');
+
+ if ($entrypoint === $projectRoot) {
+ return ':';
+ }
+
+ if (str_starts_with($entrypoint, $projectRoot . '/')) {
+ return ':' . ltrim(substr($entrypoint, strlen($projectRoot)), '/');
+ }
+
+ return ':' . ltrim($entrypoint, '/');
+ }
+
+ /**
+ * Gets entry identifiers used for matching.
+ *
+ * @return list
+ * Returns command names, command classes, or script identifiers.
+ */
+ public function getNames(): array
+ {
+ return $this->names;
+ }
+
+ /**
+ * Gets raw CLI arguments.
+ *
+ * @return list
+ * Returns arguments to replay inside Docker.
+ */
+ public function getArguments(): array
+ {
+ return $this->arguments;
+ }
+
+ /**
+ * Gets the entry name shown in redirect notices.
+ *
+ * @return string
+ * Returns a human-readable Artisan command or script identifier.
+ */
+ public function getDisplayName(): string
+ {
+ return $this->displayName;
+ }
+}
diff --git a/src/Laravel/Redirector.php b/src/Laravel/Redirector.php
new file mode 100644
index 0000000..19b98b9
--- /dev/null
+++ b/src/Laravel/Redirector.php
@@ -0,0 +1,186 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer\Laravel;
+
+use empaphy\docker_composer\ContainerDetector;
+use empaphy\docker_composer\DockerComposeCommandBuilder;
+use empaphy\docker_composer\DockerComposeResolvedOptions;
+use empaphy\docker_composer\DockerComposeRunner;
+use empaphy\docker_composer\DockerComposeWorkdirResolver;
+use empaphy\docker_composer\ProcessRunner;
+use Symfony\Component\Console\Formatter\OutputFormatter;
+
+/**
+ * Redirects Laravel console entries into Docker Compose.
+ */
+final class Redirector
+{
+ /**
+ * Resolves container workdir and host directory mapping.
+ */
+ private DockerComposeWorkdirResolver $workdirResolver;
+
+ /**
+ * Receives redirect notices before Docker execution begins.
+ *
+ * @var resource
+ */
+ private $errorOutput;
+
+ /**
+ * Creates a Laravel console redirector.
+ *
+ * @param DockerComposeRunner $dockerRunner
+ * The shared Docker Compose runner.
+ *
+ * @param DockerComposeCommandBuilder $commandBuilder
+ * The shared Docker Compose command builder.
+ *
+ * @param ContainerDetector $containerDetector
+ * The detector for existing container execution.
+ *
+ * @param ProcessRunner|null $processRunner
+ * The process runner used for workdir discovery, or `null`.
+ *
+ * @param DockerComposeWorkdirResolver|null $workdirResolver
+ * The workdir resolver, or `null` for the default resolver.
+ *
+ * @param resource|null $errorOutput
+ * The writable stream receiving redirect notices, or `null` for stderr.
+ */
+ public function __construct(
+ private readonly DockerComposeRunner $dockerRunner,
+ private readonly DockerComposeCommandBuilder $commandBuilder,
+ private readonly ContainerDetector $containerDetector,
+ private readonly ?ProcessRunner $processRunner = null,
+ ?DockerComposeWorkdirResolver $workdirResolver = null,
+ $errorOutput = null,
+ ) {
+ $this->workdirResolver = $workdirResolver ?? new DockerComposeWorkdirResolver($this->commandBuilder);
+ if ($errorOutput === null) {
+ /** @var resource $errorOutput */
+ $errorOutput = fopen('php://stderr', 'w');
+ }
+
+ $this->errorOutput = $errorOutput;
+ }
+
+ /**
+ * Redirects a Laravel console entry into Docker Compose when configured.
+ *
+ * @param Config $config
+ * The Laravel Docker configuration.
+ *
+ * @param ConsoleEntry $entry
+ * The Laravel console entry being redirected.
+ *
+ * @param string $projectRoot
+ * The absolute Laravel project root on the host.
+ *
+ * @param bool $interactive
+ * Whether interactive Docker execution is allowed.
+ *
+ * @return int|null
+ * Returns Docker exit code when redirected, or `null` for host execution.
+ */
+ public function redirect(Config $config, ConsoleEntry $entry, string $projectRoot, bool $interactive): ?int
+ {
+ if (! $config->isEnabled() || $this->isDisabledByEnvironment() || $this->containerDetector->isInsideContainer() || $config->excludes($entry)) {
+ return null;
+ }
+
+ $effectiveConfig = $config->forEntry($entry);
+ if ($effectiveConfig === null) {
+ return null;
+ }
+
+ $this->writeRedirectNotice($entry, $effectiveConfig);
+
+ $resolution = $this->workdirResolver->resolve($effectiveConfig, $projectRoot, $this->processRunner, $this->dockerRunner);
+ $effectiveOptions = new DockerComposeResolvedOptions($effectiveConfig, $resolution->getWorkdir());
+ $arguments = $entry->getArguments();
+ if ($resolution->hasPathMapping() && $resolution->getContainerWorkingDirectory() !== null) {
+ $arguments = $this->absolutizeEntrypoint($arguments, $projectRoot);
+ $arguments = $this->commandBuilder->translateProjectPaths($arguments, $projectRoot, $resolution->getContainerWorkingDirectory());
+ }
+
+ $command = $this->commandBuilder->buildProcessCommand($effectiveOptions, $arguments, $interactive);
+ $result = $this->dockerRunner->run($effectiveOptions, $command, $interactive);
+
+ return $result->getExitCode();
+ }
+
+ /**
+ * Writes a redirect notice to the configured error stream.
+ *
+ * @param ConsoleEntry $entry
+ * The Laravel console entry being redirected.
+ *
+ * @param Config $config
+ * The effective Docker configuration for the entry.
+ *
+ * @return void
+ * Returns nothing.
+ */
+ private function writeRedirectNotice(ConsoleEntry $entry, Config $config): void
+ {
+ $formatter = new OutputFormatter(false);
+
+ fwrite($this->errorOutput, $formatter->format(sprintf(
+ 'docker-composer: Running %s in Docker Compose service %s.',
+ OutputFormatter::escape($entry->getDisplayName()),
+ OutputFormatter::escape($config->getService()),
+ )) . PHP_EOL);
+ }
+
+ /**
+ * Converts a project-relative PHP entrypoint to an absolute host path.
+ *
+ * @param list $arguments
+ * The raw CLI arguments.
+ *
+ * @param string $projectRoot
+ * The absolute Laravel project root on the host.
+ *
+ * @return list
+ * Returns arguments with an absolute first entrypoint when possible.
+ */
+ private function absolutizeEntrypoint(array $arguments, string $projectRoot): array
+ {
+ $entrypoint = $arguments[0] ?? null;
+ if ($entrypoint === null || str_starts_with($entrypoint, '/') || preg_match('/^[A-Za-z]:[\/\\\\]/', $entrypoint) === 1) {
+ return $arguments;
+ }
+
+ $candidate = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $entrypoint;
+ if (is_file($candidate)) {
+ $arguments[0] = $candidate;
+ }
+
+ return $arguments;
+ }
+
+ /**
+ * Checks whether redirection is disabled by environment variable.
+ *
+ * @return bool
+ * Returns `true` when `DOCKER_COMPOSER_DISABLE` is truthy.
+ */
+ private function isDisabledByEnvironment(): bool
+ {
+ $value = getenv('DOCKER_COMPOSER_DISABLE');
+
+ return $value !== false && $value !== '' && $value !== '0';
+ }
+}
diff --git a/src/Laravel/ServiceProvider.php b/src/Laravel/ServiceProvider.php
new file mode 100644
index 0000000..6856f93
--- /dev/null
+++ b/src/Laravel/ServiceProvider.php
@@ -0,0 +1,341 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer\Laravel;
+
+use Closure;
+use empaphy\docker_composer\DockerComposeCommandBuilder;
+use empaphy\docker_composer\DockerComposeRunner;
+use empaphy\docker_composer\EnvironmentContainerDetector;
+use empaphy\docker_composer\ShellProcessRunner;
+use Illuminate\Console\Events\CommandStarting;
+use Illuminate\Contracts\Console\Kernel;
+use Illuminate\Support\ServiceProvider as IlluminateServiceProvider;
+use Throwable;
+
+/**
+ * Registers Laravel console Docker redirection through package autodiscovery.
+ */
+final class ServiceProvider extends IlluminateServiceProvider
+{
+ /**
+ * Terminates the host process after successful Docker redirection.
+ *
+ * @var Closure(int): void
+ */
+ private Closure $terminator;
+
+ /**
+ * Creates the Laravel Docker service provider.
+ *
+ * @param mixed $app
+ * The Laravel application instance.
+ *
+ * @param (Closure(int): void)|null $terminator
+ * The process terminator, or `null` to use native `exit`.
+ */
+ public function __construct($app, ?Closure $terminator = null)
+ {
+ parent::__construct($app);
+
+ $this->terminator = $terminator ?? static function (int $exitCode): void {
+ // @codeCoverageIgnoreStart
+ exit($exitCode);
+ // @codeCoverageIgnoreEnd
+ };
+ }
+
+ /**
+ * Registers package configuration defaults.
+ *
+ * @return void
+ * Returns nothing.
+ */
+ public function register(): void
+ {
+ $this->mergeConfigFrom(dirname(__DIR__, 2) . '/config/docker_composer.php', 'docker_composer');
+ }
+
+ /**
+ * Boots Laravel Docker redirection for console execution.
+ *
+ * @return void
+ * Returns nothing.
+ */
+ public function boot(): void
+ {
+ $this->publishes([
+ dirname(__DIR__, 2) . '/config/docker_composer.php' => $this->app->configPath('docker_composer.php'),
+ ], 'docker-composer-config');
+
+ if (! $this->app->runningInConsole()) {
+ return;
+ }
+
+ $arguments = $this->getServerArguments();
+ if ($arguments === []) {
+ return;
+ }
+
+ $projectRoot = $this->app->basePath();
+ $config = Config::fromArray($this->getConfig());
+ $redirector = $this->createRedirector();
+ if ($this->isArtisan($arguments[0])) {
+ $this->listenForArtisanCommands($config, $redirector, $arguments, $projectRoot);
+
+ return;
+ }
+
+ $this->exitIfRedirected($redirector->redirect(
+ $config,
+ ConsoleEntry::script(ConsoleEntry::scriptName($arguments[0], $projectRoot), $arguments),
+ $projectRoot,
+ $this->isInteractive(),
+ ));
+ }
+
+ /**
+ * Gets package configuration from Laravel.
+ *
+ * @return array
+ * Returns the `docker_composer` config array.
+ */
+ private function getConfig(): array
+ {
+ $config = $this->app->make('config')->get('docker_composer', []);
+
+ return is_array($config) ? $config : [];
+ }
+
+ /**
+ * Creates the Laravel console redirector.
+ *
+ * @return Redirector
+ * Returns the configured redirector.
+ */
+ private function createRedirector(): Redirector
+ {
+ $processRunner = new ShellProcessRunner();
+ $commandBuilder = new DockerComposeCommandBuilder();
+
+ return new Redirector(
+ new DockerComposeRunner($processRunner, $commandBuilder),
+ $commandBuilder,
+ new EnvironmentContainerDetector(),
+ $processRunner,
+ );
+ }
+
+ /**
+ * Registers the command-starting listener for Artisan commands.
+ *
+ * @param Config $config
+ * The Laravel Docker configuration.
+ *
+ * @param Redirector $redirector
+ * The redirector used to execute Docker commands.
+ *
+ * @param list $arguments
+ * The raw CLI arguments to replay in Docker.
+ *
+ * @param string $projectRoot
+ * The absolute Laravel project root on the host.
+ *
+ * @return void
+ * Returns nothing.
+ */
+ private function listenForArtisanCommands(Config $config, Redirector $redirector, array $arguments, string $projectRoot): void
+ {
+ $artisanClass = 'Illuminate\Console\Application';
+ if (is_callable([$artisanClass, 'starting'])) {
+ call_user_func([$artisanClass, 'starting'], function (object $artisan) use ($config, $redirector, $arguments, $projectRoot): void {
+ $commandName = $this->getCommandNameFromArguments($arguments);
+ $this->exitIfRedirected($redirector->redirect(
+ $config,
+ ConsoleEntry::artisan($commandName, null, $arguments),
+ $projectRoot,
+ $this->isInteractive(),
+ ));
+ });
+ }
+
+ try {
+ $events = $this->app->make('events');
+ } catch (Throwable) {
+ return;
+ }
+
+ if (! is_object($events) || ! method_exists($events, 'listen')) {
+ return;
+ }
+
+ $events->listen(CommandStarting::class, function (CommandStarting $event) use ($config, $redirector, $arguments, $projectRoot): void {
+ $commandName = $this->getEventCommandName($event);
+ $this->exitIfRedirected($redirector->redirect(
+ $config,
+ ConsoleEntry::artisan($commandName, $this->resolveArtisanCommandClass($commandName), $arguments),
+ $projectRoot,
+ $this->isInteractive(),
+ ));
+ });
+ }
+
+ /**
+ * Gets the Artisan command name from raw CLI arguments.
+ *
+ * @param list $arguments
+ * The raw CLI arguments.
+ *
+ * @return string|null
+ * Returns the command name, or `null` when unavailable.
+ */
+ private function getCommandNameFromArguments(array $arguments): ?string
+ {
+ foreach (array_slice($arguments, 1) as $argument) {
+ if ($argument === '--') {
+ return null;
+ }
+
+ if ($argument !== '' && ! str_starts_with($argument, '-')) {
+ return $argument;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets the Artisan command name from a Laravel command event.
+ *
+ * @param CommandStarting $event
+ * The event.
+ *
+ * @return string|null
+ * Returns the command name, or `null` when unavailable.
+ */
+ private function getEventCommandName(CommandStarting $event): ?string
+ {
+ $command = $event->command ?? null;
+
+ return is_string($command) && $command !== '' ? $command : null;
+ }
+
+ /**
+ * Resolves an Artisan command name to its command class.
+ *
+ * @param string|null $commandName
+ * The Artisan command name, or `null` when unavailable.
+ *
+ * @return class-string|null
+ * Returns the command class, or `null` when resolution fails.
+ */
+ private function resolveArtisanCommandClass(?string $commandName): ?string
+ {
+ if ($commandName === null) {
+ return null;
+ }
+
+ try {
+ $kernel = $this->app->make(Kernel::class);
+ if (! is_object($kernel)) {
+ return null;
+ }
+
+ foreach ($kernel->all() as $name => $command) {
+ if ($name === $commandName && is_object($command)) {
+ return $command::class;
+ }
+ }
+
+ if (! method_exists($kernel, 'getArtisan')) {
+ return null;
+ }
+
+ $artisan = $kernel->getArtisan();
+ if (! is_object($artisan) || ! method_exists($artisan, 'find')) {
+ return null;
+ }
+
+ $command = $artisan->find($commandName);
+
+ return is_object($command) ? $command::class : null;
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * Gets raw CLI arguments from `$_SERVER`.
+ *
+ * @return list
+ * Returns `argv` as a list of strings, or an empty list when unavailable.
+ */
+ private function getServerArguments(): array
+ {
+ if (! isset($_SERVER['argv']) || ! is_array($_SERVER['argv']) || ! array_is_list($_SERVER['argv'])) {
+ return [];
+ }
+
+ $arguments = [];
+ foreach ($_SERVER['argv'] as $argument) {
+ if (! is_string($argument)) {
+ return [];
+ }
+
+ $arguments[] = $argument;
+ }
+
+ return $arguments;
+ }
+
+ /**
+ * Checks whether the entrypoint is Laravel's Artisan file.
+ *
+ * @param string $entrypoint
+ * The first CLI argument.
+ *
+ * @return bool
+ * Returns `true` when __entrypoint__ names `artisan`.
+ */
+ private function isArtisan(string $entrypoint): bool
+ {
+ return basename(str_replace('\\', '/', $entrypoint)) === 'artisan';
+ }
+
+ /**
+ * Checks whether the current process can run interactively.
+ *
+ * @return bool
+ * Returns `true` when STDIN is a terminal.
+ */
+ private function isInteractive(): bool
+ {
+ return defined('STDIN') && function_exists('stream_isatty') && stream_isatty(STDIN);
+ }
+
+ /**
+ * Exits the host process if a Docker redirect occurred.
+ *
+ * @param int|null $exitCode
+ * The Docker exit code, or `null` when no redirection happened.
+ *
+ * @return void
+ * Returns nothing.
+ */
+ private function exitIfRedirected(?int $exitCode): void
+ {
+ if ($exitCode !== null) {
+ ($this->terminator)($exitCode);
+ }
+ }
+}
diff --git a/src/ProcessRunner.php b/src/ProcessRunner.php
index 0a8b5ef..98d5a28 100644
--- a/src/ProcessRunner.php
+++ b/src/ProcessRunner.php
@@ -14,7 +14,7 @@
namespace empaphy\docker_composer;
/**
- * Runs external commands for Docker Composer.
+ * Runs external commands for Docker-Composer.
*/
interface ProcessRunner
{
diff --git a/src/ShellProcessRunner.php b/src/ShellProcessRunner.php
new file mode 100644
index 0000000..467f656
--- /dev/null
+++ b/src/ShellProcessRunner.php
@@ -0,0 +1,151 @@
+
+ * @license MIT
+ * @package DockerComposer
+ */
+
+declare(strict_types=1);
+
+namespace empaphy\docker_composer;
+
+/**
+ * Runs commands through PHP process primitives without Composer IO.
+ */
+final class ShellProcessRunner implements OutputCapturingProcessRunner
+{
+ /**
+ * Opens process resources.
+ *
+ * @var callable(list, array, array): (resource|false)
+ * Returns an open process resource, or `false` when startup fails.
+ */
+ private $processOpener;
+
+ /**
+ * Stores stderr captured from the last command.
+ */
+ private string $errorOutput = '';
+
+ /**
+ * Creates a shell-backed process runner.
+ *
+ * @param (callable(list, array, array): (resource|false))|null $processOpener
+ * The process opener, or `null` to use `proc_open`.
+ */
+ public function __construct(?callable $processOpener = null)
+ {
+ $this->processOpener = $processOpener ?? 'proc_open';
+ }
+
+ /**
+ * Runs a command and returns its process status.
+ *
+ * @param list $command
+ * The command arguments to execute.
+ *
+ * @param bool $tty
+ * Whether to request TTY passthrough.
+ *
+ * @return int
+ * Returns the command exit code.
+ */
+ public function run(array $command, bool $tty = false): int
+ {
+ $output = '';
+
+ return $this->runProcess($command, false, $output);
+ }
+
+ /**
+ * Runs a command while capturing standard output.
+ *
+ * @param list $command
+ * The command arguments to execute.
+ *
+ * @param string $output
+ * The captured standard output.
+ *
+ * @return int
+ * Returns the process exit code.
+ */
+ public function runWithOutput(array $command, string &$output): int
+ {
+ return $this->runProcess($command, true, $output);
+ }
+
+ /**
+ * Gets stderr captured from the last command.
+ *
+ * @return string
+ * Returns the last process error output.
+ */
+ public function getErrorOutput(): string
+ {
+ return $this->errorOutput;
+ }
+
+ /**
+ * Checks whether TTY passthrough is available.
+ *
+ * @return bool
+ * Returns `false`; shell execution uses inherited streams.
+ */
+ public function supportsTty(): bool
+ {
+ return false;
+ }
+
+ /**
+ * Runs a command with optional stdout capture.
+ *
+ * @param list $command
+ * The command arguments to execute.
+ *
+ * @param bool $captureOutput
+ * Whether standard output should be captured instead of inherited.
+ *
+ * @param string $output
+ * The captured standard output.
+ *
+ * @return int
+ * Returns the process exit code.
+ */
+ private function runProcess(array $command, bool $captureOutput, string &$output): int
+ {
+ $this->errorOutput = '';
+ $output = '';
+ $descriptors = [
+ 0 => ['file', 'php://stdin', 'r'],
+ 1 => $captureOutput ? ['pipe', 'w'] : ['file', 'php://stdout', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ /** @var array $pipes */
+ $pipes = [];
+ $process = ($this->processOpener)($command, $descriptors, $pipes);
+ if (! is_resource($process)) {
+ $this->errorOutput = 'Unable to start process.';
+
+ return 1;
+ }
+
+ if ($captureOutput) {
+ $output = stream_get_contents($pipes[1]) ?: '';
+ fclose($pipes[1]);
+ }
+
+ $this->errorOutput = stream_get_contents($pipes[2]) ?: '';
+ if ($this->errorOutput !== '') {
+ file_put_contents('php://stderr', $this->errorOutput);
+ }
+
+ fclose($pipes[2]);
+
+ return proc_close($process);
+ }
+}
diff --git a/stubs/PHPStan/Testing/functions.stub.php b/stubs/PHPStan/Testing/functions.stub.php
deleted file mode 100644
index 730c1e5..0000000
--- a/stubs/PHPStan/Testing/functions.stub.php
+++ /dev/null
@@ -1,46 +0,0 @@
-
- */
- private array $projectDirectories = [];
-
- protected function tearDown(): void
- {
- foreach ($this->projectDirectories as $projectDirectory) {
- $this->runCommand(['docker', 'compose', 'down', '--volumes', '--remove-orphans'], $projectDirectory, [], false);
- $this->removeDirectory($projectDirectory);
- }
-
- $this->projectDirectories = [];
- }
-
- public function testExecModeRedirectsCustomAndLifecycleScriptsWithAutoUp(): void
- {
- $projectDirectory = $this->createProject([
- 'service' => 'php',
- 'mode' => 'exec',
- 'compose-files' => 'docker-compose.yaml',
- 'workdir' => '/usr/src/app',
- ]);
- $this->installProject($projectDirectory);
-
- $this->runCommand(['docker', 'compose', 'down', '--volumes', '--remove-orphans'], $projectDirectory);
- $this->runCommand(['composer', 'run-script', 'mark'], $projectDirectory);
- self::assertSame('1', trim((string) file_get_contents($projectDirectory . '/result.txt')));
-
- @unlink($projectDirectory . '/lifecycle.txt');
- $this->runCommand(['composer', 'dump-autoload'], $projectDirectory);
- self::assertSame('1', trim((string) file_get_contents($projectDirectory . '/lifecycle.txt')));
- }
-
- public function testInstallCommandRedirectsWhenPluginIsAlreadyInstalled(): void
- {
- $projectDirectory = $this->createProject([
- 'service' => 'php',
- 'mode' => 'exec',
- 'compose-files' => 'docker-compose.yaml',
- 'workdir' => '/usr/src/app',
- ]);
- $this->installProject($projectDirectory);
-
- $result = $this->runCommand(['composer', 'install', '--no-interaction', '--no-progress', '--prefer-dist'], $projectDirectory);
-
- self::assertStringContainsString('Running composer install in Docker Compose service php.', $result['stderr']);
- }
-
- public function testServiceMappingOverrideRedirectsToConfiguredService(): void
- {
- $projectDirectory = $this->createProject([
- 'service' => 'php',
- 'service-mapping' => [
- 'php_tools' => 'mark',
- ],
- 'compose-files' => 'docker-compose.yaml',
- 'workdir' => '/usr/src/app',
- ]);
- $this->installProject($projectDirectory);
-
- $this->runCommand(['composer', 'run-script', 'mark'], $projectDirectory);
-
- self::assertSame('override', trim((string) file_get_contents($projectDirectory . '/result.txt')));
- }
-
- public function testRunModeBypassMissingConfigAndInsideContainerBehavior(): void
- {
- $runProjectDirectory = $this->createProject([
- 'service' => 'php',
- 'mode' => 'run',
- 'compose-files' => 'docker-compose.yaml',
- 'workdir' => '/usr/src/app',
- ]);
- $this->installProject($runProjectDirectory);
-
- $this->runCommand(['composer', 'run-script', 'mark'], $runProjectDirectory);
- self::assertSame('1', trim((string) file_get_contents($runProjectDirectory . '/result.txt')));
-
- @unlink($runProjectDirectory . '/result.txt');
- $this->runCommand(['composer', 'run-script', 'mark'], $runProjectDirectory, ['DOCKER_COMPOSER_DISABLE' => '1']);
- self::assertSame('host', trim((string) file_get_contents($runProjectDirectory . '/result.txt')));
-
- @unlink($runProjectDirectory . '/result.txt');
- $this->runCommand([
- 'docker',
- 'compose',
- 'run',
- '--rm',
- '-T',
- '--workdir',
- '/usr/src/app',
- '--env',
- 'DOCKER_COMPOSER_INSIDE=1',
- 'php',
- 'composer',
- 'run-script',
- 'mark',
- ], $runProjectDirectory);
- self::assertSame('1', trim((string) file_get_contents($runProjectDirectory . '/result.txt')));
-
- $missingConfigProjectDirectory = $this->createProject([]);
- $this->installProject($missingConfigProjectDirectory);
-
- $this->runCommand(['composer', 'run-script', 'mark'], $missingConfigProjectDirectory);
- self::assertSame('host', trim((string) file_get_contents($missingConfigProjectDirectory . '/result.txt')));
- }
-
- /**
- * @param array $dockerComposerConfig
- * @param list>|null $repositories
- */
- private function createProject(array $dockerComposerConfig, ?array $repositories = null, string $requireVersion = '*'): string
- {
- $projectDirectory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
- . DIRECTORY_SEPARATOR
- . 'docker-composer-integration-'
- . bin2hex(random_bytes(8));
- if (! mkdir($projectDirectory, 0777, true) && ! is_dir($projectDirectory)) {
- throw new \RuntimeException(sprintf('Unable to create integration project directory "%s".', $projectDirectory));
- }
-
- $this->projectDirectories[] = $projectDirectory;
-
- $composerJson = [
- 'name' => 'empaphy/docker-composer-integration',
- 'description' => 'Temporary docker-composer integration fixture.',
- 'minimum-stability' => 'dev',
- 'prefer-stable' => true,
- 'repositories' => $repositories ?? [[
- 'type' => 'path',
- 'url' => dirname(__DIR__, 2),
- 'options' => ['symlink' => false],
- ]],
- 'require' => [
- 'empaphy/docker-composer' => $requireVersion,
- ],
- 'config' => [
- 'allow-plugins' => [
- 'empaphy/docker-composer' => true,
- ],
- ],
- 'scripts' => [
- 'mark' => '@php -r "file_put_contents(\'result.txt\', getenv(\'DOCKER_COMPOSER_TEST_MARK\') ?: (getenv(\'DOCKER_COMPOSER_INSIDE\') ?: \'host\'));"',
- 'post-autoload-dump' => '@php -r "file_put_contents(\'lifecycle.txt\', getenv(\'DOCKER_COMPOSER_INSIDE\') ?: \'host\');"',
- ],
- 'extra' => [
- 'docker-composer' => $dockerComposerConfig,
- ],
- ];
-
- $encodedComposerJson = json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- if ($encodedComposerJson === false) {
- throw new \RuntimeException('Unable to encode integration composer.json.');
- }
-
- file_put_contents($projectDirectory . '/composer.json', $encodedComposerJson . PHP_EOL);
- file_put_contents($projectDirectory . '/docker-compose.yaml', sprintf(<<<'YAML'
-services:
- php:
- image: %s
- command: ['sleep', 'infinity']
- working_dir: /usr/src/app
- volumes:
- - { type: bind, source: '.', target: '/usr/src/app' }
- php_tools:
- image: %s
- command: ['sleep', 'infinity']
- environment:
- DOCKER_COMPOSER_TEST_MARK: override
- working_dir: /usr/src/app
- volumes:
- - { type: bind, source: '.', target: '/usr/src/app' }
-YAML, $this->getComposerImage(), $this->getComposerImage()));
-
- return $projectDirectory;
- }
-
- /**
- * @param list> $repositories
- */
- protected function updateProjectRepositories(string $projectDirectory, array $repositories): void
- {
- $composerJsonPath = $projectDirectory . '/composer.json';
- $composerJson = json_decode((string) file_get_contents($composerJsonPath), true);
- if (! is_array($composerJson)) {
- throw new \RuntimeException(sprintf('Unable to decode "%s".', $composerJsonPath));
- }
-
- $composerJson['repositories'] = $repositories;
-
- $encodedComposerJson = json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- if ($encodedComposerJson === false) {
- throw new \RuntimeException(sprintf('Unable to encode "%s".', $composerJsonPath));
- }
-
- file_put_contents($composerJsonPath, $encodedComposerJson . PHP_EOL);
- }
-
- private function getComposerImage(): string
- {
- $composerVersion = getenv('DOCKER_COMPOSER_TEST_COMPOSER_VERSION');
- if ($composerVersion === false || $composerVersion === '' || $composerVersion === 'v2') {
- return 'composer:2';
- }
-
- return 'composer:' . $composerVersion;
- }
-
- /**
- * @return list
- */
- protected function getRequireCommand(string $package): array
- {
- $command = ['composer', 'require', $package, '--no-interaction', '--no-progress'];
- $composerVersion = getenv('DOCKER_COMPOSER_TEST_COMPOSER_VERSION');
- if ($composerVersion !== false && $composerVersion !== 'v2') {
- return $command;
- }
-
- array_splice($command, 2, 0, '-m');
-
- return $command;
- }
-
- private function installProject(string $projectDirectory): void
- {
- $this->runCommand(['composer', 'install', '--no-interaction', '--no-progress', '--prefer-dist'], $projectDirectory);
- }
-
- /**
- * @param list $command
- * @param array $environment
- *
- * @return array{stdout: string, stderr: string, exit-code: int}
- */
- private function runCommand(array $command, string $workingDirectory, array $environment = [], bool $failOnError = true): array
- {
- $descriptorSpec = [
- 1 => ['pipe', 'w'],
- 2 => ['pipe', 'w'],
- ];
- $processEnvironment = array_merge(getenv() ?: [], [
- 'COMPOSER_CACHE_DIR' => $workingDirectory . '/.composer-cache',
- 'COMPOSER_NO_INTERACTION' => '1',
- ], $environment);
-
- $process = proc_open($command, $descriptorSpec, $pipes, $workingDirectory, $processEnvironment);
- if (! is_resource($process)) {
- throw new \RuntimeException(sprintf('Unable to start command: %s', implode(' ', $command)));
- }
-
- $stdout = stream_get_contents($pipes[1]);
- $stderr = stream_get_contents($pipes[2]);
- fclose($pipes[1]);
- fclose($pipes[2]);
- $exitCode = proc_close($process);
-
- if ($failOnError && $exitCode !== 0) {
- self::fail(sprintf(
- "Command failed with exit code %d:\n%s\n\nSTDOUT:\n%s\n\nSTDERR:\n%s",
- $exitCode,
- implode(' ', $command),
- $stdout,
- $stderr,
- ));
- }
-
- return [
- 'stdout' => $stdout,
- 'stderr' => $stderr,
- 'exit-code' => $exitCode,
- ];
- }
-
- private function removeDirectory(string $directory): void
- {
- if (! is_dir($directory)) {
- return;
- }
-
- $iterator = new \RecursiveIteratorIterator(
- new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
- \RecursiveIteratorIterator::CHILD_FIRST,
- );
-
- foreach ($iterator as $fileInfo) {
- if ($fileInfo->isDir() && ! $fileInfo->isLink()) {
- rmdir($fileInfo->getPathname());
- } else {
- unlink($fileInfo->getPathname());
- }
- }
-
- rmdir($directory);
- }
-}
diff --git a/tests/Unit/DockerComposeCommandBuilderTest.php b/tests/Unit/DockerComposeCommandBuilderTest.php
index e2f7ade..c180b8d 100644
--- a/tests/Unit/DockerComposeCommandBuilderTest.php
+++ b/tests/Unit/DockerComposeCommandBuilderTest.php
@@ -12,6 +12,7 @@
use Composer\Util\ProcessExecutor;
use empaphy\docker_composer\DockerComposeCommandBuilder;
use empaphy\docker_composer\DockerComposerConfig;
+use empaphy\docker_composer\DockerComposeOptions;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Symfony\Component\Console\Input\ArrayInput;
@@ -169,6 +170,111 @@ public function testCommandBuilderBuildsInteractiveRunCommand(): void
], $command);
}
+ public function testCommandBuilderBuildsGenericProcessCommand(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => [
+ 'service' => 'php',
+ 'mode' => DockerComposeOptions::MODE_RUN,
+ 'workdir' => '/usr/src/app',
+ ],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+
+ $command = (new DockerComposeCommandBuilder())->buildProcessCommand($config, ['php', 'artisan', 'migrate'], false);
+
+ self::assertSame([
+ 'docker',
+ 'compose',
+ 'run',
+ '--rm',
+ '-T',
+ '--workdir',
+ '/usr/src/app',
+ '--env',
+ 'DOCKER_COMPOSER_INSIDE=1',
+ 'php',
+ 'php',
+ 'artisan',
+ 'migrate',
+ ], $command);
+ }
+
+ public function testCommandBuilderBuildsWorkdirDiscoveryCommands(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => [
+ 'service' => 'php',
+ 'compose-files' => 'compose.yaml',
+ 'project-directory' => '.',
+ ],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $builder = new DockerComposeCommandBuilder();
+
+ self::assertSame([
+ 'docker',
+ 'compose',
+ '--file',
+ 'compose.yaml',
+ '--project-directory',
+ '.',
+ 'config',
+ '--format',
+ 'json',
+ ], $builder->buildConfigCommand($config));
+ self::assertSame([
+ 'docker',
+ 'compose',
+ '--file',
+ 'compose.yaml',
+ '--project-directory',
+ '.',
+ 'exec',
+ '-T',
+ 'php',
+ 'pwd',
+ ], $builder->buildExecWorkdirCommand($config));
+ self::assertSame([
+ 'docker',
+ 'compose',
+ '--file',
+ 'compose.yaml',
+ '--project-directory',
+ '.',
+ 'run',
+ '--rm',
+ '-T',
+ 'php',
+ 'pwd',
+ ], $builder->buildRunWorkdirCommand($config));
+ self::assertSame([
+ 'docker',
+ 'image',
+ 'inspect',
+ '--format',
+ '{{.Config.WorkingDir}}',
+ 'php:cli',
+ ], $builder->buildImageWorkdirCommand('php:cli'));
+ }
+
+ public function testCommandBuilderTranslatesProjectPaths(): void
+ {
+ $arguments = (new DockerComposeCommandBuilder())->translateProjectPaths([
+ '/host/app/artisan',
+ '--path=/host/app/database/migrations',
+ '/host/app',
+ '/elsewhere/file.php',
+ ], '/host/app', '/usr/src/app');
+
+ self::assertSame([
+ '/usr/src/app/artisan',
+ '--path=/usr/src/app/database/migrations',
+ '/usr/src/app',
+ '/elsewhere/file.php',
+ ], $arguments);
+ }
+
public function testCommandBuilderUsesServerArgvFallback(): void
{
[$composer] = $this->createComposer([], [
diff --git a/tests/Unit/DockerComposeRunnerTest.php b/tests/Unit/DockerComposeRunnerTest.php
new file mode 100644
index 0000000..d511328
--- /dev/null
+++ b/tests/Unit/DockerComposeRunnerTest.php
@@ -0,0 +1,105 @@
+createConfig(['service' => 'php']);
+ $processRunner = new MockProcessRunner();
+ $runner = new DockerComposeRunner($processRunner, new DockerComposeCommandBuilder());
+
+ $first = $runner->run($config, ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], false);
+ $second = $runner->run($config, ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], false);
+
+ self::assertTrue($first->isSuccessful());
+ self::assertTrue($second->isSuccessful());
+ self::assertSame([
+ ['docker', 'compose', 'up', '-d', 'php'],
+ ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'],
+ ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'],
+ ], $processRunner->commands);
+ }
+
+ public function testExecModeSkipsStartupWhenServiceIsRunning(): void
+ {
+ $config = $this->createConfig(['service' => 'php']);
+ $processRunner = new MockOutputCapturingProcessRunner(outputs: ['php']);
+ $runner = new DockerComposeRunner($processRunner, new DockerComposeCommandBuilder());
+
+ $result = $runner->run($config, ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], false);
+
+ self::assertTrue($result->isSuccessful());
+ self::assertSame([
+ ['docker', 'compose', 'ps', '--status', 'running', '--services', 'php'],
+ ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'],
+ ], $processRunner->commands);
+ }
+
+ public function testStartupFailureReturnsFailedResult(): void
+ {
+ $config = $this->createConfig(['service' => 'php']);
+ $processRunner = new MockProcessRunner([7]);
+ $runner = new DockerComposeRunner($processRunner, new DockerComposeCommandBuilder());
+
+ $result = $runner->run($config, ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], false);
+
+ self::assertFalse($result->isSuccessful());
+ self::assertSame('up', $result->getPhase());
+ self::assertSame(7, $result->getExitCode());
+ self::assertSame(['docker', 'compose', 'up', '-d', 'php'], $result->getCommand());
+ self::assertSame([
+ ['docker', 'compose', 'up', '-d', 'php'],
+ ], $processRunner->commands);
+ }
+
+ public function testRunModeDoesNotStartService(): void
+ {
+ $config = $this->createConfig([
+ 'service' => 'php',
+ 'mode' => DockerComposeOptions::MODE_RUN,
+ ]);
+ $processRunner = new MockProcessRunner();
+ $runner = new DockerComposeRunner($processRunner, new DockerComposeCommandBuilder());
+
+ $result = $runner->run($config, ['docker', 'compose', 'run', '--rm', '-T', 'php', 'php', '-v'], false);
+
+ self::assertSame('run', $result->getPhase());
+ self::assertSame([
+ ['docker', 'compose', 'run', '--rm', '-T', 'php', 'php', '-v'],
+ ], $processRunner->commands);
+ }
+
+ /**
+ * @param array $options
+ */
+ private function createConfig(array $options): DockerComposerConfig
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => $options,
+ ]);
+
+ return DockerComposerConfig::fromComposer($composer);
+ }
+}
diff --git a/tests/Unit/DockerComposeWorkdirResolverTest.php b/tests/Unit/DockerComposeWorkdirResolverTest.php
new file mode 100644
index 0000000..dac1439
--- /dev/null
+++ b/tests/Unit/DockerComposeWorkdirResolverTest.php
@@ -0,0 +1,371 @@
+composeOutput([
+ 'volumes' => [
+ ['type' => 'bind', 'source' => '/host', 'target' => '/container'],
+ ['type' => 'bind', 'source' => '/host/app', 'target' => '/usr/src/app'],
+ ],
+ ])]);
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertSame('/usr/src/app', $resolution->getWorkdir());
+ self::assertSame('/usr/src/app', $resolution->getContainerWorkingDirectory());
+ self::assertSame('/usr/src/app', $resolution->getContainerProjectRoot());
+ self::assertTrue($resolution->hasPathMapping());
+ self::assertSame([['docker', 'compose', 'config', '--format', 'json']], $runner->commands);
+ }
+
+ public function testVolumeMappingUsesLongestAncestorSource(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([
+ 'volumes' => [
+ ['type' => 'bind', 'source' => '/host', 'target' => '/container'],
+ ['type' => 'bind', 'source' => '/host/other', 'target' => '/other'],
+ ],
+ ])]);
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app/package', $runner, $this->createRunner($runner));
+
+ self::assertSame('/container/app/package', $resolution->getWorkdir());
+ self::assertSame('/container/app/package', $resolution->getContainerWorkingDirectory());
+ }
+
+ public function testExplicitWorkdirIsAuthoritativeAndFallbackMapping(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([
+ 'volumes' => [
+ ['type' => 'bind', 'source' => '/host/app', 'target' => '/mounted'],
+ ],
+ ])]);
+ $config = $this->createConfig([
+ 'service' => 'php',
+ 'workdir' => '/configured',
+ ]);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertSame('/configured', $resolution->getWorkdir());
+ self::assertSame('/mounted', $resolution->getContainerWorkingDirectory());
+
+ $fallback = $this->createResolver()->resolve($config, '/host/app', new MockProcessRunner());
+
+ self::assertSame('/configured', $fallback->getWorkdir());
+ self::assertSame('/configured', $fallback->getContainerWorkingDirectory());
+ }
+
+ public function testComposeWorkingDirSetsWorkdirWithoutPathMapping(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([
+ 'working_dir' => '/srv/app',
+ ])]);
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertSame('/srv/app', $resolution->getWorkdir());
+ self::assertNull($resolution->getContainerWorkingDirectory());
+ self::assertFalse($resolution->hasPathMapping());
+ }
+
+ public function testExecModeProbesServiceWorkdir(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(
+ [0, 0, 0, 0],
+ outputs: [$this->composeOutput([]), '', "/pwd\n"],
+ );
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertSame('/pwd', $resolution->getWorkdir());
+ self::assertNull($resolution->getContainerWorkingDirectory());
+ self::assertSame(['config', 'ps', 'up', 'exec'], [
+ $runner->commands[0][2],
+ $runner->commands[1][2],
+ $runner->commands[2][2],
+ $runner->commands[3][2],
+ ]);
+ self::assertSame(['docker', 'compose', 'exec', '-T', 'php', 'pwd'], $runner->commands[3]);
+ }
+
+ public function testRunModeProbesOneOffServiceWorkdir(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(
+ [0, 0],
+ outputs: [$this->composeOutput([]), "/run-pwd\n"],
+ );
+ $config = $this->createConfig([
+ 'service' => 'php',
+ 'mode' => 'run',
+ ]);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertSame('/run-pwd', $resolution->getWorkdir());
+ self::assertSame(['docker', 'compose', 'run', '--rm', '-T', 'php', 'pwd'], $runner->commands[1]);
+ }
+
+ public function testImageWorkdirFallbackIgnoresEmptyImageWorkdir(): void
+ {
+ $config = $this->createConfig([
+ 'service' => 'php',
+ 'mode' => 'run',
+ ]);
+ $resolvedRunner = new MockOutputCapturingProcessRunner(
+ [0, 1, 0],
+ outputs: [$this->composeOutput(['image' => 'php:cli']), '', "/image\n"],
+ );
+
+ $resolved = $this->createResolver()->resolve($config, '/host/app', $resolvedRunner, $this->createRunner($resolvedRunner));
+
+ self::assertSame('/image', $resolved->getWorkdir());
+ self::assertSame(['docker', 'image', 'inspect', '--format', '{{.Config.WorkingDir}}', 'php:cli'], $resolvedRunner->commands[2]);
+
+ $emptyRunner = new MockOutputCapturingProcessRunner(
+ [0, 1, 0],
+ outputs: [$this->composeOutput(['image' => 'php:cli']), '', ''],
+ );
+
+ $empty = $this->createResolver()->resolve($config, '/host/app', $emptyRunner, $this->createRunner($emptyRunner));
+
+ self::assertNull($empty->getWorkdir());
+ self::assertNull($empty->getContainerWorkingDirectory());
+ }
+
+ public function testComposeConfigDiscoveryFailuresResolveNothing(): void
+ {
+ $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner([1], outputs: ['']));
+ $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner(outputs: ['not-json']));
+ $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner(outputs: [
+ json_encode(['networks' => []], JSON_THROW_ON_ERROR),
+ ]));
+ $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner(outputs: [
+ json_encode(['services' => ['worker' => []]], JSON_THROW_ON_ERROR),
+ ]));
+ $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner(outputs: [
+ json_encode(['services' => ['php' => 'invalid']], JSON_THROW_ON_ERROR),
+ ]));
+ }
+
+ public function testIgnoresNonListVolumesAndReadsWorkingDir(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([
+ 'volumes' => [
+ 'source' => '/host/app',
+ 'target' => '/mounted',
+ ],
+ 'working_dir' => '/srv/app',
+ ])]);
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertSame('/srv/app', $resolution->getWorkdir());
+ self::assertNull($resolution->getContainerWorkingDirectory());
+ }
+
+ public function testIgnoresMalformedVolumeEntriesAndReadsWorkingDir(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([
+ 'volumes' => [
+ 'not-an-object',
+ ['type' => 'volume', 'source' => '/host/app', 'target' => '/mounted'],
+ ['type' => 'bind', 'source' => '', 'target' => '/mounted'],
+ ['type' => 'bind', 'source' => '/host/app', 'target' => ''],
+ ['type' => 'bind', 'source' => false, 'target' => '/mounted'],
+ ['type' => 'bind', 'source' => '/host/app', 'target' => false],
+ ],
+ 'working_dir' => '/srv/app',
+ ])]);
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertSame('/srv/app', $resolution->getWorkdir());
+ self::assertNull($resolution->getContainerWorkingDirectory());
+ }
+
+ public function testVolumeMappingNormalizesWindowsPaths(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([
+ 'volumes' => [
+ ['type' => 'bind', 'source' => 'C:\\Users\\project', 'target' => '/workspace'],
+ ],
+ ])]);
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, 'C:\\Users\\project\\packages\\app', $runner, $this->createRunner($runner));
+
+ self::assertSame('/workspace/packages/app', $resolution->getWorkdir());
+ self::assertSame('/workspace/packages/app', $resolution->getContainerWorkingDirectory());
+ }
+
+ public function testVolumeMappingAppendsDescendantPathsToContainerRoot(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([
+ 'volumes' => [
+ ['type' => 'bind', 'source' => '/host', 'target' => '/'],
+ ],
+ ])]);
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertSame('/app', $resolution->getWorkdir());
+ self::assertSame('/app', $resolution->getContainerWorkingDirectory());
+ }
+
+ public function testAppendPathReturnsBaseForEmptySuffix(): void
+ {
+ $method = new \ReflectionMethod(DockerComposeWorkdirResolver::class, 'appendPath');
+
+ self::assertSame('/container', $method->invoke($this->createResolver(), '/container', ''));
+ }
+
+ public function testExecModeProbeReturnsNullWithoutDockerRunner(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([])]);
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner);
+
+ self::assertNull($resolution->getWorkdir());
+ self::assertNull($resolution->getContainerWorkingDirectory());
+ self::assertSame([['docker', 'compose', 'config', '--format', 'json']], $runner->commands);
+ }
+
+ public function testExecModeProbeReturnsNullWhenServiceStartupFails(): void
+ {
+ $runner = new MockOutputCapturingProcessRunner(
+ [0, 1, 2],
+ outputs: [$this->composeOutput([]), ''],
+ );
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertNull($resolution->getWorkdir());
+ self::assertNull($resolution->getContainerWorkingDirectory());
+ self::assertSame(['config', 'ps', 'up'], [
+ $runner->commands[0][2],
+ $runner->commands[1][2],
+ $runner->commands[2][2],
+ ]);
+ }
+
+ public function testImageWorkdirFallbackReturnsNullWhenImageIsMissingOrEmpty(): void
+ {
+ foreach ([[], ['image' => '']] as $service) {
+ $runner = new MockOutputCapturingProcessRunner(
+ [0, 1],
+ outputs: [$this->composeOutput($service), ''],
+ );
+ $config = $this->createConfig([
+ 'service' => 'php',
+ 'mode' => 'run',
+ ]);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner));
+
+ self::assertNull($resolution->getWorkdir());
+ self::assertNull($resolution->getContainerWorkingDirectory());
+ self::assertSame(['config', 'run'], [
+ $runner->commands[0][2],
+ $runner->commands[1][2],
+ ]);
+ }
+ }
+
+ public function testResolvedOptionsDelegateExceptWorkdir(): void
+ {
+ $config = $this->createConfig([
+ 'service' => 'php',
+ 'mode' => 'run',
+ 'compose-files' => 'compose.yaml',
+ 'project-directory' => '.',
+ ]);
+ $options = new DockerComposeResolvedOptions($config, '/resolved');
+
+ self::assertSame('php', $options->getService());
+ self::assertSame('run', $options->getMode());
+ self::assertSame(['compose.yaml'], $options->getComposeFiles());
+ self::assertSame('.', $options->getProjectDirectory());
+ self::assertSame('/resolved', $options->getWorkdir());
+ }
+
+ /**
+ * @param array $service
+ */
+ private function composeOutput(array $service): string
+ {
+ return json_encode(['services' => ['php' => $service]], JSON_THROW_ON_ERROR);
+ }
+
+ private function assertUnresolvedAfterConfigDiscovery(MockOutputCapturingProcessRunner $runner): void
+ {
+ $config = $this->createConfig(['service' => 'php']);
+
+ $resolution = $this->createResolver()->resolve($config, '/host/app', $runner);
+
+ self::assertNull($resolution->getWorkdir());
+ self::assertNull($resolution->getContainerWorkingDirectory());
+ self::assertSame([['docker', 'compose', 'config', '--format', 'json']], $runner->commands);
+ }
+
+ /**
+ * @param array $options
+ */
+ private function createConfig(array $options): DockerComposerConfig
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => $options,
+ ]);
+
+ return DockerComposerConfig::fromComposer($composer);
+ }
+
+ private function createResolver(): DockerComposeWorkdirResolver
+ {
+ return new DockerComposeWorkdirResolver(new DockerComposeCommandBuilder());
+ }
+
+ private function createRunner(MockOutputCapturingProcessRunner $runner): DockerComposeRunner
+ {
+ return new DockerComposeRunner($runner, new DockerComposeCommandBuilder());
+ }
+}
diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php
index 93b1b64..9af5ff1 100644
--- a/tests/Unit/DockerComposerPluginTest.php
+++ b/tests/Unit/DockerComposerPluginTest.php
@@ -15,6 +15,11 @@
use Composer\Script\Event as ScriptEvent;
use empaphy\docker_composer\ComposerProcessRunner;
use empaphy\docker_composer\DockerComposeCommandBuilder;
+use empaphy\docker_composer\DockerComposeExecutionResult;
+use empaphy\docker_composer\DockerComposeResolvedOptions;
+use empaphy\docker_composer\DockerComposeRunner;
+use empaphy\docker_composer\DockerComposeWorkdirResolution;
+use empaphy\docker_composer\DockerComposeWorkdirResolver;
use empaphy\docker_composer\DockerComposerConfig;
use empaphy\docker_composer\DockerComposerPlugin;
use InvalidArgumentException;
@@ -31,6 +36,11 @@
#[CoversClass(ComposerProcessRunner::class)]
#[CoversClass(DockerComposerConfig::class)]
#[CoversClass(DockerComposeCommandBuilder::class)]
+#[CoversClass(DockerComposeRunner::class)]
+#[CoversClass(DockerComposeExecutionResult::class)]
+#[CoversClass(DockerComposeWorkdirResolver::class)]
+#[CoversClass(DockerComposeWorkdirResolution::class)]
+#[CoversClass(DockerComposeResolvedOptions::class)]
class DockerComposerPluginTest extends TestCase
{
public function testPluginLifecycleMethodsAreSafe(): void
@@ -332,13 +342,24 @@ public function testExecModeSkipsAutoUpWhenServiceIsAlreadyRunning(): void
],
],
);
- $runner = new MockOutputCapturingProcessRunner([0, 0], outputs: ['php' . PHP_EOL]);
+ $runner = new MockOutputCapturingProcessRunner([0, 0, 0], outputs: [$this->composeConfigWithWorkingDir(), 'php' . PHP_EOL]);
$plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
$plugin->activate($composer, $io);
$plugin->onScript(new ScriptEvent('test', $composer, $io));
self::assertSame([
+ [
+ 'docker',
+ 'compose',
+ '--file',
+ 'docker-compose.yaml',
+ '--project-directory',
+ '.',
+ 'config',
+ '--format',
+ 'json',
+ ],
[
'docker',
'compose',
@@ -361,6 +382,8 @@ public function testExecModeSkipsAutoUpWhenServiceIsAlreadyRunning(): void
'.',
'exec',
'-T',
+ '--workdir',
+ '/usr/src/app',
'--env',
'DOCKER_COMPOSER_INSIDE=1',
'php',
@@ -380,16 +403,17 @@ public function testExecModeRunsAutoUpWhenServiceIsNotRunning(): void
['test' => ['host-command']],
['docker-composer' => ['service' => 'php']],
);
- $runner = new MockOutputCapturingProcessRunner([0, 0, 0], outputs: ['']);
+ $runner = new MockOutputCapturingProcessRunner([0, 0, 0, 0], outputs: [$this->composeConfigWithWorkingDir(), '']);
$plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
$plugin->activate($composer, $io);
$plugin->onScript(new ScriptEvent('test', $composer, $io));
- self::assertSame(['ps', 'up', 'exec'], [
+ self::assertSame(['config', 'ps', 'up', 'exec'], [
$runner->commands[0][2],
$runner->commands[1][2],
$runner->commands[2][2],
+ $runner->commands[3][2],
]);
}
@@ -399,16 +423,17 @@ public function testExecModeRunsAutoUpWhenRunningServiceCheckFails(): void
['test' => ['host-command']],
['docker-composer' => ['service' => 'php']],
);
- $runner = new MockOutputCapturingProcessRunner([7, 0, 0], outputs: ['']);
+ $runner = new MockOutputCapturingProcessRunner([0, 7, 0, 0], outputs: [$this->composeConfigWithWorkingDir(), '']);
$plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
$plugin->activate($composer, $io);
$plugin->onScript(new ScriptEvent('test', $composer, $io));
- self::assertSame(['ps', 'up', 'exec'], [
+ self::assertSame(['config', 'ps', 'up', 'exec'], [
$runner->commands[0][2],
$runner->commands[1][2],
$runner->commands[2][2],
+ $runner->commands[3][2],
]);
}
@@ -618,6 +643,57 @@ public function testRedirectsInstallCommandBeforeHostExecution(): void
self::assertStringContainsString('Running composer install in Docker Compose service php.', $io->getOutput());
}
+ public function testRedirectedCommandUsesActiveHostWorkingDirectoryMapping(): void
+ {
+ $previousCwd = getcwd();
+ self::assertIsString($previousCwd);
+
+ $projectRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'docker-composer-' . bin2hex(random_bytes(8));
+ $packageDirectory = $projectRoot . DIRECTORY_SEPARATOR . 'packages' . DIRECTORY_SEPARATOR . 'demo';
+ self::assertTrue(mkdir($packageDirectory, 0777, true));
+ $hostMountedDirectory = realpath($projectRoot);
+ $hostPackageDirectory = realpath($packageDirectory);
+ self::assertIsString($hostMountedDirectory);
+ self::assertIsString($hostPackageDirectory);
+
+ try {
+ self::assertTrue(chdir($hostMountedDirectory));
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => [
+ 'service' => 'php',
+ ],
+ ]);
+ $runner = new MockOutputCapturingProcessRunner(
+ [0, 0, 0],
+ outputs: [
+ $this->composeConfigWithVolumes([
+ ['type' => 'bind', 'source' => $hostMountedDirectory, 'target' => '/workspace'],
+ ]),
+ 'php' . PHP_EOL,
+ ],
+ );
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $insidePath = $hostPackageDirectory . DIRECTORY_SEPARATOR . 'local-package';
+ $siblingPath = $hostMountedDirectory . DIRECTORY_SEPARATOR . 'packages' . DIRECTORY_SEPARATOR . 'sibling-package';
+ $input = new ArgvInput(['composer', 'require', $insidePath, $siblingPath]);
+ $input->setInteractive(false);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'require');
+
+ self::assertTrue(chdir($hostPackageDirectory));
+ $plugin->activate($composer, $io);
+ $this->assertCommandExecutionStops($plugin, $event);
+
+ self::assertSame('/workspace/packages/demo', $runner->commands[2][5]);
+ self::assertSame('/workspace/packages/demo/local-package', $runner->commands[2][11]);
+ self::assertSame($siblingPath, $runner->commands[2][12]);
+ } finally {
+ chdir($previousCwd);
+ rmdir($packageDirectory);
+ rmdir(dirname($packageDirectory));
+ rmdir($projectRoot);
+ }
+ }
+
public function testRedirectsDependencyCommandsBeforeHostExecution(): void
{
[$composer, $io] = $this->createComposer([], [
@@ -799,7 +875,7 @@ public function testCommandProcessRunnerRequiresActivationContext(): void
$method = new \ReflectionMethod(DockerComposerPlugin::class, 'getProcessRunnerForCommand');
$this->expectException(ScriptExecutionException::class);
- $this->expectExceptionMessage('Docker Composer plugin was not activated.');
+ $this->expectExceptionMessage('Docker-Composer plugin was not activated.');
$method->invoke($plugin);
}
@@ -1078,6 +1154,31 @@ public function testDisableEnvironmentVariableFallsThrough(): void
self::assertSame([], $runner->commands);
}
+ private function composeConfigWithWorkingDir(): string
+ {
+ return json_encode([
+ 'services' => [
+ 'php' => [
+ 'working_dir' => '/usr/src/app',
+ ],
+ ],
+ ], JSON_THROW_ON_ERROR);
+ }
+
+ /**
+ * @param list> $volumes
+ */
+ private function composeConfigWithVolumes(array $volumes): string
+ {
+ return json_encode([
+ 'services' => [
+ 'php' => [
+ 'volumes' => $volumes,
+ ],
+ ],
+ ], JSON_THROW_ON_ERROR);
+ }
+
private function assertScriptExecutionFails(DockerComposerPlugin $plugin, ScriptEvent $event): ScriptExecutionException
{
try {
diff --git a/tests/Unit/Laravel/ConfigTest.php b/tests/Unit/Laravel/ConfigTest.php
new file mode 100644
index 0000000..e0bcc03
--- /dev/null
+++ b/tests/Unit/Laravel/ConfigTest.php
@@ -0,0 +1,242 @@
+isEnabled());
+ self::assertSame(DockerComposeOptions::MODE_EXEC, $config->getMode());
+ self::assertSame([], $config->getComposeFiles());
+ self::assertNull($config->getProjectDirectory());
+ self::assertNull($config->getWorkdir());
+ self::assertFalse($config->excludes(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate'])));
+ self::assertNull($config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate'])));
+ }
+
+ public function testParsesLaravelConfig(): void
+ {
+ $config = Config::fromArray([
+ 'enabled' => 'true',
+ 'service' => 'php',
+ 'mode' => 'run',
+ 'compose_files' => 'docker-compose.yaml',
+ 'project_directory' => '.',
+ 'workdir' => '/usr/src/app',
+ 'exclude' => ['queue:work', ExcludedSignatureCommand::class],
+ 'service_mapping' => [
+ 'php-tools' => [
+ 'config:cache',
+ ExampleCommand::class,
+ SignatureCommand::class,
+ ':scripts/task.php',
+ ],
+ ],
+ ]);
+
+ self::assertTrue($config->isEnabled());
+ self::assertSame('php', $config->getService());
+ self::assertSame('run', $config->getMode());
+ self::assertSame(['docker-compose.yaml'], $config->getComposeFiles());
+ self::assertSame('.', $config->getProjectDirectory());
+ self::assertSame('/usr/src/app', $config->getWorkdir());
+ self::assertTrue($config->excludes(ConsoleEntry::artisan('queue:work', null, ['artisan', 'queue:work'])));
+ self::assertTrue($config->excludes(ConsoleEntry::artisan('excluded:run', null, ['artisan', 'excluded:run'])));
+ self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan('config:cache', null, ['artisan', 'config:cache']))?->getService());
+ self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan(null, ExampleCommand::class, ['artisan', 'example']))?->getService());
+ self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan('signature:run', null, ['artisan', 'signature:run']))?->getService());
+ self::assertSame('php-tools', $config->forEntry(ConsoleEntry::script(':scripts/task.php', ['scripts/task.php']))?->getService());
+ }
+
+ public function testAcceptsEnabledForms(): void
+ {
+ /** @var list $cases */
+ $cases = [
+ [true, true],
+ [false, false],
+ [1, true],
+ [0, false],
+ ['true', true],
+ ['false', false],
+ ['yes', true],
+ ['no', false],
+ ['on', true],
+ ['off', false],
+ ['1', true],
+ ['0', false],
+ ];
+
+ foreach ($cases as [$value, $expected]) {
+ self::assertSame($expected, Config::fromArray(['enabled' => $value])->isEnabled());
+ }
+ }
+
+ public function testRejectsInvalidEnabledValues(): void
+ {
+ $this->assertInvalidConfig(['enabled' => 2], 'docker_composer.enabled must be a boolean.');
+ $this->assertInvalidConfig(['enabled' => 'maybe'], 'docker_composer.enabled must be a boolean.');
+ $this->assertInvalidConfig(['enabled' => []], 'docker_composer.enabled must be a boolean.');
+ }
+
+ public function testDefaultServiceAppliesWhenNoMappingMatches(): void
+ {
+ $config = Config::fromArray([
+ 'enabled' => true,
+ 'service' => 'php',
+ ]);
+
+ self::assertSame('php', $config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']))?->getService());
+ }
+
+ public function testMissingServiceLeavesEntryUnconfigured(): void
+ {
+ $config = Config::fromArray([
+ 'enabled' => true,
+ ]);
+
+ self::assertNull($config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate'])));
+
+ $this->expectException(LogicException::class);
+ $this->expectExceptionMessage('Docker Compose service is not configured.');
+
+ $config->getService();
+ }
+
+ public function testRejectsUnknownKeys(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('docker_composer contains unknown key "unknown".');
+
+ Config::fromArray(['unknown' => true]);
+ }
+
+ public function testRejectsNonStringTopLevelKeys(): void
+ {
+ $this->assertInvalidConfig([0 => 'invalid'], 'docker_composer must be an array with string keys.');
+ }
+
+ public function testRejectsInvalidOptionalStrings(): void
+ {
+ foreach (['service', 'project_directory', 'workdir'] as $key) {
+ $this->assertInvalidConfig([$key => ''], sprintf('docker_composer.%s must be a non-empty string.', $key));
+ $this->assertInvalidConfig([$key => false], sprintf('docker_composer.%s must be a non-empty string.', $key));
+ }
+ }
+
+ public function testRejectsInvalidModeAndLists(): void
+ {
+ $this->assertInvalidConfig(['mode' => 'invalid'], 'docker_composer.mode must be "exec" or "run".');
+ $this->assertInvalidConfig(['mode' => false], 'docker_composer.mode must be "exec" or "run".');
+ $this->assertInvalidConfig(['compose_files' => ''], 'docker_composer.compose_files must contain non-empty strings.');
+ $this->assertInvalidConfig(['compose_files' => false], 'docker_composer.compose_files must be a list of strings.');
+ $this->assertInvalidConfig(['compose_files' => ['path' => 'docker-compose.yaml']], 'docker_composer.compose_files must be a list of strings.');
+ $this->assertInvalidConfig(['compose_files' => ['']], 'docker_composer.compose_files must contain only non-empty strings.');
+ $this->assertInvalidConfig(['compose_files' => [false]], 'docker_composer.compose_files must contain only non-empty strings.');
+ $this->assertInvalidConfig(['exclude' => false], 'docker_composer.exclude must be a list of strings.');
+ $this->assertInvalidConfig(['exclude' => ['command' => 'migrate']], 'docker_composer.exclude must be a list of strings.');
+ $this->assertInvalidConfig(['exclude' => ['']], 'docker_composer.exclude must contain only non-empty strings.');
+ $this->assertInvalidConfig(['exclude' => [false]], 'docker_composer.exclude must contain only non-empty strings.');
+ }
+
+ public function testAcceptsComposeFileListAndEmptyServiceMapping(): void
+ {
+ $config = Config::fromArray([
+ 'compose_files' => ['docker-compose.yaml', 'docker-compose.override.yaml'],
+ 'service_mapping' => [],
+ ]);
+
+ self::assertSame(['docker-compose.yaml', 'docker-compose.override.yaml'], $config->getComposeFiles());
+ self::assertNull($config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate'])));
+ }
+
+ public function testRejectsInvalidMappingShapes(): void
+ {
+ $this->assertInvalidConfig(['service_mapping' => 'php'], 'docker_composer.service_mapping must be an object of strings or lists of strings.');
+ $this->assertInvalidConfig(['service_mapping' => ['php']], 'docker_composer.service_mapping must be an object of strings or lists of strings.');
+ $this->assertInvalidConfig(['service_mapping' => ['' => 'migrate']], 'docker_composer.service_mapping must use non-empty string keys.');
+ $this->assertInvalidConfig(['service_mapping' => [1 => 'migrate']], 'docker_composer.service_mapping must use non-empty string keys.');
+ $this->assertInvalidConfig(['service_mapping' => ['php' => []]], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.');
+ $this->assertInvalidConfig(['service_mapping' => ['php' => ['migrate' => 'migrate']]], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.');
+ $this->assertInvalidConfig(['service_mapping' => ['php' => false]], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.');
+ $this->assertInvalidConfig(['service_mapping' => ['php' => '']], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.');
+ $this->assertInvalidConfig(['service_mapping' => ['php' => ['']]], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.');
+ }
+
+ public function testExpandsNamePropertyAndAcceptsDuplicateSameServiceMapping(): void
+ {
+ $config = Config::fromArray([
+ 'service_mapping' => [
+ 'php-tools' => [
+ NamedCommand::class,
+ 'migrate',
+ 'migrate',
+ ],
+ ],
+ ]);
+
+ self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan('named:run', null, ['artisan', 'named:run']))?->getService());
+ self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']))?->getService());
+ }
+
+ public function testRejectsDuplicateMappingToDifferentServices(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('docker_composer.service_mapping must not assign an entry to multiple services.');
+
+ Config::fromArray([
+ 'service_mapping' => [
+ 'php' => 'migrate',
+ 'worker' => 'migrate',
+ ],
+ ]);
+ }
+
+ /**
+ * @param array $raw
+ */
+ private function assertInvalidConfig(array $raw, string $message): void
+ {
+ try {
+ Config::fromArray($raw);
+ self::fail(sprintf('Expected invalid config exception for message "%s".', $message));
+ } catch (InvalidArgumentException $exception) {
+ self::assertSame($message, $exception->getMessage());
+ }
+ }
+}
+
+final class ExampleCommand {}
+
+final class SignatureCommand
+{
+ protected string $signature = 'signature:run {argument?}';
+}
+
+final class ExcludedSignatureCommand
+{
+ protected string $signature = 'excluded:run';
+}
+
+final class NamedCommand
+{
+ protected string $name = 'named:run';
+}
diff --git a/tests/Unit/Laravel/ConsoleEntryTest.php b/tests/Unit/Laravel/ConsoleEntryTest.php
new file mode 100644
index 0000000..565b247
--- /dev/null
+++ b/tests/Unit/Laravel/ConsoleEntryTest.php
@@ -0,0 +1,73 @@
+getNames());
+ self::assertSame(['artisan', 'config:cache'], $entry->getArguments());
+ self::assertSame('artisan config:cache', $entry->getDisplayName());
+ }
+
+ public function testCreatesFallbackArtisanDisplayName(): void
+ {
+ $entry = ConsoleEntry::artisan(null, null, ['/host/app/artisan']);
+
+ self::assertSame([], $entry->getNames());
+ self::assertSame('/host/app/artisan', $entry->getDisplayName());
+ }
+
+ public function testIgnoresEmptyArtisanNames(): void
+ {
+ $method = new \ReflectionMethod(ConsoleEntry::class, 'artisan');
+ $entry = $method->invoke(null, '', '', ['artisan']);
+
+ self::assertInstanceOf(ConsoleEntry::class, $entry);
+ self::assertSame([], $entry->getNames());
+ self::assertSame('artisan', $entry->getDisplayName());
+ }
+
+ public function testDeduplicatesArtisanNames(): void
+ {
+ $entry = ConsoleEntry::artisan(DuplicateConsoleEntryCommand::class, DuplicateConsoleEntryCommand::class, ['artisan']);
+
+ self::assertSame([DuplicateConsoleEntryCommand::class], $entry->getNames());
+ }
+
+ public function testCreatesRelativeScriptName(): void
+ {
+ self::assertSame(':', ConsoleEntry::scriptName('/host/app', '/host/app'));
+ self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('/host/app/scripts/task.php', '/host/app'));
+ self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('scripts/task.php', '/host/app'));
+
+ $entry = ConsoleEntry::script(':scripts/task.php', ['scripts/task.php']);
+
+ self::assertSame(':scripts/task.php', $entry->getDisplayName());
+ }
+
+ public function testNormalizesWindowsScriptNames(): void
+ {
+ self::assertSame(':', ConsoleEntry::scriptName('C:\\host\\app\\', 'C:\\host\\app'));
+ self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('C:\\host\\app\\scripts\\task.php', 'C:\\host\\app'));
+ self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('scripts\\task.php', 'C:\\host\\app'));
+ }
+}
+
+final class ExampleConsoleEntryCommand {}
+
+final class DuplicateConsoleEntryCommand {}
diff --git a/tests/Unit/Laravel/RedirectorTest.php b/tests/Unit/Laravel/RedirectorTest.php
new file mode 100644
index 0000000..4ff580c
--- /dev/null
+++ b/tests/Unit/Laravel/RedirectorTest.php
@@ -0,0 +1,243 @@
+ true,
+ 'service' => 'php',
+ 'workdir' => '/usr/src/app',
+ 'service_mapping' => [
+ 'php-tools' => 'config:cache',
+ ],
+ ]);
+ $runner = new MockProcessRunner();
+ $builder = new DockerComposeCommandBuilder();
+ $errorOutput = $this->createErrorOutput();
+ $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput);
+
+ $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('config:cache', null, ['/host/app/artisan', 'config:cache']), '/host/app', false);
+
+ self::assertSame(0, $exitCode);
+ self::assertSame("docker-composer: Running artisan config:cache in Docker Compose service php-tools.\n", $this->readErrorOutput($errorOutput));
+ self::assertSame([
+ ['docker', 'compose', 'up', '-d', 'php-tools'],
+ [
+ 'docker',
+ 'compose',
+ 'exec',
+ '-T',
+ '--workdir',
+ '/usr/src/app',
+ '--env',
+ 'DOCKER_COMPOSER_INSIDE=1',
+ 'php-tools',
+ '/usr/src/app/artisan',
+ 'config:cache',
+ ],
+ ], $runner->commands);
+ }
+
+ public function testRedirectNoticeKeepsEscapedConsoleTagsReadable(): void
+ {
+ $config = Config::fromArray([
+ 'enabled' => true,
+ 'service' => 'php',
+ ]);
+ $runner = new MockProcessRunner();
+ $builder = new DockerComposeCommandBuilder();
+ $errorOutput = $this->createErrorOutput();
+ $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput);
+
+ $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('bad', null, ['artisan', 'bad']), '/host/app', false);
+
+ self::assertSame(0, $exitCode);
+ self::assertSame("docker-composer: Running artisan bad in Docker Compose service php.\n", $this->readErrorOutput($errorOutput));
+ }
+
+ public function testReturnsNullWhenDisabledInsideContainerExcludedOrUnconfigured(): void
+ {
+ $entry = ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']);
+ $builder = new DockerComposeCommandBuilder();
+ $errorOutput = $this->createErrorOutput();
+
+ $disabledRunner = new MockProcessRunner();
+ $disabled = Config::fromArray(['enabled' => false, 'service' => 'php']);
+ self::assertNull((new Redirector(new DockerComposeRunner($disabledRunner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput))->redirect($disabled, $entry, '/host/app', false));
+ self::assertSame([], $disabledRunner->commands);
+
+ $defaultOutputRunner = new MockProcessRunner();
+ self::assertNull((new Redirector(new DockerComposeRunner($defaultOutputRunner, $builder), $builder, new MockContainerDetector(false)))->redirect($disabled, $entry, '/host/app', false));
+ self::assertSame([], $defaultOutputRunner->commands);
+
+ $insideRunner = new MockProcessRunner();
+ $enabled = Config::fromArray(['enabled' => true, 'service' => 'php']);
+ self::assertNull((new Redirector(new DockerComposeRunner($insideRunner, $builder), $builder, new MockContainerDetector(true), errorOutput: $errorOutput))->redirect($enabled, $entry, '/host/app', false));
+ self::assertSame([], $insideRunner->commands);
+
+ $excludedRunner = new MockProcessRunner();
+ $excluded = Config::fromArray(['enabled' => true, 'service' => 'php', 'exclude' => ['migrate']]);
+ self::assertNull((new Redirector(new DockerComposeRunner($excludedRunner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput))->redirect($excluded, $entry, '/host/app', false));
+ self::assertSame([], $excludedRunner->commands);
+
+ $unconfiguredRunner = new MockProcessRunner();
+ $unconfigured = Config::fromArray(['enabled' => true]);
+ self::assertNull((new Redirector(new DockerComposeRunner($unconfiguredRunner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput))->redirect($unconfigured, $entry, '/host/app', false));
+ self::assertSame([], $unconfiguredRunner->commands);
+ self::assertSame('', $this->readErrorOutput($errorOutput));
+ }
+
+ public function testRedirectSkipsEntrypointAbsolutizingWithoutPathMapping(): void
+ {
+ $projectRoot = $this->createProjectRootWithArtisan();
+ $config = Config::fromArray([
+ 'enabled' => true,
+ 'service' => 'php',
+ ]);
+ $runner = new MockProcessRunner();
+ $builder = new DockerComposeCommandBuilder();
+ $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), errorOutput: $this->createErrorOutput());
+
+ try {
+ $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']), $projectRoot, false);
+ } finally {
+ $this->removeProjectRootWithArtisan($projectRoot);
+ }
+
+ self::assertSame(0, $exitCode);
+ self::assertSame('artisan', $runner->commands[1][7]);
+ }
+
+ public function testRedirectAbsolutizesAndTranslatesEntrypointWithPathMapping(): void
+ {
+ $projectRoot = $this->createProjectRootWithArtisan();
+ $config = Config::fromArray([
+ 'enabled' => true,
+ 'service' => 'php',
+ ]);
+ $configOutput = json_encode([
+ 'services' => [
+ 'php' => [
+ 'volumes' => [
+ ['type' => 'bind', 'source' => $projectRoot, 'target' => '/usr/src/app'],
+ ],
+ ],
+ ],
+ ], JSON_THROW_ON_ERROR);
+ $runner = new MockOutputCapturingProcessRunner([0, 0, 0], outputs: [$configOutput, "php\n"]);
+ $builder = new DockerComposeCommandBuilder();
+ $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), $runner, errorOutput: $this->createErrorOutput());
+
+ try {
+ $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']), $projectRoot, false);
+ } finally {
+ $this->removeProjectRootWithArtisan($projectRoot);
+ }
+
+ self::assertSame(0, $exitCode);
+ self::assertSame('/usr/src/app/artisan', $runner->commands[2][9]);
+ }
+
+ #[BackupGlobals(true)]
+ public function testEnvironmentDisableReturnsNull(): void
+ {
+ putenv('DOCKER_COMPOSER_DISABLE=1');
+
+ try {
+ $config = Config::fromArray([
+ 'enabled' => true,
+ 'service' => 'php',
+ ]);
+ $runner = new MockProcessRunner();
+ $builder = new DockerComposeCommandBuilder();
+ $errorOutput = $this->createErrorOutput();
+ $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput);
+
+ self::assertNull($redirector->redirect($config, ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']), '/host/app', false));
+ self::assertSame([], $runner->commands);
+ self::assertSame('', $this->readErrorOutput($errorOutput));
+ } finally {
+ putenv('DOCKER_COMPOSER_DISABLE');
+ }
+ }
+
+ /**
+ * @return resource
+ */
+ private function createErrorOutput()
+ {
+ $errorOutput = fopen('php://temp', 'w+');
+ if ($errorOutput === false) {
+ throw new \RuntimeException('Unable to create temporary error output stream.');
+ }
+
+ return $errorOutput;
+ }
+
+ /**
+ * @param resource $errorOutput
+ */
+ private function readErrorOutput($errorOutput): string
+ {
+ rewind($errorOutput);
+
+ return stream_get_contents($errorOutput) ?: '';
+ }
+
+ private function createProjectRootWithArtisan(): string
+ {
+ $projectRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
+ . DIRECTORY_SEPARATOR
+ . 'docker-composer-laravel-'
+ . bin2hex(random_bytes(8));
+ if (! mkdir($projectRoot, 0777, true) && ! is_dir($projectRoot)) {
+ throw new \RuntimeException(sprintf('Unable to create test directory "%s".', $projectRoot));
+ }
+
+ file_put_contents($projectRoot . DIRECTORY_SEPARATOR . 'artisan', '');
+
+ return $projectRoot;
+ }
+
+ private function removeProjectRootWithArtisan(string $projectRoot): void
+ {
+ @unlink($projectRoot . DIRECTORY_SEPARATOR . 'artisan');
+ @rmdir($projectRoot);
+ }
+}
diff --git a/tests/Unit/Laravel/ServiceProviderTest.php b/tests/Unit/Laravel/ServiceProviderTest.php
new file mode 100644
index 0000000..b15d276
--- /dev/null
+++ b/tests/Unit/Laravel/ServiceProviderTest.php
@@ -0,0 +1,827 @@
+ [
+ 'enabled' => true,
+ 'service' => 'php',
+ ],
+ ]);
+ $provider = new ServiceProvider(new FakeLaravelApplication($config));
+
+ $provider->register();
+
+ $merged = $config->get('docker_composer');
+
+ self::assertIsArray($merged);
+ self::assertTrue($merged['enabled']);
+ self::assertSame('php', $merged['service']);
+ self::assertSame('exec', $merged['mode']);
+ self::assertSame([], $merged['compose_files']);
+ self::assertNull($merged['project_directory']);
+ self::assertNull($merged['workdir']);
+ self::assertSame([], $merged['exclude']);
+ self::assertSame([], $merged['service_mapping']);
+ }
+
+ public function testBootPublishesConfigAndReturnsOutsideConsole(): void
+ {
+ $config = new FakeLaravelConfig();
+ $app = new FakeLaravelApplication($config, runningInConsole: false, basePath: '/host/app');
+ $provider = new ServiceProvider($app);
+
+ $provider->boot();
+
+ $source = dirname(__DIR__, 3) . '/config/docker_composer.php';
+
+ self::assertSame(
+ [$source => '/host/app/config/docker_composer.php'],
+ IlluminateServiceProvider::pathsToPublish(ServiceProvider::class, 'docker-composer-config'),
+ );
+ self::assertSame([], $app->makeCalls);
+ }
+
+ public function testBootReturnsWhenServerArgumentsAreUnavailable(): void
+ {
+ $this->assertBootReturnsBeforeResolvingServices(null, hasServerArguments: false);
+ $this->assertBootReturnsBeforeResolvingServices([]);
+ $this->assertBootReturnsBeforeResolvingServices('artisan');
+ $this->assertBootReturnsBeforeResolvingServices([1 => 'artisan']);
+ $this->assertBootReturnsBeforeResolvingServices(['artisan', 1]);
+ }
+
+ public function testBootsNonArtisanScriptWithDisabledConfig(): void
+ {
+ $config = new FakeLaravelConfig([
+ 'docker_composer' => [
+ 'enabled' => false,
+ 'service' => 'php',
+ ],
+ ]);
+ $app = new FakeLaravelApplication($config, runningInConsole: true, basePath: '/host/app');
+ $exitCodes = [];
+ $provider = new ServiceProvider($app, function (int $exitCode) use (&$exitCodes): void {
+ $exitCodes[] = $exitCode;
+ });
+
+ $this->bootWithArguments($provider, ['/host/app/scripts/task.php', '--flag']);
+
+ self::assertSame(['config'], $app->makeCalls);
+ self::assertSame([], $exitCodes);
+ }
+
+ public function testBootReturnsWhenArtisanEventsBindingIsUnavailable(): void
+ {
+ $config = new FakeLaravelConfig([
+ 'docker_composer' => 'invalid',
+ ]);
+ $app = new FakeLaravelApplication($config, runningInConsole: true);
+ $provider = new ServiceProvider($app);
+
+ $this->bootWithArguments($provider, ['artisan', 'migrate']);
+
+ self::assertSame(['config', 'events'], $app->makeCalls);
+ }
+
+ #[DataProvider('invalidEventDispatcherExamples')]
+ public function testBootReturnsWhenArtisanEventsDispatcherIsInvalid(mixed $events): void
+ {
+ $config = new FakeLaravelConfig([
+ 'docker_composer' => [
+ 'enabled' => false,
+ 'service' => 'php',
+ ],
+ ]);
+ $app = new FakeLaravelApplication($config, runningInConsole: true, events: $events, bindEvents: true);
+ $provider = new ServiceProvider($app);
+
+ $this->bootWithArguments($provider, ['artisan', 'migrate']);
+
+ self::assertSame(['config', 'events'], $app->makeCalls);
+ }
+
+ public function testBootRegistersAndInvokesCommandStartingListener(): void
+ {
+ $events = new FakeEventsDispatcher();
+ $kernel = new FakeLaravelKernel([
+ 'migrate' => new FakeListedArtisanCommand(),
+ ]);
+ $config = new FakeLaravelConfig([
+ 'docker_composer' => [
+ 'enabled' => false,
+ 'service' => 'php',
+ ],
+ ]);
+ $app = new FakeLaravelApplication($config, runningInConsole: true, events: $events, bindEvents: true, kernel: $kernel, bindKernel: true);
+ $exitCodes = [];
+ $provider = new ServiceProvider($app, function (int $exitCode) use (&$exitCodes): void {
+ $exitCodes[] = $exitCode;
+ });
+
+ $this->bootWithArguments($provider, ['/host/app/artisan', 'migrate']);
+
+ $listeners = $events->listeners[CommandStarting::class] ?? [];
+ self::assertCount(1, $listeners);
+
+ $listeners[0](new CommandStarting('migrate', new ArrayInput([]), new NullOutput()));
+
+ self::assertSame(1, $kernel->allCalls);
+ self::assertSame([], $exitCodes);
+ }
+
+ public function testBootRegistersAndInvokesArtisanStartingCallback(): void
+ {
+ $events = new FakeEventsDispatcher();
+ $config = new FakeLaravelConfig([
+ 'docker_composer' => [
+ 'enabled' => false,
+ 'service' => 'php',
+ ],
+ ]);
+ $app = new FakeLaravelApplication($config, runningInConsole: true, events: $events, bindEvents: true);
+ $exitCodes = [];
+ $provider = new ServiceProvider($app, function (int $exitCode) use (&$exitCodes): void {
+ $exitCodes[] = $exitCode;
+ });
+
+ $this->bootWithArguments($provider, ['/host/app/artisan', '--env=testing', 'migrate']);
+ $bootstrappers = $this->getArtisanBootstrappers();
+
+ self::assertCount(1, $bootstrappers);
+
+ $bootstrappers[0](new \stdClass());
+
+ self::assertSame([], $exitCodes);
+ }
+
+ /**
+ * @param list $arguments
+ */
+ #[DataProvider('commandNameArgumentExamples')]
+ public function testGetsCommandNameFromArguments(array $arguments, ?string $expected): void
+ {
+ $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig()));
+
+ self::assertSame($expected, $this->invokeProviderMethod($provider, 'getCommandNameFromArguments', [$arguments]));
+ }
+
+ #[DataProvider('eventCommandExamples')]
+ public function testGetsCommandNameFromCommandStartingEvent(string $command, ?string $expected): void
+ {
+ $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig()));
+ $event = new CommandStarting($command, new ArrayInput([]), new NullOutput());
+
+ self::assertSame($expected, $this->invokeProviderMethod($provider, 'getEventCommandName', [$event]));
+ }
+
+ public function testGetsNullCommandNameFromUnsetCommandStartingEvent(): void
+ {
+ $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig()));
+ $event = new CommandStarting('migrate', new ArrayInput([]), new NullOutput());
+ unset($event->command);
+
+ self::assertNull($this->invokeProviderMethod($provider, 'getEventCommandName', [$event]));
+ }
+
+ public function testResolvesArtisanCommandClassFromKernelCommandList(): void
+ {
+ $kernel = new FakeLaravelKernel([
+ 'migrate' => new FakeListedArtisanCommand(),
+ ]);
+ $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig(), kernel: $kernel, bindKernel: true));
+
+ self::assertSame(FakeListedArtisanCommand::class, $this->invokeProviderMethod($provider, 'resolveArtisanCommandClass', ['migrate']));
+ }
+
+ public function testResolvesArtisanCommandClassFromArtisanFind(): void
+ {
+ $artisan = new FakeArtisanApplication([
+ 'queue:work' => new FakeFoundArtisanCommand(),
+ ]);
+ $kernel = new FakeLaravelKernel([
+ 'migrate' => new FakeListedArtisanCommand(),
+ ], $artisan);
+ $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig(), kernel: $kernel, bindKernel: true));
+
+ self::assertSame(FakeFoundArtisanCommand::class, $this->invokeProviderMethod($provider, 'resolveArtisanCommandClass', ['queue:work']));
+ self::assertSame(1, $artisan->findCalls);
+ }
+
+ public function testArtisanCommandClassResolutionReturnsNullWhenNameIsMissing(): void
+ {
+ $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig()));
+
+ self::assertNull($this->invokeProviderMethod($provider, 'resolveArtisanCommandClass', [null]));
+ }
+
+ public function testArtisanCommandClassResolutionReturnsNullWhenKernelIsMissingOrInvalid(): void
+ {
+ $missingKernel = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig()));
+ $invalidKernel = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig(), kernel: 'invalid', bindKernel: true));
+
+ self::assertNull($this->invokeProviderMethod($missingKernel, 'resolveArtisanCommandClass', ['migrate']));
+ self::assertNull($this->invokeProviderMethod($invalidKernel, 'resolveArtisanCommandClass', ['migrate']));
+ }
+
+ public function testArtisanCommandClassResolutionReturnsNullWhenArtisanIsMissingOrInvalid(): void
+ {
+ $withoutArtisan = new ServiceProvider(new FakeLaravelApplication(
+ new FakeLaravelConfig(),
+ kernel: new FakeLaravelKernelWithoutArtisan(),
+ bindKernel: true,
+ ));
+ $nonObjectArtisan = new ServiceProvider(new FakeLaravelApplication(
+ new FakeLaravelConfig(),
+ kernel: new FakeLaravelKernel(artisan: 'invalid'),
+ bindKernel: true,
+ ));
+ $objectWithoutFind = new ServiceProvider(new FakeLaravelApplication(
+ new FakeLaravelConfig(),
+ kernel: new FakeLaravelKernel(artisan: new \stdClass()),
+ bindKernel: true,
+ ));
+ $nonObjectCommand = new ServiceProvider(new FakeLaravelApplication(
+ new FakeLaravelConfig(),
+ kernel: new FakeLaravelKernel(artisan: new FakeArtisanApplication(['migrate' => 'invalid'])),
+ bindKernel: true,
+ ));
+
+ self::assertNull($this->invokeProviderMethod($withoutArtisan, 'resolveArtisanCommandClass', ['migrate']));
+ self::assertNull($this->invokeProviderMethod($nonObjectArtisan, 'resolveArtisanCommandClass', ['migrate']));
+ self::assertNull($this->invokeProviderMethod($objectWithoutFind, 'resolveArtisanCommandClass', ['migrate']));
+ self::assertNull($this->invokeProviderMethod($nonObjectCommand, 'resolveArtisanCommandClass', ['migrate']));
+ }
+
+ public function testArtisanCommandClassResolutionReturnsNullWhenResolutionThrows(): void
+ {
+ $throwingKernel = new ServiceProvider(new FakeLaravelApplication(
+ new FakeLaravelConfig(),
+ kernel: new ThrowingLaravelKernel(),
+ bindKernel: true,
+ ));
+ $throwingArtisan = new ServiceProvider(new FakeLaravelApplication(
+ new FakeLaravelConfig(),
+ kernel: new FakeLaravelKernel(artisan: new FakeArtisanApplication(throws: true)),
+ bindKernel: true,
+ ));
+
+ self::assertNull($this->invokeProviderMethod($throwingKernel, 'resolveArtisanCommandClass', ['migrate']));
+ self::assertNull($this->invokeProviderMethod($throwingArtisan, 'resolveArtisanCommandClass', ['migrate']));
+ }
+
+ public function testExitIfRedirectedIgnoresNullExitCode(): void
+ {
+ $exitCodes = [];
+ $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig()), function (int $exitCode) use (&$exitCodes): void {
+ $exitCodes[] = $exitCode;
+ });
+
+ $this->invokeProviderMethod($provider, 'exitIfRedirected', [null]);
+
+ self::assertSame([], $exitCodes);
+ }
+
+ public function testExitIfRedirectedUsesInjectedTerminator(): void
+ {
+ $exitCodes = [];
+ $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig()), function (int $exitCode) use (&$exitCodes): void {
+ $exitCodes[] = $exitCode;
+ });
+
+ $this->invokeProviderMethod($provider, 'exitIfRedirected', [17]);
+
+ self::assertSame([17], $exitCodes);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function invalidEventDispatcherExamples(): iterable
+ {
+ yield 'scalar' => ['invalid'];
+ yield 'object without listen method' => [new \stdClass()];
+ }
+
+ /**
+ * @return iterable, 1: string|null}>
+ */
+ public static function commandNameArgumentExamples(): iterable
+ {
+ yield 'empty arguments' => [[], null];
+ yield 'only entrypoint' => [['artisan'], null];
+ yield 'argument separator' => [['artisan', '--', 'migrate'], null];
+ yield 'options before command' => [['artisan', '--env=testing', '-v', '', 'migrate'], 'migrate'];
+ yield 'first non-option argument' => [['artisan', 'about', '--json'], 'about'];
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function eventCommandExamples(): iterable
+ {
+ yield 'non-empty command' => ['migrate', 'migrate'];
+ yield 'empty command' => ['', null];
+ }
+
+ /**
+ * @param mixed $serverArguments
+ * The temporary `$_SERVER['argv']` value.
+ */
+ private function assertBootReturnsBeforeResolvingServices(mixed $serverArguments, bool $hasServerArguments = true): void
+ {
+ $this->withServerArguments($serverArguments, $hasServerArguments, function (): void {
+ $app = new FakeLaravelApplication(new FakeLaravelConfig(), runningInConsole: true);
+ $provider = new ServiceProvider($app);
+
+ $provider->boot();
+
+ self::assertSame([], $app->makeCalls);
+ });
+ }
+
+ /**
+ * @param list $arguments
+ * The temporary server arguments.
+ */
+ private function bootWithArguments(ServiceProvider $provider, array $arguments): void
+ {
+ $this->withServerArguments($arguments, true, function () use ($provider): void {
+ $provider->boot();
+ });
+ }
+
+ /**
+ * @param mixed $serverArguments
+ * The temporary `$_SERVER['argv']` value.
+ *
+ * @param Closure(): void $callback
+ * The callback to run with the temporary arguments.
+ */
+ private function withServerArguments(mixed $serverArguments, bool $hasServerArguments, Closure $callback): void
+ {
+ $hadOriginalArguments = array_key_exists('argv', $_SERVER);
+ $originalArguments = $_SERVER['argv'] ?? null;
+
+ try {
+ if ($hasServerArguments) {
+ $_SERVER['argv'] = $serverArguments;
+ } else {
+ unset($_SERVER['argv']);
+ }
+
+ $callback();
+ } finally {
+ if ($hadOriginalArguments) {
+ $_SERVER['argv'] = $originalArguments;
+ } else {
+ unset($_SERVER['argv']);
+ }
+ }
+ }
+
+ /**
+ * @param list $arguments
+ * The private method arguments.
+ */
+ private function invokeProviderMethod(ServiceProvider $provider, string $method, array $arguments = []): mixed
+ {
+ return (new ReflectionMethod($provider, $method))->invoke($provider, ...$arguments);
+ }
+
+ /**
+ * @return list
+ */
+ private function getArtisanBootstrappers(): array
+ {
+ $property = (new ReflectionClass(ArtisanApplication::class))->getProperty('bootstrappers');
+ $bootstrappers = $property->getValue();
+ self::assertIsArray($bootstrappers);
+
+ $callbacks = [];
+ foreach ($bootstrappers as $bootstrapper) {
+ self::assertInstanceOf(Closure::class, $bootstrapper);
+ $callbacks[] = $bootstrapper;
+ }
+
+ return $callbacks;
+ }
+}
+
+final class FakeLaravelApplication extends Container implements Application
+{
+ /**
+ * Stores resolved container keys.
+ *
+ * @var list
+ */
+ public array $makeCalls = [];
+
+ public function __construct(
+ FakeLaravelConfig $config,
+ private readonly bool $runningInConsole = false,
+ private readonly string $basePath = '/host/app',
+ mixed $events = null,
+ bool $bindEvents = false,
+ mixed $kernel = null,
+ bool $bindKernel = false,
+ ) {
+ $this->instance('config', $config);
+ if ($bindEvents) {
+ $this->instance('events', $events);
+ }
+
+ if ($bindKernel) {
+ $this->instance(Kernel::class, $kernel);
+ }
+ }
+
+ /**
+ * @param array $parameters
+ */
+ public function make($abstract, array $parameters = [])
+ {
+ $this->makeCalls[] = is_string($abstract) ? $abstract : get_debug_type($abstract);
+
+ return parent::make($abstract, $parameters);
+ }
+
+ public function version(): string
+ {
+ return 'testing';
+ }
+
+ public function basePath($path = ''): string
+ {
+ return $this->path($this->basePath, $path);
+ }
+
+ public function bootstrapPath($path = ''): string
+ {
+ return $this->path($this->basePath . '/bootstrap', $path);
+ }
+
+ public function configPath($path = ''): string
+ {
+ return $this->path($this->basePath . '/config', $path);
+ }
+
+ public function databasePath($path = ''): string
+ {
+ return $this->path($this->basePath . '/database', $path);
+ }
+
+ public function langPath($path = ''): string
+ {
+ return $this->path($this->basePath . '/lang', $path);
+ }
+
+ public function publicPath($path = ''): string
+ {
+ return $this->path($this->basePath . '/public', $path);
+ }
+
+ public function resourcePath($path = ''): string
+ {
+ return $this->path($this->basePath . '/resources', $path);
+ }
+
+ public function storagePath($path = ''): string
+ {
+ return $this->path($this->basePath . '/storage', $path);
+ }
+
+ /**
+ * @param string|array ...$environments
+ */
+ public function environment(...$environments): string|bool
+ {
+ if ($environments === []) {
+ return 'testing';
+ }
+
+ $expected = [];
+ foreach ($environments as $environment) {
+ foreach ((array) $environment as $name) {
+ $expected[] = $name;
+ }
+ }
+
+ return in_array('testing', $expected, true);
+ }
+
+ public function runningInConsole(): bool
+ {
+ return $this->runningInConsole;
+ }
+
+ public function runningUnitTests(): bool
+ {
+ return true;
+ }
+
+ public function hasDebugModeEnabled(): bool
+ {
+ return false;
+ }
+
+ public function maintenanceMode(): MaintenanceMode
+ {
+ return new FakeMaintenanceMode();
+ }
+
+ public function isDownForMaintenance(): bool
+ {
+ return false;
+ }
+
+ public function registerConfiguredProviders(): void {}
+
+ public function register($provider, $force = false): IlluminateServiceProvider
+ {
+ if ($provider instanceof IlluminateServiceProvider) {
+ return $provider;
+ }
+
+ if (is_string($provider) && is_a($provider, IlluminateServiceProvider::class, true)) {
+ return new $provider($this);
+ }
+
+ throw new \InvalidArgumentException('Expected a service provider instance or class name.');
+ }
+
+ public function registerDeferredProvider($provider, $service = null): void {}
+
+ public function resolveProvider($provider): IlluminateServiceProvider
+ {
+ if (is_string($provider) && is_a($provider, IlluminateServiceProvider::class, true)) {
+ return new $provider($this);
+ }
+
+ throw new \InvalidArgumentException('Expected a service provider class name.');
+ }
+
+ public function boot(): void {}
+
+ public function booting($callback): void {}
+
+ public function booted($callback): void {}
+
+ /**
+ * @param array $bootstrappers
+ */
+ public function bootstrapWith(array $bootstrappers): void {}
+
+ public function getLocale(): string
+ {
+ return 'en';
+ }
+
+ public function getNamespace(): string
+ {
+ return 'Tests\\';
+ }
+
+ /**
+ * @return list
+ */
+ public function getProviders($provider): array
+ {
+ return [];
+ }
+
+ public function hasBeenBootstrapped(): bool
+ {
+ return false;
+ }
+
+ public function loadDeferredProviders(): void {}
+
+ public function setLocale($locale): void {}
+
+ public function shouldSkipMiddleware(): bool
+ {
+ return false;
+ }
+
+ public function terminating($callback): Application
+ {
+ return $this;
+ }
+
+ public function terminate(): void {}
+
+ private function path(string $base, mixed $path): string
+ {
+ $path = is_string($path) ? $path : '';
+
+ return $base . ($path === '' ? '' : '/' . $path);
+ }
+}
+
+final class FakeMaintenanceMode implements MaintenanceMode
+{
+ /**
+ * @param array $payload
+ */
+ public function activate(array $payload): void {}
+
+ public function deactivate(): void {}
+
+ public function active(): bool
+ {
+ return false;
+ }
+
+ /**
+ * @return array
+ */
+ public function data(): array
+ {
+ return [];
+ }
+}
+
+final class FakeLaravelConfig
+{
+ /**
+ * Stores fake Laravel configuration values.
+ *
+ * @param array $values
+ * The initial config values.
+ */
+ public function __construct(private array $values = []) {}
+
+ public function get(string $key, mixed $default = null): mixed
+ {
+ return $this->values[$key] ?? $default;
+ }
+
+ public function set(string $key, mixed $value): void
+ {
+ $this->values[$key] = $value;
+ }
+}
+
+final class FakeEventsDispatcher
+{
+ /**
+ * Stores listeners by event class.
+ *
+ * @var array>
+ */
+ public array $listeners = [];
+
+ public function listen(string $event, Closure $listener): void
+ {
+ $this->listeners[$event] ??= [];
+ $this->listeners[$event][] = $listener;
+ }
+}
+
+abstract class FakeLaravelKernelBase
+{
+ /**
+ * @return array
+ */
+ abstract public function all(): array;
+}
+
+final class FakeLaravelKernel extends FakeLaravelKernelBase
+{
+ public int $allCalls = 0;
+
+ /**
+ * @param array $commands
+ */
+ public function __construct(private readonly array $commands = [], private readonly mixed $artisan = null) {}
+
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ $this->allCalls++;
+
+ return $this->commands;
+ }
+
+ public function getArtisan(): mixed
+ {
+ return $this->artisan;
+ }
+}
+
+final class FakeLaravelKernelWithoutArtisan extends FakeLaravelKernelBase
+{
+ /**
+ * @param array $commands
+ */
+ public function __construct(private readonly array $commands = []) {}
+
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ return $this->commands;
+ }
+}
+
+final class ThrowingLaravelKernel extends FakeLaravelKernelBase
+{
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ throw new \RuntimeException('Kernel command resolution failed.');
+ }
+}
+
+final class FakeArtisanApplication
+{
+ public int $findCalls = 0;
+
+ /**
+ * @param array $commands
+ */
+ public function __construct(private readonly array $commands = [], private readonly bool $throws = false) {}
+
+ public function find(string $commandName): mixed
+ {
+ $this->findCalls++;
+ if ($this->throws) {
+ throw new \RuntimeException('Artisan command resolution failed.');
+ }
+
+ return $this->commands[$commandName] ?? null;
+ }
+}
+
+final class FakeListedArtisanCommand {}
+
+final class FakeFoundArtisanCommand {}
diff --git a/tests/Unit/Mocks/MockCommandBuilder.php b/tests/Unit/Mocks/MockCommandBuilder.php
index 8ed4de4..e9908a1 100644
--- a/tests/Unit/Mocks/MockCommandBuilder.php
+++ b/tests/Unit/Mocks/MockCommandBuilder.php
@@ -6,22 +6,32 @@
use Composer\Script\Event as ScriptEvent;
use empaphy\docker_composer\DockerComposeCommandBuilder;
-use empaphy\docker_composer\DockerComposerConfig;
+use empaphy\docker_composer\DockerComposeOptions;
final class MockCommandBuilder extends DockerComposeCommandBuilder
{
- public function buildRunningServicesCommand(DockerComposerConfig $config): array
+ public function buildRunningServicesCommand(DockerComposeOptions $config): array
{
return ['php', '-r', 'exit(1);'];
}
- public function buildUpCommand(DockerComposerConfig $config): array
+ public function buildUpCommand(DockerComposeOptions $config): array
{
return ['php', '-r', 'exit(0);'];
}
- public function buildScriptCommand(DockerComposerConfig $config, ScriptEvent $event, bool $interactive): array
+ public function buildConfigCommand(DockerComposeOptions $config): array
{
+ return ['php', '-r', 'echo \'{"services":{"php":{"working_dir":"/usr/src/app"}}}\';'];
+ }
+
+ public function buildScriptCommand(
+ DockerComposeOptions $config,
+ ScriptEvent $event,
+ bool $interactive,
+ ?string $hostPathRoot = null,
+ ?string $containerPathRoot = null,
+ ): array {
return ['php', '-r', 'exit(0);'];
}
}
diff --git a/tests/Unit/Mocks/MockProcessExecutor.php b/tests/Unit/Mocks/MockProcessExecutor.php
index dd2104a..353b631 100644
--- a/tests/Unit/Mocks/MockProcessExecutor.php
+++ b/tests/Unit/Mocks/MockProcessExecutor.php
@@ -18,15 +18,14 @@ final class MockProcessExecutor extends ProcessExecutor
*/
public array $ttyCommands = [];
- /**
- * @noinspection PhpMissingParentConstructorInspection
- */
public function __construct(
private readonly int $executeExitCode,
private readonly int $ttyExitCode,
private readonly string $testErrorOutput,
private readonly string $testOutput = '',
- ) {}
+ ) {
+ parent::__construct();
+ }
/**
* @param string|non-empty-list $command
diff --git a/tests/Unit/ShellProcessRunnerTest.php b/tests/Unit/ShellProcessRunnerTest.php
new file mode 100644
index 0000000..5ab71d7
--- /dev/null
+++ b/tests/Unit/ShellProcessRunnerTest.php
@@ -0,0 +1,92 @@
+runWithOutput([
+ PHP_BINARY,
+ '-r',
+ 'fwrite(STDOUT, "captured output\n");',
+ ], $output);
+
+ self::assertSame(0, $exitCode);
+ self::assertSame("captured output\n", $output);
+ self::assertSame('', $runner->getErrorOutput());
+ }
+
+ public function testRunReturnsExitCodeAndCapturesStderr(): void
+ {
+ $runner = new ShellProcessRunner();
+
+ $exitCode = $runner->run([
+ PHP_BINARY,
+ '-r',
+ 'fwrite(STDERR, "captured error\n"); exit(7);',
+ ]);
+
+ self::assertSame(7, $exitCode);
+ self::assertSame("captured error\n", $runner->getErrorOutput());
+ }
+
+ public function testRunWithOutputReturnsFailureWhenProcessCannotStart(): void
+ {
+ $runner = new ShellProcessRunner(self::failToOpenProcess(...));
+ $output = 'previous output';
+
+ $exitCode = $runner->runWithOutput([
+ PHP_BINARY,
+ '-r',
+ 'fwrite(STDOUT, "unreachable output\n");',
+ ], $output);
+
+ self::assertSame(1, $exitCode);
+ self::assertSame('', $output);
+ self::assertSame('Unable to start process.', $runner->getErrorOutput());
+ }
+
+ public function testSupportsTtyReturnsFalse(): void
+ {
+ self::assertFalse((new ShellProcessRunner())->supportsTty());
+ }
+
+ /**
+ * Fakes a failed process startup.
+ *
+ * @param list $command
+ * The command that would have been executed.
+ *
+ * @param array $descriptors
+ * The process descriptors that would have been used.
+ *
+ * @param array $pipes
+ * The process pipe resources.
+ *
+ * @return false
+ * Always returns `false` to mimic `proc_open` startup failure.
+ */
+ private static function failToOpenProcess(array $command, array $descriptors, array &$pipes): mixed
+ {
+ unset($command, $descriptors);
+
+ $pipes = [];
+
+ return false;
+ }
+}