diff --git a/CLAUDE.md b/CLAUDE.md index 11ff3ac..67d41b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `saucebase/installer` is a Laravel package (dev dependency) that provides the `saucebase:install` and `saucebase:stack` Artisan commands. It also publishes Docker configuration files (`docker-compose.yml`, `Dockerfile`, `nginx.conf`, `php.ini`, `xdebug.ini`) to the host app via `vendor:publish --tag=saucebase-docker`. -`saucebase:install` can bootstrap the entire dev environment — including starting Docker, running `composer install` inside the container, and building frontend assets — before handing off to the Laravel-specific install steps. Local PHP + Composer is a prerequisite (same as Laravel itself). +`saucebase:install` bootstraps the entire dev environment — prompting for SSL, starting Docker, running explicit artisan steps in the container, applying module patches on the host, and running per-module migrations and seeders. Local PHP + Composer is a prerequisite (same as Laravel itself). ## Commands @@ -27,18 +27,36 @@ composer install **`InstallCommand`** selects an environment driver, then orchestrates the install flow: -- `--driver=docker` → `DockerEnvironment`: publishes Docker stubs, generates SSL via mkcert, starts `docker compose`, runs `composer install` inside the container, re-invokes `saucebase:install --driver=native` inside the container, then runs `npm install && npm run build` on the host. -- `--driver=native` (default when not prompted) → `NativeEnvironment`: runs the Laravel install directly. +- `--driver=docker` → `DockerEnvironment`: see Docker flow below. +- `--driver=native` (default when not prompted) → `NativeEnvironment`: delegates to `InstallCommand::install()`. -Laravel install steps (run by `NativeEnvironment` or inside the container for Docker): +**Native install steps** (run by `NativeEnvironment` via `install()`): 1. `ensureEnvFile()` — copies `.env.example` → `.env` if missing 2. `generateApplicationKey()` — skips if `APP_KEY` already set 3. `setupDatabase()` — runs `migrate` (or `migrate:fresh` with `--fresh`) with seed 4. `runStack()` — calls `saucebase:stack` with the selected framework -5. `setupModules()` — fetches available modules from Packagist, `composer require`s selected ones, then runs `modules:sync` + `migrate` +5. `setupModules()` — fetches available modules from Packagist, `composer require`s selected ones, then: `dump-autoload` → `applyModulePatches()` → `modules:sync` → per-module `modules:migrate` + `modules:seed` 6. `createStorageLink()` + `clearCaches()` -**Environment drivers** live in `src/Environments/`. Each implements `Environments/Contracts/Environment` (`name()`, `label()`, `run(InstallCommand)`). Add new drivers (Valet, Herd, Sail) by creating a class there and adding a `match` arm in `InstallCommand::resolveDriver()`. +**Docker flow** (`DockerEnvironment::run()`): +1. `promptForSsl()` — asks user whether to enable HTTPS (requires mkcert); `--force` defaults to SSL on +2. If SSL requested but `mkcert` not installed → hard failure with install hint +3. `publishStubs()` — `vendor:publish --tag=saucebase-docker`; if SSL off, overwrites `docker/nginx.conf` with `nginx-no-ssl.conf` stub +4. `generateSsl()` — runs mkcert for `*.localhost` (no-op if SSL disabled or certs already exist) +5. `ensureEnvFile()` — copies `.env.example` → `.env` if missing +6. `setDockerEnvDefaults()` — calls `applyDockerEnvDefaults()` to patch `.env`: `DB_CONNECTION=mysql`, MySQL credentials, `MAIL_MAILER=smtp`, `APP_URL=https://localhost` (or `http://` if SSL off) +7. `startDocker()` — `docker compose restart` + `docker compose up -d --wait --build` (30 min timeout, streaming output) +8. `runComposerInContainer()` — `composer install` in the `app` container +9. `generateAppKey()` → `runMigrations()` → `runStack()` — artisan steps in the container via `execInContainer()` +10. `installModules()` — per module: `composer require` in container → `applyModulePatches()` on host → `modules:sync` → `modules:migrate` → `modules:seed` in container +11. `createStorageLink()` + `clearCaches()` in container +12. `reloadDocker()` — `docker compose up -d --wait` + +**Stubs** live in `stubs/docker/`. Two nginx configs are shipped: `nginx.conf` (SSL, HTTPS on 443) and `nginx-no-ssl.conf` (plain HTTP on 80). `publishStubs()` always publishes the SSL version first, then overwrites with the no-SSL version if needed. + +**`applyModulePatches(array $modules)`** (on `InstallCommand`, public) — for each module looks for `*.patch` files in `vendor/saucebase/{name}/patches/` and `modules/{name}/patches/`. Runs `git apply --check` first (skips if already applied), then `git apply`. Always runs on the host so git is available and volume-mounted changes are immediately visible. + +**Environment drivers** live in `src/Environments/`. Each implements `Environments/Contracts/Environment` (`name()`, `label()`, `missingPrerequisites()`, `run(InstallCommand)`). Add new drivers (Valet, Herd, Sail) by creating a class there and adding a `match` arm in `InstallCommand::resolveDriver()`. **`StackCommand`** manages frontend framework selection (Vue/React). Prompts when no stack argument is given. Supports `--dev` (contributor mode — copies config only, keeps both framework dirs) and `--reset`. @@ -51,7 +69,7 @@ Uses [Orchestral Testbench](https://github.com/orchestral/testbench) — no full - `InstallCommandTest` — covers `fetchPackageFrameworks()`, `filterModulesByFramework()`, stack dispatch, driver selection, and `--driver=native` behaviour. Uses anonymous class overrides to stub heavy operations without mocking internals. `TestableInstallCommand` at the bottom exposes protected methods for direct unit testing. - `StackCommandTest` — covers dev mode, install mode, reset, git skip-worktree, module and recipe stub processing. - `Environments/NativeEnvironmentTest` — verifies `run()` delegates to `install()` and passes through the return code. -- `Environments/DockerEnvironmentTest` — unit-tests `buildContainerArgs()` for all flag-forwarding combinations using `FakeInstallCommand`. +- `Environments/DockerEnvironmentTest` — tests `resolveModules()`, `applyDockerEnvDefaults()` (all SSL/no-SSL branches), SSL gate in `run()`, and `missingPrerequisites()`. Uses `FakeInstallCommand` (at bottom of file) as a stub with no-op output methods. ## Wiring into the host app diff --git a/README.md b/README.md index c7e6f95..0b39107 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # saucebase/installer ![Tests](https://github.com/saucebase-dev/installer/actions/workflows/php.yml/badge.svg) +![PHP](https://img.shields.io/badge/PHP-%5E8.4-777BB4?logo=php&logoColor=white) +![Laravel](https://img.shields.io/badge/Laravel-13-FF2D20?logo=laravel&logoColor=white) +[![Saucebase](https://img.shields.io/packagist/v/saucebase/saucebase?color=5455c4&label=saucebase%2Fsaucebase)](https://packagist.org/packages/saucebase/saucebase) Dev-environment installer for [Saucebase](https://github.com/saucebase-dev/saucebase) applications. @@ -27,15 +30,32 @@ Bootstraps a new Saucebase dev environment from scratch. php artisan saucebase:install ``` -Prompts for the frontend stack (Vue or React) and the environment driver (Docker or native PHP), then runs the full setup sequence: +Prompts for the frontend stack (Vue or React) and the environment driver (Docker or native PHP), then runs the full setup sequence. -1. Publishes Docker config files and generates SSL certificates (Docker driver only) -2. Starts `docker compose` and installs PHP dependencies inside the container -3. Copies `.env.example` → `.env` and generates `APP_KEY` -4. Runs migrations and seeds the database -5. Wires up the selected frontend stack -6. Installs any selected modules -7. Creates the storage symlink and clears caches +**Docker driver** (`--driver=docker`): + +1. Prompts whether to enable HTTPS via SSL (requires [mkcert](https://github.com/FiloSottile/mkcert)) +2. Publishes Docker config files (`docker-compose.yml`, `Dockerfile`, `nginx.conf`, etc.) +3. Generates SSL certificates with mkcert (if SSL enabled) +4. Patches `.env` with Docker-appropriate defaults — MySQL credentials, `MAIL_MAILER=smtp` for Mailpit, and `APP_URL` matching the SSL choice +5. Starts `docker compose` and installs PHP dependencies inside the container +6. Generates `APP_KEY`, runs migrations, wires up the frontend stack +7. Installs any selected modules (`composer require` → patches → `modules:sync` → `modules:migrate` → `modules:seed` per module) +8. Creates the storage symlink and clears caches + +**Native driver** (`--driver=native`): + +1. Copies `.env.example` → `.env` and generates `APP_KEY` +2. Runs migrations and seeds the database +3. Wires up the selected frontend stack +4. Installs any selected modules (same patch + migrate + seed flow as Docker) +5. Creates the storage symlink and clears caches + +**Docker prerequisites** + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Docker Engine with Compose plugin +- [mkcert](https://github.com/FiloSottile/mkcert) — only if you choose SSL (`brew install mkcert`) +- npm **Options** @@ -45,14 +65,14 @@ Prompts for the frontend stack (Vue or React) and the environment driver (Docker | `--driver=docker\|native` | Environment driver (skips the prompt) | | `--fresh` | Run `migrate:fresh` instead of `migrate` (destructive — wipes all data) | | `--all-modules` | Install every available module for the selected stack without prompting | -| `--modules=auth,billing` | Install specific modules by name (comma-separated) | +| `--modules=auth,billing` | Install specific modules by name (comma-separated, short or full package name) | | `--dev` | Contributor mode — skips module installation | -| `--force` | Skip confirmations | -| `--no-logo` | Suppress the welcome banner (passed automatically when running inside a container) | +| `--force` | Skip confirmations (Docker driver defaults to SSL when forced) | +| `--no-logo` | Suppress the welcome banner | **Examples** -Fully interactive — prompts for stack and driver: +Fully interactive — prompts for stack, driver, SSL, and modules: ```bash php artisan saucebase:install ``` @@ -67,11 +87,6 @@ React + native PHP, specific modules only: php artisan saucebase:install react --driver=native --modules=auth,billing ``` -Re-run inside an already-running container (skips Docker steps): -```bash -php artisan saucebase:install vue --driver=native --force -``` - --- ### `saucebase:stack` diff --git a/composer.json b/composer.json index 3657f6e..d3308f1 100644 --- a/composer.json +++ b/composer.json @@ -6,15 +6,15 @@ "license": "MIT", "require": { "php": "^8.4", - "illuminate/console": "^12.0|^13.0", - "illuminate/filesystem": "^12.0|^13.0", - "illuminate/http": "^12.0|^13.0", - "illuminate/support": "^12.0|^13.0", + "illuminate/console": "^13.0", + "illuminate/filesystem": "^13.0", + "illuminate/http": "^13.0", + "illuminate/support": "^13.0", "laravel/prompts": "^0.3", "symfony/process": "^7.0" }, "require-dev": { - "orchestra/testbench": "^10.0", + "orchestra/testbench": "^11.0", "phpunit/phpunit": "^11.0" }, "autoload": { diff --git a/src/Console/Commands/InstallCommand.php b/src/Console/Commands/InstallCommand.php index 12f61d2..0a199ef 100644 --- a/src/Console/Commands/InstallCommand.php +++ b/src/Console/Commands/InstallCommand.php @@ -157,7 +157,7 @@ protected function promptForModules(): void * @param string[] $packages * @return string[] */ - protected function filterModulesByFramework(array $packages, string $framework): array + public function filterModulesByFramework(array $packages, string $framework): array { return array_values(array_filter( $packages, @@ -346,7 +346,10 @@ protected function setupModules(): void return $process->isSuccessful(); }); - // Phase 3: sync module configs, then migrate all in one pass + // Phase 2.5: apply any patches the modules ship for the host app + $this->applyModulePatches($selected); + + // Phase 3: sync module configs, then migrate + seed each module individually $this->components->task('Syncing modules', function () { $process = new Process([PHP_BINARY, base_path('artisan'), 'modules:sync']); $process->setTimeout(30); @@ -355,19 +358,69 @@ protected function setupModules(): void return $process->isSuccessful(); }); - $this->components->task('Running module migrations', function () { - $process = new Process([PHP_BINARY, base_path('artisan'), 'migrate', '--seed', '--force']); - $process->setTimeout(120); - $process->run(); + foreach ($selected as $package) { + $name = Str::after($package, '/'); - return $process->isSuccessful(); - }); + $this->components->task("Migrating {$name}", function () use ($name) { + $process = new Process([PHP_BINARY, base_path('artisan'), 'modules:migrate', "--module={$name}", '--force']); + $process->setTimeout(120); + $process->run(); + + return $process->isSuccessful(); + }); + + $this->components->task("Seeding {$name}", function () use ($name) { + $process = new Process([PHP_BINARY, base_path('artisan'), 'modules:seed', "--module={$name}"]); + $process->setTimeout(60); + $process->run(); + + return $process->isSuccessful(); + }); + } + } + + public function applyModulePatches(array $modules): void + { + foreach ($modules as $package) { + $name = Str::after($package, '/'); + + $dirs = array_filter([ + base_path("vendor/saucebase/{$name}/patches"), + base_path("modules/{$name}/patches"), + ], 'is_dir'); + + foreach ($dirs as $dir) { + foreach (glob("{$dir}/*.patch") ?: [] as $patch) { + $label = basename($patch); + + $check = new Process(['git', 'apply', '--check', '--whitespace=nowarn', $patch]); + $check->setWorkingDirectory(base_path()); + $check->run(); + + if (! $check->isSuccessful()) { + $this->warn("Skipping {$label}: already applied or conflicts."); + + continue; + } + + $apply = new Process(['git', 'apply', '--whitespace=nowarn', $patch]); + $apply->setWorkingDirectory(base_path()); + $apply->run(); + + if ($apply->isSuccessful()) { + $this->info("Applied patch: {$label}"); + } else { + $this->warn("Failed to apply {$label}: ".$apply->getErrorOutput()); + } + } + } + } } /** * @return string[] */ - protected function fetchAvailableModules(): array + public function fetchAvailableModules(): array { if (! empty($this->availableModules)) { return $this->availableModules; @@ -495,11 +548,7 @@ protected function displaySuccess(): void $this->newLine(); $this->line('Next steps:'); $this->line(' 1. Ensure APP_URL is set correctly in .env'); - if ($this->option('driver') === 'docker') { - $this->line(' 2. Start the dev server: npm run dev'); - } else { - $this->line(' 2. Start the dev server: php artisan serve or composer dev'); - } + $this->line(' 2. Start the dev server: '.($this->option('driver') === 'docker' ? 'npm run dev' : 'php artisan serve or composer dev').''); $this->line(' 3. Open your app in the browser: '.config('app.url').''); $this->newLine(); $this->line('Learn more: https://github.com/saucebase-dev/saucebase'); diff --git a/src/Environments/DockerEnvironment.php b/src/Environments/DockerEnvironment.php index 0bf5d1a..6e28ca2 100644 --- a/src/Environments/DockerEnvironment.php +++ b/src/Environments/DockerEnvironment.php @@ -3,6 +3,7 @@ namespace Saucebase\Installer\Environments; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Str; use Saucebase\Installer\Console\Commands\InstallCommand; use Saucebase\Installer\Environments\Contracts\Environment; use Symfony\Component\Process\Process; @@ -68,7 +69,18 @@ public function run(InstallCommand $command): int return InstallCommand::FAILURE; } - $this->runInstallInContainer($command); + if (! $this->generateAppKey($command)) { + return InstallCommand::FAILURE; + } + + if (! $this->runMigrations($command)) { + return InstallCommand::FAILURE; + } + + $this->runStack($command); + $this->installModules($command); + $this->createStorageLink($command); + $this->clearCaches($command); $this->reloadDocker($command); return InstallCommand::SUCCESS; @@ -170,46 +182,120 @@ protected function runComposerInContainer(InstallCommand $command): bool return $process->isSuccessful(); } - protected function runInstallInContainer(InstallCommand $command): void + protected function execInContainer(InstallCommand $command, array $args, int $timeout = 120): bool { - $args = $this->buildContainerArgs($command); - $command->info('Running installer in container...'); - $process = new Process(array_merge(['docker', 'compose', 'exec', '-T', 'app'], $args)); - $process->setTimeout(300); + $process->setTimeout($timeout); $process->run(fn ($_type, $buffer) => $command->line(trim($buffer))); + + return $process->isSuccessful(); } - protected function reloadDocker(InstallCommand $command): void + protected function generateAppKey(InstallCommand $command): bool { - $command->info('Reloading container...'); - $process = new Process(['docker', 'compose', 'up', '-d', '--wait']); - $process->setTimeout(60); - $process->run(); + $command->info('Generating application key...'); + + return $this->execInContainer($command, ['php', 'artisan', 'key:generate', '--force']); } - /** @return string[] */ - public function buildContainerArgs(InstallCommand $command): array + protected function runMigrations(InstallCommand $command): bool { - $args = ['php', 'artisan', 'saucebase:install', '--driver=native', '--force', '--no-logo']; + $fresh = $command->option('fresh'); + $command->info($fresh ? 'Running fresh migrations...' : 'Running migrations...'); + + return $this->execInContainer( + $command, + ['php', 'artisan', $fresh ? 'migrate:fresh' : 'migrate', '--seed', '--force'], + timeout: 300, + ); + } - if ($stack = $command->getSelectedStack()) { - $args[] = $stack; + protected function runStack(InstallCommand $command): void + { + if (! $stack = $command->getSelectedStack()) { + return; } - if ($modules = $command->option('modules')) { - $args[] = '--modules='.$modules; - } else { - $args[] = '--modules='.implode(',', $command->getSelectedModules()); + $command->info("Setting up {$stack} stack..."); + $args = ['php', 'artisan', 'saucebase:stack', $stack]; + + if ($command->option('dev')) { + $args[] = '--dev'; + } + + $this->execInContainer($command, $args); + } + + protected function installModules(InstallCommand $command): void + { + $modules = $this->resolveModules($command); + + if (empty($modules)) { + return; } - foreach (['fresh', 'dev', 'all-modules'] as $opt) { - if ($command->option($opt)) { - $args[] = "--{$opt}"; + $command->info('Installing modules...'); + + $anyFailed = false; + foreach ($modules as $package) { + $ok = $this->execInContainer($command, ['composer', 'require', $package, '--no-interaction'], timeout: 300); + + if (! $ok) { + $command->warn("Failed to require {$package} — skipping."); + $anyFailed = true; } } - return $args; + if ($anyFailed) { + return; + } + + $this->execInContainer($command, ['composer', 'dump-autoload', '--no-interaction']); + $command->applyModulePatches($modules); + $this->execInContainer($command, ['php', 'artisan', 'modules:sync']); + + foreach ($modules as $package) { + $name = Str::after($package, '/'); + $this->execInContainer($command, ['php', 'artisan', 'modules:migrate', "--module={$name}", '--force'], timeout: 120); + $this->execInContainer($command, ['php', 'artisan', 'modules:seed', "--module={$name}"]); + } + } + + protected function resolveModules(InstallCommand $command): array + { + if ($command->option('all-modules')) { + $available = $command->fetchAvailableModules(); + + return $command->getSelectedStack() + ? $command->filterModulesByFramework($available, $command->getSelectedStack()) + : $available; + } + + if ($raw = $command->option('modules')) { + return array_values(array_filter(array_map('trim', explode(',', $raw)))); + } + + return $command->getSelectedModules(); + } + + protected function createStorageLink(InstallCommand $command): void + { + $command->info('Creating storage link...'); + $this->execInContainer($command, ['php', 'artisan', 'storage:link']); + } + + protected function clearCaches(InstallCommand $command): void + { + $command->info('Clearing caches...'); + $this->execInContainer($command, ['php', 'artisan', 'optimize:clear']); + } + + protected function reloadDocker(InstallCommand $command): void + { + $command->info('Reloading container...'); + $process = new Process(['docker', 'compose', 'up', '-d', '--wait']); + $process->setTimeout(60); + $process->run(); } protected function setDockerEnvDefaults(InstallCommand $command): void diff --git a/tests/Feature/Environments/DockerEnvironmentTest.php b/tests/Feature/Environments/DockerEnvironmentTest.php index e2b468b..980d377 100644 --- a/tests/Feature/Environments/DockerEnvironmentTest.php +++ b/tests/Feature/Environments/DockerEnvironmentTest.php @@ -26,119 +26,28 @@ public function test_implements_environment_contract(): void } // ------------------------------------------------------------------------- - // buildContainerArgs + // resolveModules // ------------------------------------------------------------------------- - public function test_always_includes_driver_native_and_force_flags(): void + public function test_resolve_modules_returns_selected_modules_when_no_option_set(): void { - $args = $this->buildArgs(); + $modules = $this->resolveModules(modules: ['saucebase/auth', 'saucebase/billing']); - $this->assertContains('--driver=native', $args); - $this->assertContains('--force', $args); + $this->assertSame(['saucebase/auth', 'saucebase/billing'], $modules); } - public function test_always_includes_artisan_command(): void + public function test_resolve_modules_parses_modules_option(): void { - $args = $this->buildArgs(); + $modules = $this->resolveModules(options: ['modules' => 'saucebase/auth, saucebase/billing']); - $this->assertContains('php', $args); - $this->assertContains('artisan', $args); - $this->assertContains('saucebase:install', $args); + $this->assertSame(['saucebase/auth', 'saucebase/billing'], $modules); } - public function test_forwards_stack_argument(): void + public function test_resolve_modules_returns_empty_when_nothing_selected(): void { - $args = $this->buildArgs(stack: 'vue'); + $modules = $this->resolveModules(modules: []); - $this->assertContains('vue', $args); - } - - public function test_forwards_react_stack(): void - { - $args = $this->buildArgs(stack: 'react'); - - $this->assertContains('react', $args); - } - - public function test_omits_stack_when_null(): void - { - $args = $this->buildArgs(stack: null); - - $this->assertNotContains('vue', $args); - $this->assertNotContains('react', $args); - } - - public function test_forwards_selected_modules_as_comma_list(): void - { - $args = $this->buildArgs(modules: ['saucebase/auth', 'saucebase/billing']); - - $this->assertContains('--modules=saucebase/auth,saucebase/billing', $args); - } - - public function test_includes_empty_modules_arg_when_none_selected(): void - { - $args = $this->buildArgs(modules: []); - - $this->assertContains('--modules=', $args); - } - - public function test_forwards_fresh_flag(): void - { - $args = $this->buildArgs(options: ['fresh' => true]); - - $this->assertContains('--fresh', $args); - } - - public function test_omits_fresh_when_not_set(): void - { - $args = $this->buildArgs(options: ['fresh' => false]); - - $this->assertNotContains('--fresh', $args); - } - - public function test_forwards_dev_flag(): void - { - $args = $this->buildArgs(options: ['dev' => true]); - - $this->assertContains('--dev', $args); - } - - public function test_forwards_all_modules_flag(): void - { - $args = $this->buildArgs(options: ['all-modules' => true]); - - $this->assertContains('--all-modules', $args); - } - - public function test_forwards_modules_option_when_set_directly(): void - { - $args = $this->buildArgs(modules: [], options: ['modules' => 'saucebase/auth,saucebase/billing']); - - $this->assertContains('--modules=saucebase/auth,saucebase/billing', $args); - } - - public function test_always_includes_modules_arg_even_when_none_selected(): void - { - $args = $this->buildArgs(modules: [], options: []); - - $matched = array_filter($args, fn (string $a) => str_starts_with($a, '--modules=')); - $this->assertNotEmpty($matched, '--modules= must always be present so the container skips the interactive prompt'); - } - - public function test_multiple_flags_are_all_forwarded(): void - { - $args = $this->buildArgs( - stack: 'react', - modules: ['saucebase/auth'], - options: ['fresh' => true, 'dev' => true], - ); - - $this->assertContains('react', $args); - $this->assertContains('--modules=saucebase/auth', $args); - $this->assertContains('--fresh', $args); - $this->assertContains('--dev', $args); - $this->assertContains('--driver=native', $args); - $this->assertContains('--force', $args); + $this->assertSame([], $modules); } // ------------------------------------------------------------------------- @@ -573,23 +482,29 @@ public function applyDockerEnvDefaults(string $env, bool $ssl = true): string /** * @param string[] $modules - * @param array $options + * @param array $options * @return string[] */ - private function buildArgs( - ?string $stack = null, + private function resolveModules( array $modules = [], array $options = [], ): array { - $command = new FakeInstallCommand($stack, $modules, $options); + $command = new FakeInstallCommand(null, $modules, $options); + + $exposed = new class extends DockerEnvironment + { + public function resolveModules(InstallCommand $command): array + { + return parent::resolveModules($command); + } + }; - return (new DockerEnvironment)->buildContainerArgs($command); + return $exposed->resolveModules($command); } } /** - * Minimal InstallCommand stub for testing buildContainerArgs() without - * needing a real Symfony Console input binding. + * Minimal InstallCommand stub for DockerEnvironment tests. * * @internal */