From 1ba536ed8b8ba4e9c1ad6ea1dd4e07349cb7b3bd Mon Sep 17 00:00:00 2001 From: roble Date: Wed, 26 Aug 2026 20:59:53 +0100 Subject: [PATCH 1/3] feat: Enhance Docker environment handling and add comprehensive tests - Introduced failure handling in the Environment class to capture and report failed steps during installation. - Added methods to manage resume options and ensure proper messaging for failed steps. - Implemented port conflict detection and resolution logic in DockerEnvironment, including tests for various scenarios. - Created InstallCommandIdentityTest to validate application name and host handling during installation. - Added InstallCommandResumeTest to ensure correct behavior of resume commands and failure messaging. - Refactored TestableInstallCommand to expose additional methods for testing purposes. --- src/Console/Commands/InstallCommand.php | 298 +++++++++- src/Console/Commands/NewCommand.php | 32 +- src/Environments/DockerEnvironment.php | 465 +++++++++++++++- src/Environments/Environment.php | 36 ++ .../Environments/DockerEnvironmentTest.php | 512 +++++++++++++++++- tests/Feature/InstallCommandIdentityTest.php | 182 +++++++ tests/Feature/InstallCommandResumeTest.php | 111 ++++ tests/Feature/InstallCommandTest.php | 82 --- tests/Feature/TestableInstallCommand.php | 100 ++++ 9 files changed, 1707 insertions(+), 111 deletions(-) create mode 100644 tests/Feature/InstallCommandIdentityTest.php create mode 100644 tests/Feature/InstallCommandResumeTest.php create mode 100644 tests/Feature/TestableInstallCommand.php diff --git a/src/Console/Commands/InstallCommand.php b/src/Console/Commands/InstallCommand.php index ca2e48b..e4fdd32 100644 --- a/src/Console/Commands/InstallCommand.php +++ b/src/Console/Commands/InstallCommand.php @@ -13,6 +13,7 @@ use function Laravel\Prompts\callout; use function Laravel\Prompts\select; +use function Laravel\Prompts\text; class InstallCommand extends Command { @@ -23,6 +24,7 @@ class InstallCommand extends Command {--path= : The Saucebase application directory (defaults to the current directory)} {--driver= : Environment driver (docker, native) — prompted if omitted} {--ssl= : Enable HTTPS with mkcert for docker (yes/no) — prompted if omitted} + {--domain= : Hostname the app is served on (e.g. myapp.test) — prompted if omitted} {--fresh : Run migrate:fresh instead of migrate (destructive)} {--all-modules : Enable and migrate all available modules without prompting} {--modules= : Comma-separated list of modules to enable (e.g. Auth,Settings), or "none"} @@ -34,6 +36,8 @@ class InstallCommand extends Command protected ?string $selectedStack = null; + protected ?string $domain = null; + /** @var string[] */ protected array $selectedModules = []; @@ -53,6 +57,8 @@ public function handle(): int return $this->handleCIInstallation(); } + $this->captureDomain(); + $driver = $this->resolveDriver(); $missing = $driver->missingPrerequisites(); @@ -70,19 +76,22 @@ public function handle(): int public function targetPath(): string { if ($this->resolvedTargetPath === null) { - try { - $path = $this->option('path'); - } catch (\Throwable) { - // No input bound (command instantiated outside the console app). - $path = null; - } - - $this->resolvedTargetPath = rtrim($path ?: getcwd(), '/'); + $this->resolvedTargetPath = rtrim($this->optionOrNull('path') ?: getcwd(), '/'); } return $this->resolvedTargetPath; } + /** Read an option, tolerating a command instantiated outside the console app (no input bound). */ + public function optionOrNull(string $key): string|array|bool|null + { + try { + return $this->option($key); + } catch (\Throwable) { + return null; + } + } + public function path(string $relative = ''): string { return $relative === '' ? $this->targetPath() : $this->targetPath().'/'.$relative; @@ -144,6 +153,77 @@ protected function captureStack(): void $this->selectedStack = $stack; } + protected function captureDomain(): void + { + $option = $this->option('domain'); + + // An already-installed app's host is the default, so accepting the prompt keeps + // it and answering something else genuinely changes it. + $current = $this->envValue('APP_HOST') ?: 'localhost'; + + $domain = match (true) { + $option !== null && $option !== '' => $option, + (bool) $this->option('force') => $current, + default => text( + label: 'Which hostname will you use for this app?', + default: $current, + hint: 'Use localhost, or a custom domain such as myapp.test.', + validate: fn (string $value) => self::normalizeDomain($value) === null + ? 'Enter a bare hostname, e.g. myapp.test' + : null, + ), + }; + + $this->domain = self::normalizeDomain($domain) ?? 'localhost'; + + $this->warnWhenDomainDoesNotResolve($this->domain); + } + + /** Read a key straight out of the target app's .env, or null when absent. */ + protected function envValue(string $key): ?string + { + $env = @file_get_contents($this->path('.env')); + + return ($env !== false && preg_match('/^'.preg_quote($key, '/').'=(.+)$/m', $env, $m) === 1) + ? trim(trim($m[1]), "\"'") + : null; + } + + /** + * Reduce a user-entered value to a bare hostname, or null when it is not one. + * Accepts a pasted URL: "https://My.App.test:8443/" becomes "my.app.test". + */ + public static function normalizeDomain(string $value): ?string + { + $host = strtolower(trim($value)); + $host = preg_replace('#^[a-z]+://#', '', $host); + $host = strtok($host, ':/'); + + return ($host !== false && $host !== '' && preg_match('/^[a-z0-9.-]+$/', $host) === 1) + ? $host + : null; + } + + /** + * A custom domain resolves only via /etc/hosts, Herd, Valet or dnsmasq. Say so + * rather than letting the browser fail after a successful install. + */ + protected function warnWhenDomainDoesNotResolve(string $domain): void + { + if ($domain === 'localhost' || gethostbyname($domain.'.') !== $domain.'.') { + return; + } + + $this->components->warn("\"{$domain}\" does not resolve on this machine yet."); + $this->line(' Add it to /etc/hosts before opening the app:'); + $this->line(" 127.0.0.1 {$domain}"); + } + + public function getDomain(): string + { + return $this->domain ?? 'localhost'; + } + public function runStack(): void { if ($this->selectedStack) { @@ -178,6 +258,42 @@ public function promptForModules(): void $this->selectedModules = $this->registry()->promptSelection($available); } + /** + * Refuse to install a module that ships no frontend for the chosen stack. + * + * @param string[] $packages + */ + public function assertModulesSupportStack(array $packages): bool + { + if (! $this->selectedStack || empty($packages)) { + return true; + } + + $incompatible = []; + + foreach ($packages as $package) { + $frameworks = $this->fetchPackageFrameworks($package); + + if (! in_array($this->selectedStack, $frameworks, true)) { + $incompatible[$package] = $frameworks; + } + } + + if (empty($incompatible)) { + return true; + } + + $this->error("These modules do not support the {$this->selectedStack} stack:"); + + foreach ($incompatible as $package => $frameworks) { + $this->line(" {$package} — supports: ".implode(', ', $frameworks)); + } + + $this->line('Drop them from --modules, or install with a stack they support.'); + + return false; + } + /** * @param string[] $packages * @return string[] @@ -214,6 +330,8 @@ public function install(): int return self::FAILURE; } + $this->applyAppIdentity(); + $this->generateApplicationKey(); if (! $this->setupDatabase()) { @@ -269,6 +387,87 @@ public function ensureEnvFile(): bool return false; } + /** + * Name the app after its own directory, and record the host it will be served on. + * + * The skeleton ships APP_NAME="Saucebase" / APP_SLUG=saucebase, and nothing used to + * change them — so every app shared a name, and (since applyDockerEnvDefaults() + * derives them from APP_SLUG) a database name too. + */ + public function applyAppIdentity(bool $native = true): void + { + $path = $this->path('.env'); + $original = @file_get_contents($path); + + if ($original === false) { + return; + } + + $directory = basename($this->targetPath()); + + $modified = $this->applyIdentityToEnv( + $original, + Str::headline($directory), + Str::slug($directory), + $this->getDomain(), + native: $native, + ); + + if ($modified !== $original) { + file_put_contents($path, $modified); + $this->info('Application name and host written to .env.'); + } + } + + /** + * Only a skeleton default or a blank is replaced, so a re-run never clobbers a + * value the user has since edited. + * + * @param bool $native Whether to set APP_URL here; under Docker it is owned by + * applyDockerEnvDefaults(), which also knows scheme and port. + */ + protected function applyIdentityToEnv(string $env, string $name, string $slug, string $host, bool $native = false): string + { + $replaceable = [ + 'APP_NAME' => ['Saucebase', str_contains($name, ' ') ? "\"{$name}\"" : $name], + 'APP_SLUG' => ['saucebase', $slug], + ]; + + foreach ($replaceable as $key => [$default, $value]) { + $current = preg_match('/^'.preg_quote($key, '/').'=(.*)$/m', $env, $m) + ? trim(trim($m[1]), "\"'") + : null; + + if ($current === null || $current === '' || $current === $default) { + $env = self::setEnvLine($env, $key, $value); + } + } + + // Always authoritative: the certificate and nginx config are generated from the + // resolved domain, so APP_HOST must agree with them. A user's own value is not + // lost — it is what captureDomain() offers as the prompt default. + $env = self::setEnvLine($env, 'APP_HOST', $host); + + // Native serves on whatever laravel/installer already wrote for localhost + // (http://localhost:8000, which is what `composer dev` binds); only a custom + // host needs correcting here. + if ($native && $host !== 'localhost') { + $env = self::setEnvLine($env, 'APP_URL', 'http://'.$host); + } + + return $env; + } + + /** Replace a key's value, appending the line when the key is absent. */ + public static function setEnvLine(string $env, string $key, string $value): string + { + $pattern = '/^'.preg_quote($key, '/').'=.*$/m'; + + return preg_match($pattern, $env) + ? preg_replace($pattern, "{$key}={$value}", $env) + : $env."\n{$key}={$value}"; + } + public function envHasAppKey(): bool { $env = @file_get_contents($this->path('.env')); @@ -530,4 +729,87 @@ protected function successCalloutContent(array $steps): array 'Learn more: '.Element::link('https://github.com/saucebase-dev/saucebase'), ]); } + + /** @param array $resumeOptions */ + public function displayFailure(?string $step = null, array $resumeOptions = []): void + { + callout(label: 'Installation did not finish', content: $this->failureCalloutContent($step, $resumeOptions)); + } + + /** + * @param array $resumeOptions + * @return array + */ + protected function failureCalloutContent(?string $step, array $resumeOptions): array + { + return array_values(array_filter([ + $step ? "Failed at: {$step}" : null, + 'Your application directory is intact — nothing was rolled back.', + 'Fix the problem reported above, then resume with:', + ' '.$this->resumeCommand($resumeOptions), + ])); + } + + /** + * The exact command that resumes this install, with every answer baked in. + * + * `install` is idempotent (existing stubs, certs, .env and app key are all left + * alone), so re-running it is the resume path. A bare `saucebase install` would + * re-prompt for stack, driver and modules, hence every option is spelled out — + * including the ones that came from prompts rather than the command line. + * + * @param array $resumeOptions Driver-resolved options (--driver, --ssl). + */ + public function resumeCommand(array $resumeOptions = []): string + { + $command = 'saucebase install'; + + if ($this->selectedStack) { + $command .= ' '.$this->selectedStack; + } + + foreach ($resumeOptions as $option => $value) { + $command .= " {$option}={$value}"; + } + + if ($this->domain !== null) { + $command .= ' --domain='.$this->domain; + } + + if ($modules = $this->resumeModules()) { + $command .= ' --modules='.$modules; + } + + foreach (['all-modules', 'dev', 'fresh', 'force'] as $flag) { + if ($this->optionOrNull($flag)) { + $command .= ' --'.$flag; + } + } + + $target = $this->targetPath(); + + return $target === rtrim((string) getcwd(), '/') + ? $command + : 'cd '.$this->quotePath($target).' && '.$command; + } + + /** The --modules value for a resume run, or null when selection was never resolved. */ + protected function resumeModules(): ?string + { + if ($this->optionOrNull('all-modules')) { + return null; + } + + if ($option = $this->optionOrNull('modules')) { + return is_string($option) ? $option : null; + } + + return $this->selectedModules ? implode(',', $this->selectedModules) : null; + } + + /** Quote only when needed, but quote safely: paths can contain $, backticks or quotes. */ + protected function quotePath(string $path): string + { + return preg_match('#^[A-Za-z0-9._/@:+-]+$#', $path) === 1 ? $path : escapeshellarg($path); + } } diff --git a/src/Console/Commands/NewCommand.php b/src/Console/Commands/NewCommand.php index 6b14037..cb39ae3 100644 --- a/src/Console/Commands/NewCommand.php +++ b/src/Console/Commands/NewCommand.php @@ -33,6 +33,7 @@ class NewCommand extends Command {--modules= : Comma-separated list of modules to enable, or "none"} {--all-modules : Enable and migrate all available modules without prompting} {--ssl= : Enable HTTPS with mkcert for docker (yes/no) — prompted if omitted} + {--domain= : Hostname the app is served on (e.g. myapp.test) — prompted if omitted} {--using= : The skeleton package to install (defaults to saucebase/saucebase)} {--dev : Dev environment} {--fresh : Run migrate:fresh instead of migrate (destructive)} @@ -75,6 +76,8 @@ public function handle(): int return self::FAILURE; } + $domain = $this->resolveDomain($name); + $stack = $this->option('stack') ?: select( label: 'Which frontend stack would you like to use?', options: ['vue' => 'Vue', 'react' => 'React'], @@ -91,7 +94,7 @@ public function handle(): int $this->line(' Setup continues below - see the final summary for the real next steps.'); - $result = $this->call('install', $this->installArguments($name, $driver, $stack, $ssl, $modules)); + $result = $this->call('install', $this->installArguments($name, $driver, $stack, $ssl, $modules, $domain)); if ($result === self::SUCCESS) { $this->saveDriverPreference($driver->name()); @@ -131,6 +134,30 @@ protected function resolveSsl(Environment $driver): bool }; } + /** + * The hostname the app will be served on. Collected upfront (like ssl and stack) + * because the SSL certificate and nginx config are both built from it. + */ + protected function resolveDomain(string $name): string + { + $option = $this->option('domain'); + + $answer = match (true) { + $option !== null && $option !== '' => $option, + (bool) $this->option('force') => 'localhost', + default => text( + label: 'Which hostname will you use for this app?', + default: 'localhost', + hint: 'Use localhost, or a custom domain such as '.strtolower($name).'.test', + validate: fn (string $value) => InstallCommand::normalizeDomain($value) === null + ? 'Enter a bare hostname, e.g. myapp.test' + : null, + ), + }; + + return InstallCommand::normalizeDomain($answer) ?? 'localhost'; + } + /** * Prompt for modules upfront so the install can run unattended. * Returns null when module selection is already driven by options. @@ -216,12 +243,13 @@ protected function skeletonPackage(): string * @param string[]|null $modules * @return array */ - protected function installArguments(string $name, Environment $driver, string $stack, ?bool $ssl, ?array $modules): array + protected function installArguments(string $name, Environment $driver, string $stack, ?bool $ssl, ?array $modules, string $domain = 'localhost'): array { $arguments = [ 'stack' => $stack, '--path' => getcwd().'/'.$name, '--driver' => $driver->name(), + '--domain' => $domain, '--no-logo' => true, ]; diff --git a/src/Environments/DockerEnvironment.php b/src/Environments/DockerEnvironment.php index b1acfe6..8ff6c36 100644 --- a/src/Environments/DockerEnvironment.php +++ b/src/Environments/DockerEnvironment.php @@ -7,11 +7,34 @@ use Symfony\Component\Process\Process; use function Laravel\Prompts\confirm; +use function Laravel\Prompts\select; class DockerEnvironment extends Environment { + /** + * Host ports published by docker-compose.yml: env key => [compose default, label]. + * Keep in sync with stubs/docker/docker-compose.yml. + */ + private const PORTS = [ + 'APP_PORT' => [80, 'HTTP'], + 'APP_HTTPS_PORT' => [443, 'HTTPS'], + 'FORWARD_DB_PORT' => [3306, 'MySQL'], + 'FORWARD_REDIS_PORT' => [6379, 'Redis'], + 'FORWARD_MAILPIT_PORT' => [1025, 'Mailpit SMTP'], + 'FORWARD_MAILPIT_DASHBOARD_PORT' => [8025, 'Mailpit dashboard'], + ]; + + /** Where to start scanning for a replacement port; defaults to the conflicting port + 1. */ + private const ALTERNATIVE_PORT_BASES = [ + 'APP_PORT' => 8080, + 'APP_HTTPS_PORT' => 8443, + ]; + protected bool $ssl = true; + /** @var array Env key => replacement port, when the defaults were taken. */ + protected array $portOverrides = []; + public function name(): string { return 'docker'; @@ -59,31 +82,41 @@ protected function boot(InstallCommand $command): int $this->generateSsl($command); if (! $command->ensureEnvFile()) { - return InstallCommand::FAILURE; + return $this->fail('Preparing .env'); } + // APP_HOST must land before setDockerEnvDefaults(), which builds APP_URL from it. + $command->applyAppIdentity(native: false); $this->setDockerEnvDefaults($command); + // Ports are read back from .env, so this must follow setDockerEnvDefaults(). + if (! $this->checkPorts($command)) { + return $this->fail('Checking host ports'); + } + + // Needs the host and the final port assignments, so it follows the port check. + $this->configureNginx($command); + if (! $this->startDocker($command)) { - return InstallCommand::FAILURE; + return $this->fail('Starting Docker services'); } if (! $this->runComposerInContainer($command)) { - return InstallCommand::FAILURE; + return $this->fail('Installing PHP dependencies'); } if (! $this->generateAppKey($command)) { - return InstallCommand::FAILURE; + return $this->fail('Generating application key'); } if (! $this->runMigrations($command)) { - return InstallCommand::FAILURE; + return $this->fail('Running migrations'); } $this->runStack($command); if (! $this->installModules($command)) { - return InstallCommand::FAILURE; + return $this->fail('Installing modules'); } $command->rewriteCrossModuleImports(); @@ -153,8 +186,10 @@ protected function generateSsl(InstallCommand $command): void $certFile = $command->path('docker/ssl/app.pem'); $keyFile = $command->path('docker/ssl/app.key.pem'); + $host = $command->getDomain(); - if (file_exists($certFile) && file_exists($keyFile)) { + // Re-installing with a different domain must not keep a cert that omits it. + if (file_exists($certFile) && file_exists($keyFile) && $this->certCoversHost($certFile, $host)) { return; } @@ -167,7 +202,7 @@ protected function generateSsl(InstallCommand $command): void 'mkcert', '-key-file', $keyFile, '-cert-file', $certFile, - '*.localhost', 'localhost', '127.0.0.1', '::1', + ...$this->certificateHosts($host), ]); $cert->run(); @@ -176,6 +211,373 @@ protected function generateSsl(InstallCommand $command): void } } + /** @return string[] mkcert SANs: the chosen host and its wildcard, plus localhost. */ + protected function certificateHosts(string $host): array + { + $hosts = ['localhost', '*.localhost', '127.0.0.1', '::1']; + + return $host === 'localhost' ? $hosts : [$host, "*.{$host}", ...$hosts]; + } + + /** + * Whether an existing certificate already lists the host as a SAN. + * + * Read via the text dump rather than `-checkhost`: macOS ships LibreSSL, which + * does not support that flag. Any failure returns false, so we regenerate. + */ + protected function certCoversHost(string $certFile, string $host): bool + { + $process = new Process(['openssl', 'x509', '-in', $certFile, '-noout', '-text']); + $process->setTimeout(15); + $process->run(); + + if (! $process->isSuccessful()) { + return false; + } + + // Anchor the end so "DNS:myapp.test" does not satisfy a request for "app.test". + return preg_match('/DNS:'.preg_quote($host, '/').'(?=[,\s]|$)/', $process->getOutput()) === 1; + } + + /** + * Point the published nginx config at the chosen host and the ports actually + * published. Both stubs ship hardcoded for localhost on 80/443. + */ + protected function configureNginx(InstallCommand $command): void + { + $path = $command->path('docker/nginx.conf'); + $original = @file_get_contents($path); + + if ($original === false) { + return; + } + + // Read back from .env rather than $portOverrides: on a resume the ports were + // persisted by an earlier run and nothing is overridden this time round. + $ports = $this->resolvePorts($command); + + $modified = $this->applyNginxSettings( + $original, + $command->getDomain(), + $ports['APP_PORT'], + $ports['APP_HTTPS_PORT'], + ); + + if ($modified !== $original) { + file_put_contents($path, $modified); + $command->info('nginx configured for '.$command->getDomain().'.'); + } + } + + protected function applyNginxSettings(string $conf, string $host, int $httpPort, int $httpsPort): string + { + // Only touch blocks that name localhost — the stub's, and the ones this method + // wrote before (it always keeps localhost as a secondary name). A vhost the user + // added themselves has no localhost and is left completely alone. Matching any + // existing name, not just the stub's, is what lets a re-install with a different + // domain replace the old one. The custom name goes first: the HTTP block + // redirects using $server_name, which resolves to the primary name. + $names = $host === 'localhost' ? 'localhost' : $host.' localhost'; + $conf = preg_replace( + '/^(\s*)server_name\s+([^;]*\blocalhost\b[^;]*);/m', + '$1server_name '.$names.';', + $conf, + ); + + // $server_name carries no port, so a redirect would land on 443 even when + // HTTPS was remapped. Matches an existing port so re-runs re-target cleanly. + $conf = preg_replace_callback( + '#https://\$server_name(:\d+)?\$request_uri#', + fn () => 'https://$server_name'.($httpsPort !== 443 ? ':'.$httpsPort : '').'$request_uri', + $conf, + ); + + // Laravel builds URLs from what PHP is told the port is; without this a stack + // moved off 443 by the port check would still advertise 443. + return preg_replace_callback( + '/^(\s*fastcgi_param\s+SERVER_PORT\s+)(\d+)(;)/m', + fn (array $m) => $m[1].($m[2] === '443' ? $httpsPort : $httpPort).$m[3], + $conf, + ); + } + + /** + * Verify every host port docker-compose.yml publishes is free before starting, + * so a clash surfaces as a choice rather than as a raw daemon bind error. + */ + protected function checkPorts(InstallCommand $command): bool + { + $wanted = $this->resolvePorts($command); + $inUse = array_filter($wanted, fn (int $port) => $this->portInUse($port)); + + if (empty($inUse)) { + return true; + } + + $owners = $this->dockerPortOwners(); + $ours = $this->ownContainers($command); + + // A resumed install finds its own containers already holding these ports. + // Remapping them would move the whole app on every retry. + $conflicts = array_filter( + $inUse, + fn (int $port) => ! in_array($owners[$port]['container'] ?? '', $ours, true), + ); + + if (empty($conflicts)) { + return true; + } + + $command->warn('Some host ports this app needs are already in use:'); + foreach ($conflicts as $key => $port) { + $command->line(sprintf(' %d (%s) — %s', $port, self::PORTS[$key][1], $this->describeOwner($owners[$port] ?? null))); + } + + $targets = $this->stoppableTargets($conflicts, $owners); + + $options = []; + if ($targets !== []) { + $options['stop'] = 'Stop '.$this->describeTargets($targets).' and continue'; + } + $options['ports'] = 'Use alternative free ports for this app'; + $options['abort'] = 'Abort the install'; + + // Alternative ports is the safe default: it is the only choice that resolves + // the clash without touching containers someone else may still be using. + $choice = $command->option('force') ? 'ports' : select( + label: 'How would you like to continue?', + options: $options, + default: 'ports', + ); + + return match ($choice) { + 'stop' => $this->stopConflicting($command, $targets, $conflicts), + 'ports' => $this->useAlternativePorts($command, $wanted, $conflicts), + default => $this->abortForPorts($command), + }; + } + + /** @return array Env key => the port that will be published. */ + protected function resolvePorts(InstallCommand $command): array + { + $ports = []; + + foreach (self::PORTS as $key => [$default]) { + $value = $this->readEnvValue($command, $key); + $ports[$key] = is_numeric($value) ? (int) $value : $default; + } + + return $ports; + } + + protected function portInUse(int $port): bool + { + // ponytail: connect-probe — misses a listener bound only to a non-loopback + // interface. A bind-probe matches Docker more closely but needs root for + // 80/443, so it would false-positive far more often. Swap if this ever bites. + $socket = @fsockopen('127.0.0.1', $port, $errno, $error, 0.3); + + if ($socket === false) { + return false; + } + + fclose($socket); + + return true; + } + + /** @return string[] Names of the containers belonging to this app's own Compose project. */ + protected function ownContainers(InstallCommand $command): array + { + $process = new Process(['docker', 'compose', 'ps', '--format', '{{.Name}}'], $command->targetPath()); + $process->setTimeout(15); + $process->run(); + + if (! $process->isSuccessful()) { + return []; + } + + return array_values(array_filter(array_map('trim', preg_split('/\R/', $process->getOutput()) ?: []))); + } + + /** @return array Host port => owner. */ + protected function dockerPortOwners(): array + { + $process = new Process([ + 'docker', 'ps', '--format', '{{.Ports}}|{{.Names}}|{{.Label "com.docker.compose.project"}}', + ]); + $process->setTimeout(15); + $process->run(); + + return $process->isSuccessful() ? $this->parseDockerPortOwners($process->getOutput()) : []; + } + + /** @return array */ + protected function parseDockerPortOwners(string $psOutput): array + { + $owners = []; + + foreach (preg_split('/\R/', trim($psOutput)) ?: [] as $line) { + if (trim($line) === '') { + continue; + } + + [$ports, $container, $project] = array_pad(explode('|', $line, 3), 3, ''); + + // Only published bindings carry a "host:port->" prefix. A bare "9000/tcp" + // is merely exposed to the container network and binds nothing on the host. + preg_match_all('/:(\d+)->/', $ports, $matches); + + foreach ($matches[1] as $port) { + $owners[(int) $port] ??= [ + 'container' => $container, + 'project' => $project !== '' ? $project : null, + ]; + } + } + + return $owners; + } + + /** @param array{container: string, project: string|null}|null $owner */ + protected function describeOwner(?array $owner): string + { + if ($owner === null) { + return 'held by another process on this machine (not Docker, so the installer cannot stop it)'; + } + + return $owner['project'] !== null + ? sprintf('held by Docker project "%s" (container %s)', $owner['project'], $owner['container']) + : sprintf('held by Docker container "%s"', $owner['container']); + } + + /** + * Docker stacks whose shutdown would clear *every* conflict. Stopping a subset + * would leave the install blocked anyway, so it is not offered. + * + * @param array $conflicts + * @param array $owners + * @return array + */ + protected function stoppableTargets(array $conflicts, array $owners): array + { + $targets = []; + + foreach ($conflicts as $port) { + if (! isset($owners[$port])) { + return []; + } + + $targets[$owners[$port]['project'] ?? $owners[$port]['container']] = $owners[$port]; + } + + return array_values($targets); + } + + /** @param array $targets */ + protected function describeTargets(array $targets): string + { + return implode(', ', array_map( + fn (array $target) => $target['project'] !== null + ? sprintf('Docker project "%s"', $target['project']) + : sprintf('Docker container "%s"', $target['container']), + $targets, + )); + } + + /** + * @param array $targets + * @param array $conflicts + */ + protected function stopConflicting(InstallCommand $command, array $targets, array $conflicts): bool + { + // Stopping someone else's containers is destructive — never without an explicit yes. + $confirmed = confirm( + label: 'Stop '.$this->describeTargets($targets).'?', + default: false, + hint: 'This shuts down containers that another app may still be using.', + ); + + if (! $confirmed) { + $command->warn('Nothing was stopped.'); + + return $this->abortForPorts($command); + } + + foreach ($targets as $target) { + $command->info('Stopping '.($target['project'] ?? $target['container']).'...'); + + $process = $target['project'] !== null + ? new Process(['docker', 'compose', '-p', $target['project'], 'stop']) + : new Process(['docker', 'stop', $target['container']]); + $process->setTimeout(120); + $process->run(); + + if (! $process->isSuccessful()) { + $command->error('Failed to stop '.($target['project'] ?? $target['container']).': '.$process->getErrorOutput()); + } + } + + $remaining = array_filter($conflicts, fn (int $port) => $this->portInUse($port)); + + if ($remaining !== []) { + $command->error('Still in use after stopping: '.implode(', ', $remaining)); + + return false; + } + + return true; + } + + /** + * @param array $wanted + * @param array $conflicts + */ + protected function useAlternativePorts(InstallCommand $command, array $wanted, array $conflicts): bool + { + // Seed with the ports we are already keeping so two services cannot collide. + $taken = array_values(array_diff_key($wanted, $conflicts)); + + foreach ($conflicts as $key => $port) { + $free = $this->freePort(self::ALTERNATIVE_PORT_BASES[$key] ?? $port + 1, $taken); + + if ($free === 0) { + $command->error(sprintf('Could not find a free port to replace %d (%s).', $port, self::PORTS[$key][1])); + + return false; + } + + $taken[] = $free; + $this->portOverrides[$key] = $free; + $command->line(sprintf(' %s: %d → %d', self::PORTS[$key][1], $port, $free)); + } + + // Re-run the single .env writer, now carrying the overrides. + $this->setDockerEnvDefaults($command); + + return true; + } + + /** @param int[] $taken */ + protected function freePort(int $base, array $taken = []): int + { + for ($port = $base; $port < $base + 100; $port++) { + if (! in_array($port, $taken, true) && ! $this->portInUse($port)) { + return $port; + } + } + + return 0; + } + + protected function abortForPorts(InstallCommand $command): bool + { + $command->error('Aborted: the ports Docker needs are in use.'); + $command->line('Free them (or stop the other app), then re-run the install.'); + + return false; + } + protected function startDocker(InstallCommand $command): bool { $command->info('Starting Docker services (this may take a few minutes while pulling images and starting containers)...'); @@ -299,11 +701,12 @@ protected function installModules(InstallCommand $command): bool protected function nextSteps(InstallCommand $command): array { $appUrl = $this->readEnvValue($command, 'APP_URL') ?? ($this->ssl ? 'https://localhost' : 'http://localhost'); + $mailpit = $this->readEnvValue($command, 'FORWARD_MAILPIT_DASHBOARD_PORT') ?? '8025'; return [ 'Compile frontend assets: `npm install && npm run dev`', 'Open your app: `'.$appUrl.'`', - 'Email testing (Mailpit): `http://localhost:8025`', + 'Email testing (Mailpit): `http://localhost:'.$mailpit.'`', ]; } @@ -328,21 +731,29 @@ protected function setDockerEnvDefaults(InstallCommand $command): void return; } - $modified = $this->applyDockerEnvDefaults($original, $this->ssl); + $modified = $this->applyDockerEnvDefaults($original, $this->ssl, $this->portOverrides); if ($modified !== $original) { file_put_contents($path, $modified); - $command->info('Docker database credentials written to .env.'); + $command->info('Docker settings written to .env.'); } } - protected function applyDockerEnvDefaults(string $env, bool $ssl = true): string + /** @param array $ports Env key => port, forced over any existing value. */ + protected function applyDockerEnvDefaults(string $env, bool $ssl = true, array $ports = []): string { $slug = 'saucebase'; if (preg_match('/^APP_SLUG=([^\s]+)/m', $env, $m)) { $slug = trim($m[1], "\"'"); } + // The identity pass (InstallCommand::applyAppIdentity) always runs first, so + // APP_HOST carries the domain that was chosen for this app. + $host = 'localhost'; + if (preg_match('/^APP_HOST=([^\s]+)/m', $env, $m)) { + $host = trim($m[1], "\"'"); + } + // Docker always needs mysql, not sqlite if (preg_match('/^DB_CONNECTION=(.*)$/m', $env, $m) && trim($m[1]) !== 'mysql') { $env = preg_replace('/^DB_CONNECTION=.*$/m', 'DB_CONNECTION=mysql', $env); @@ -357,11 +768,24 @@ protected function applyDockerEnvDefaults(string $env, bool $ssl = true): string $env .= "\nMAIL_MAILER=smtp"; } - // Set APP_URL to match the chosen SSL mode - $defaultUrl = $ssl ? 'https://localhost' : 'http://localhost'; + // Set APP_URL to match the chosen host and SSL mode, carrying the published + // port whenever it is not the scheme's default one. + $portKey = $ssl ? 'APP_HTTPS_PORT' : 'APP_PORT'; + $appPort = $ports[$portKey] ?? null; + + // No override this run: fall back to a port an earlier run already persisted, + // otherwise a resume would silently drop :8443 from the URL. + if ($appPort === null && preg_match('/^'.$portKey.'=(\d+)/m', $env, $m)) { + $appPort = (int) $m[1]; + } + + $suffix = ($appPort !== null && $appPort !== ($ssl ? 443 : 80)) ? ':'.$appPort : ''; + $defaultUrl = ($ssl ? 'https' : 'http').'://'.$host.$suffix; if (preg_match('/^APP_URL=(.*)$/m', $env, $m)) { $url = trim($m[1], "\"'"); - if (preg_match('#^https?://localhost(:\d+)?/?$#', $url)) { + // Correct a URL for localhost or for the chosen host (scheme and port may + // have changed); leave a genuinely custom one alone. + if (preg_match('#^https?://(localhost|'.preg_quote($host, '#').')(:\d+)?/?$#', $url)) { $env = preg_replace('/^APP_URL=.*$/m', "APP_URL={$defaultUrl}", $env); } } else { @@ -387,9 +811,20 @@ protected function applyDockerEnvDefaults(string $env, bool $ssl = true): string } } + // Port overrides are forced, not defaulted: the value already there is the + // one that clashed. + foreach ($ports as $key => $port) { + $env = InstallCommand::setEnvLine($env, $key, (string) $port); + } + return $env; } + protected function resumeOptions(): array + { + return array_merge(parent::resumeOptions(), ['--ssl' => $this->ssl ? 'yes' : 'no']); + } + protected function dockerComposeAvailable(): bool { return (bool) shell_exec('docker compose version 2>/dev/null'); diff --git a/src/Environments/Environment.php b/src/Environments/Environment.php index ac2a3c4..111f724 100644 --- a/src/Environments/Environment.php +++ b/src/Environments/Environment.php @@ -6,6 +6,9 @@ abstract class Environment { + /** Label of the step that failed, for the failure callout. Set via fail(). */ + protected ?string $failedStep = null; + public static function make(string $name): self { return match ($name) { @@ -30,15 +33,48 @@ public function run(InstallCommand $command): int $command->promptForModules(); + // Only an explicit --modules= list reaches the installer unfiltered. The prompt + // and --all-modules paths already filter by framework, and checking them here + // would cost a needless Packagist round trip. + $explicit = $command->optionOrNull('modules'); + + if (is_string($explicit) && $explicit !== '' && $explicit !== 'none' + && ! $command->assertModulesSupportStack($this->resolveModules($command))) { + return $this->fail('Checking module compatibility'); + } + $result = $this->boot($command); if ($result === InstallCommand::SUCCESS) { $command->displaySuccess(array_merge($this->cdStep($command), $this->nextSteps($command))); + } else { + // Single exit point for every failing step in boot() — the install must + // never end silently, since the app directory already exists by now. + $command->displayFailure($this->failedStep, $this->resumeOptions()); } return $result; } + /** Record which step failed, then return FAILURE for boot() to propagate. */ + protected function fail(string $step): int + { + $this->failedStep = $step; + + return InstallCommand::FAILURE; + } + + /** + * Resolved answers a resume run must carry, including ones that came from prompts + * rather than options. + * + * @return array + */ + protected function resumeOptions(): array + { + return ['--driver' => $this->name()]; + } + /** @return string[] A `cd` step when the target app lives outside the current directory, empty otherwise. */ protected function cdStep(InstallCommand $command): array { diff --git a/tests/Feature/Environments/DockerEnvironmentTest.php b/tests/Feature/Environments/DockerEnvironmentTest.php index 8b73ae8..47cebe9 100644 --- a/tests/Feature/Environments/DockerEnvironmentTest.php +++ b/tests/Feature/Environments/DockerEnvironmentTest.php @@ -447,6 +447,138 @@ public function rewriteCrossModuleImports(): void {} $this->assertSame(Command::FAILURE, $result); } + // ------------------------------------------------------------------------- + // Port conflict detection + // ------------------------------------------------------------------------- + + /** Verbatim `docker ps` output from a machine running a conflicting Saucebase stack. */ + private const DOCKER_PS = <<<'OUTPUT' + |whatsthere-queue-1|whatsthere + 9000/tcp|whatsthere-app-1|whatsthere + 0.0.0.0:6379->6379/tcp, [::]:6379->6379/tcp|whatsthere-redis-1|whatsthere + 0.0.0.0:3306->3306/tcp, [::]:3306->3306/tcp|whatsthere-mysql-1|whatsthere + 0.0.0.0:1025->1025/tcp, [::]:1025->1025/tcp, 0.0.0.0:8025->8025/tcp, [::]:8025->8025/tcp|whatsthere-mailpit-1|whatsthere + OUTPUT; + + public function test_parses_published_ports_to_their_owning_compose_project(): void + { + $owners = $this->exposed()->exposedParseDockerPortOwners(self::DOCKER_PS); + + $this->assertSame(['container' => 'whatsthere-redis-1', 'project' => 'whatsthere'], $owners[6379]); + $this->assertSame(['container' => 'whatsthere-mysql-1', 'project' => 'whatsthere'], $owners[3306]); + // One container can publish several ports. + $this->assertSame(['container' => 'whatsthere-mailpit-1', 'project' => 'whatsthere'], $owners[1025]); + $this->assertSame(['container' => 'whatsthere-mailpit-1', 'project' => 'whatsthere'], $owners[8025]); + $this->assertCount(4, $owners); + } + + public function test_ignores_exposed_but_unpublished_container_ports(): void + { + $owners = $this->exposed()->exposedParseDockerPortOwners(self::DOCKER_PS); + + // "9000/tcp" is exposed to the container network only — it binds no host port. + $this->assertArrayNotHasKey(9000, $owners); + } + + public function test_parses_a_standalone_container_as_having_no_project(): void + { + $owners = $this->exposed()->exposedParseDockerPortOwners("0.0.0.0:6379->6379/tcp|my-redis|\n"); + + $this->assertSame(['container' => 'my-redis', 'project' => null], $owners[6379]); + } + + public function test_parses_empty_docker_output_to_no_owners(): void + { + $this->assertSame([], $this->exposed()->exposedParseDockerPortOwners('')); + $this->assertSame([], $this->exposed()->exposedParseDockerPortOwners("\n \n")); + } + + public function test_free_port_skips_ports_in_use_and_ports_already_assigned(): void + { + $env = $this->exposed(inUse: [3307, 3308]); + + $this->assertSame(3310, $env->exposedFreePort(3307, taken: [3309])); + } + + public function test_free_port_returns_zero_when_the_scan_window_is_exhausted(): void + { + $env = $this->exposed(inUse: range(8080, 8300)); + + $this->assertSame(0, $env->exposedFreePort(8080)); + } + + public function test_check_ports_passes_without_querying_docker_when_nothing_conflicts(): void + { + $env = $this->exposed(inUse: []); + + $this->assertTrue($env->exposedCheckPorts(new FakeInstallCommand(null, [], ['path' => '/nonexistent']))); + $this->assertFalse($env->dockerQueried, 'docker ps must not run when no port conflicts'); + } + + public function test_check_ports_picks_free_alternatives_under_force_without_prompting(): void + { + // The reported real-world clash: another stack holding MySQL, Redis and Mailpit. + $env = $this->exposed(inUse: [3306, 6379, 1025, 8025]); + + $result = $env->exposedCheckPorts(new FakeInstallCommand(null, [], ['path' => '/nonexistent', 'force' => true])); + + $this->assertTrue($result); + $this->assertSame([ + 'FORWARD_DB_PORT' => 3307, + 'FORWARD_REDIS_PORT' => 6380, + 'FORWARD_MAILPIT_PORT' => 1026, + 'FORWARD_MAILPIT_DASHBOARD_PORT' => 8026, + ], $env->exposedPortOverrides()); + $this->assertTrue($env->envRewritten, 'the .env writer must re-run so overrides are persisted'); + // Free ports are left alone. + $this->assertArrayNotHasKey('APP_PORT', $env->exposedPortOverrides()); + } + + public function test_a_resumed_install_does_not_treat_its_own_containers_as_conflicts(): void + { + // Resuming after a mid-install failure: our own stack is up and holding the + // ports. Remapping them here would move the whole app on every retry. + $env = $this->exposed( + inUse: [3306, 6379], + owners: [ + 3306 => ['container' => 'my-app-mysql-1', 'project' => 'my-app'], + 6379 => ['container' => 'my-app-redis-1', 'project' => 'my-app'], + ], + ); + $env->fakeOwnContainers = ['my-app-mysql-1', 'my-app-redis-1', 'my-app-app-1']; + + $result = $env->exposedCheckPorts(new FakeInstallCommand(null, [], ['path' => '/nonexistent', 'force' => true])); + + $this->assertTrue($result); + $this->assertSame([], $env->exposedPortOverrides(), 'a resume must not remap its own ports'); + } + + public function test_a_foreign_stack_is_still_a_conflict_when_our_own_containers_run(): void + { + $env = $this->exposed( + inUse: [3306, 6379], + owners: [ + 3306 => ['container' => 'my-app-mysql-1', 'project' => 'my-app'], + 6379 => ['container' => 'other-redis-1', 'project' => 'other'], + ], + ); + $env->fakeOwnContainers = ['my-app-mysql-1']; + + $env->exposedCheckPorts(new FakeInstallCommand(null, [], ['path' => '/nonexistent', 'force' => true])); + + // Ours is ignored, the foreign one is remapped. + $this->assertSame(['FORWARD_REDIS_PORT' => 6380], $env->exposedPortOverrides()); + } + + public function test_check_ports_does_not_offer_to_stop_docker_when_a_plain_process_holds_a_port(): void + { + $env = $this->exposed(inUse: [6379, 8025], owners: [6379 => ['container' => 'other-redis', 'project' => 'other']]); + $env->exposedCheckPorts(new FakeInstallCommand(null, [], ['path' => '/nonexistent', 'force' => true])); + + // 8025 has no Docker owner, so stopping "other" would not unblock the install. + $this->assertSame([], $env->exposedStoppableTargets(['a' => 6379, 'b' => 8025], $env->fakeOwners)); + } + // ------------------------------------------------------------------------- // applyDockerEnvDefaults // ------------------------------------------------------------------------- @@ -590,6 +722,115 @@ public function test_leaves_custom_app_url_unchanged_regardless_of_ssl(): void $this->assertStringContainsString('APP_URL=https://myapp.test', $this->applyDefaults($input, ssl: false)); } + public function test_port_overrides_are_written_to_env(): void + { + $result = $this->applyDefaults("APP_NAME=Test\n", ports: ['FORWARD_DB_PORT' => 3307, 'APP_PORT' => 8080]); + + $this->assertStringContainsString('FORWARD_DB_PORT=3307', $result); + $this->assertStringContainsString('APP_PORT=8080', $result); + } + + public function test_port_overrides_replace_the_value_that_clashed(): void + { + // Unlike the DB defaults, an existing port value must NOT be respected — + // it is precisely the one that was found to be in use. + $result = $this->applyDefaults("FORWARD_REDIS_PORT=6379\n", ports: ['FORWARD_REDIS_PORT' => 6380]); + + $this->assertStringContainsString('FORWARD_REDIS_PORT=6380', $result); + $this->assertStringNotContainsString('FORWARD_REDIS_PORT=6379', $result); + } + + public function test_app_url_carries_a_non_default_https_port(): void + { + $result = $this->applyDefaults("APP_URL=https://localhost\n", ssl: true, ports: ['APP_HTTPS_PORT' => 8443]); + + $this->assertStringContainsString('APP_URL=https://localhost:8443', $result); + } + + public function test_app_url_carries_a_non_default_http_port_when_ssl_is_disabled(): void + { + $result = $this->applyDefaults("APP_URL=http://localhost\n", ssl: false, ports: ['APP_PORT' => 8080]); + + $this->assertStringContainsString('APP_URL=http://localhost:8080', $result); + } + + public function test_app_url_stays_bare_when_the_port_is_the_scheme_default(): void + { + $result = $this->applyDefaults("APP_URL=https://localhost\n", ssl: true, ports: ['APP_HTTPS_PORT' => 443]); + + $this->assertStringContainsString('APP_URL=https://localhost'."\n", $result); + } + + public function test_a_previously_ported_app_url_is_rewritten_to_the_new_port(): void + { + // The second pass over .env must correct a port this installer wrote earlier. + $result = $this->applyDefaults("APP_URL=https://localhost:8443\n", ssl: true, ports: ['APP_HTTPS_PORT' => 8444]); + + $this->assertStringContainsString('APP_URL=https://localhost:8444', $result); + $this->assertStringNotContainsString('8443', $result); + } + + public function test_a_custom_app_url_is_left_alone_even_with_port_overrides(): void + { + $result = $this->applyDefaults("APP_URL=https://myapp.test\n", ssl: true, ports: ['APP_HTTPS_PORT' => 8443]); + + $this->assertStringContainsString('APP_URL=https://myapp.test', $result); + } + + // ------------------------------------------------------------------------- + // Custom domain + // ------------------------------------------------------------------------- + + public function test_app_url_is_built_from_app_host(): void + { + $result = $this->applyDefaults("APP_HOST=myapp.test\nAPP_URL=http://localhost\n", ssl: true); + + $this->assertStringContainsString('APP_URL=https://myapp.test', $result); + } + + public function test_app_url_combines_a_custom_host_with_a_remapped_port(): void + { + $result = $this->applyDefaults( + "APP_HOST=myapp.test\nAPP_URL=http://localhost\n", + ssl: true, + ports: ['APP_HTTPS_PORT' => 8443], + ); + + $this->assertStringContainsString('APP_URL=https://myapp.test:8443', $result); + } + + public function test_a_stale_url_for_the_chosen_host_is_corrected(): void + { + // Re-run after the port moved: same host, so the scheme/port must be refreshed. + $result = $this->applyDefaults( + "APP_HOST=myapp.test\nAPP_URL=http://myapp.test:8080\n", + ssl: true, + ports: ['APP_HTTPS_PORT' => 8443], + ); + + $this->assertStringContainsString('APP_URL=https://myapp.test:8443', $result); + } + + public function test_a_port_persisted_by_an_earlier_run_survives_a_resume(): void + { + // Resume: the port keys are already in .env and nothing is overridden this + // run, so the URL must keep :8443 instead of falling back to 443. + $result = $this->applyDefaults( + "APP_HOST=myapp.test\nAPP_HTTPS_PORT=8443\nAPP_URL=https://myapp.test:8443\n", + ssl: true, + ports: [], + ); + + $this->assertStringContainsString('APP_URL=https://myapp.test:8443', $result); + } + + public function test_a_url_for_an_unrelated_host_is_still_left_alone(): void + { + $result = $this->applyDefaults("APP_HOST=myapp.test\nAPP_URL=https://staging.example.com\n", ssl: true); + + $this->assertStringContainsString('APP_URL=https://staging.example.com', $result); + } + public function test_real_env_example_pattern_produces_valid_docker_env(): void { $input = implode("\n", [ @@ -615,21 +856,284 @@ public function test_real_env_example_pattern_produces_valid_docker_env(): void $this->assertStringContainsString('APP_URL=https://localhost', $result); } + // ------------------------------------------------------------------------- + // nginx + certificates for a custom domain + // ------------------------------------------------------------------------- + + /** The real published stub, so the substitutions are tested against what ships. */ + private function nginxStub(): string + { + return file_get_contents(dirname(__DIR__, 3).'/stubs/docker/docker/nginx.conf'); + } + + public function test_nginx_serves_the_custom_domain_first_then_localhost(): void + { + $result = $this->exposed()->exposedApplyNginxSettings($this->nginxStub(), 'myapp.test', 80, 443); + + // Primary name first: the HTTP block redirects using $server_name. + $this->assertStringContainsString('server_name myapp.test localhost;', $result); + $this->assertStringNotContainsString('server_name localhost;', $result); + } + + public function test_nginx_is_untouched_for_a_localhost_install(): void + { + $stub = $this->nginxStub(); + + $this->assertSame($stub, $this->exposed()->exposedApplyNginxSettings($stub, 'localhost', 80, 443)); + } + + public function test_nginx_follows_a_domain_change_on_a_re_run(): void + { + $env = $this->exposed(); + + // publishStubs() skips an existing file, so this rewrite is the only thing + // that keeps a re-installed app's nginx.conf in step with a new domain. + $first = $env->exposedApplyNginxSettings($this->nginxStub(), 'old.test', 80, 443); + $second = $env->exposedApplyNginxSettings($first, 'new.test', 80, 443); + + $this->assertStringContainsString('server_name new.test localhost;', $second); + $this->assertStringNotContainsString('old.test', $second); + } + + public function test_nginx_reports_the_port_actually_published(): void + { + // Without this, a stack moved to 8443 by the port check still tells PHP 443 + // and Laravel generates URLs on the wrong port. + $result = $this->exposed()->exposedApplyNginxSettings($this->nginxStub(), 'localhost', 8080, 8443); + + $this->assertStringContainsString('fastcgi_param SERVER_PORT 8443;', $result); + $this->assertStringNotContainsString('SERVER_PORT 443;', $result); + } + + public function test_nginx_no_ssl_stub_gets_the_http_port(): void + { + $stub = file_get_contents(dirname(__DIR__, 3).'/stubs/docker/docker/nginx-no-ssl.conf'); + $result = $this->exposed()->exposedApplyNginxSettings($stub, 'localhost', 8080, 8443); + + $this->assertStringContainsString('fastcgi_param SERVER_PORT 8080;', $result); + } + + public function test_nginx_leaves_a_user_added_vhost_alone(): void + { + $conf = $this->nginxStub()."\n".implode("\n", [ + 'server {', + ' listen 8080;', + ' server_name shop.example.com;', + '}', + ]); + + $result = $this->exposed()->exposedApplyNginxSettings($conf, 'myapp.test', 80, 443); + + // Only the installer's own blocks (which always name localhost) are rewritten. + $this->assertStringContainsString('server_name shop.example.com;', $result); + $this->assertStringContainsString('server_name myapp.test localhost;', $result); + } + + public function test_nginx_redirect_carries_a_remapped_https_port(): void + { + $result = $this->exposed()->exposedApplyNginxSettings($this->nginxStub(), 'myapp.test', 8080, 8443); + + // $server_name has no port, so the redirect would otherwise land on 443. + $this->assertStringContainsString('https://$server_name:8443$request_uri', $result); + } + + public function test_nginx_redirect_stays_bare_on_the_default_port(): void + { + $result = $this->exposed()->exposedApplyNginxSettings($this->nginxStub(), 'myapp.test', 80, 443); + + $this->assertStringContainsString('https://$server_name$request_uri', $result); + $this->assertStringNotContainsString('$server_name:', $result); + } + + public function test_nginx_redirect_is_retargeted_on_a_re_run(): void + { + $first = $this->exposed()->exposedApplyNginxSettings($this->nginxStub(), 'myapp.test', 8080, 8443); + $second = $this->exposed()->exposedApplyNginxSettings($first, 'myapp.test', 80, 443); + + $this->assertStringContainsString('https://$server_name$request_uri', $second); + $this->assertStringNotContainsString('8443', $second); + } + + public function test_certificate_covers_the_custom_domain_and_localhost(): void + { + $hosts = $this->exposed()->exposedCertificateHosts('myapp.test'); + + $this->assertContains('myapp.test', $hosts); + $this->assertContains('*.myapp.test', $hosts); + $this->assertContains('localhost', $hosts); + } + + public function test_certificate_hosts_do_not_repeat_localhost(): void + { + $hosts = $this->exposed()->exposedCertificateHosts('localhost'); + + $this->assertSame(array_unique($hosts), $hosts); + $this->assertSame(['localhost', '*.localhost', '127.0.0.1', '::1'], $hosts); + } + + // ------------------------------------------------------------------------- + // Failure messaging + // ------------------------------------------------------------------------- + + public function test_run_reports_the_failed_step_and_a_resume_command(): void + { + $env = new class extends DockerEnvironment + { + protected function beforePrompts(InstallCommand $command): ?int + { + return null; + } + + protected function publishStubs(InstallCommand $command): void {} + + protected function generateSsl(InstallCommand $command): void {} + + protected function setDockerEnvDefaults(InstallCommand $command): void {} + + protected function checkPorts(InstallCommand $command): bool + { + return true; + } + + protected function startDocker(InstallCommand $command): bool + { + return false; + } + }; + + $command = new class extends FakeInstallCommand + { + public ?string $failedStep = null; + + public array $resumeOptions = []; + + public function __construct() + { + parent::__construct(null, [], []); + } + + public function ensureEnvFile(): bool + { + return true; + } + + public function promptForModules(): void {} + + public function displayFailure(?string $step = null, array $resumeOptions = []): void + { + $this->failedStep = $step; + $this->resumeOptions = $resumeOptions; + } + }; + + $this->assertSame(Command::FAILURE, $env->run($command)); + $this->assertSame('Starting Docker services', $command->failedStep); + // A resume must not re-prompt for the driver or SSL. + $this->assertSame(['--driver' => 'docker', '--ssl' => 'yes'], $command->resumeOptions); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- - private function applyDefaults(string $env, bool $ssl = true): string + /** + * A DockerEnvironment with the network probes stubbed out, exposing the pure logic. + * + * @param int[] $inUse + * @param array $owners + */ + private function exposed(array $inUse = [], array $owners = []): object + { + return new class($inUse, $owners) extends DockerEnvironment + { + public bool $dockerQueried = false; + + public bool $envRewritten = false; + + /** @param int[] $inUse */ + public function __construct(private array $inUse, public array $fakeOwners) {} + + protected function portInUse(int $port): bool + { + return in_array($port, $this->inUse, true); + } + + protected function dockerPortOwners(): array + { + $this->dockerQueried = true; + + return $this->fakeOwners; + } + + /** @var string[] Containers this app's own Compose project is running. */ + public array $fakeOwnContainers = []; + + protected function ownContainers(InstallCommand $command): array + { + return $this->fakeOwnContainers; + } + + protected function setDockerEnvDefaults(InstallCommand $command): void + { + $this->envRewritten = true; + } + + public function exposedParseDockerPortOwners(string $psOutput): array + { + return $this->parseDockerPortOwners($psOutput); + } + + /** @param int[] $taken */ + public function exposedFreePort(int $base, array $taken = []): int + { + return $this->freePort($base, $taken); + } + + public function exposedCheckPorts(InstallCommand $command): bool + { + return $this->checkPorts($command); + } + + public function exposedStoppableTargets(array $conflicts, array $owners): array + { + return $this->stoppableTargets($conflicts, $owners); + } + + /** @return array */ + public function exposedPortOverrides(): array + { + return $this->portOverrides; + } + + public function exposedApplyNginxSettings(string $conf, string $host, int $http, int $https): string + { + return $this->applyNginxSettings($conf, $host, $http, $https); + } + + /** @return string[] */ + public function exposedCertificateHosts(string $host): array + { + return $this->certificateHosts($host); + } + + public function exposedCertCoversHost(string $certFile, string $host): bool + { + return $this->certCoversHost($certFile, $host); + } + }; + } + + private function applyDefaults(string $env, bool $ssl = true, array $ports = []): string { $exposed = new class extends DockerEnvironment { - public function applyDockerEnvDefaults(string $env, bool $ssl = true): string + public function applyDockerEnvDefaults(string $env, bool $ssl = true, array $ports = []): string { - return parent::applyDockerEnvDefaults($env, $ssl); + return parent::applyDockerEnvDefaults($env, $ssl, $ports); } }; - return $exposed->applyDockerEnvDefaults($env, $ssl); + return $exposed->applyDockerEnvDefaults($env, $ssl, $ports); } /** diff --git a/tests/Feature/InstallCommandIdentityTest.php b/tests/Feature/InstallCommandIdentityTest.php new file mode 100644 index 0000000..9313a43 --- /dev/null +++ b/tests/Feature/InstallCommandIdentityTest.php @@ -0,0 +1,182 @@ +exposedApplyIdentityToEnv( + $env, + \Illuminate\Support\Str::headline($dir), + \Illuminate\Support\Str::slug($dir), + $host, + $native, + ); + } + + public function test_names_the_app_after_its_directory(): void + { + $result = $this->apply("APP_NAME=\"Saucebase\"\nAPP_SLUG=saucebase\n"); + + $this->assertStringContainsString('APP_NAME="My App"', $result); + $this->assertStringContainsString('APP_SLUG=my-app', $result); + } + + public function test_leaves_a_single_word_name_unquoted(): void + { + $result = $this->apply("APP_NAME=Saucebase\n", dir: 'whatsthere'); + + $this->assertStringContainsString('APP_NAME=Whatsthere', $result); + $this->assertStringNotContainsString('"', $result); + } + + public function test_does_not_clobber_a_name_the_user_has_set(): void + { + $result = $this->apply("APP_NAME=\"Acme Corp\"\nAPP_SLUG=acme\n", host: 'localhost'); + + $this->assertStringContainsString('APP_NAME="Acme Corp"', $result); + $this->assertStringContainsString('APP_SLUG=acme', $result); + $this->assertStringNotContainsString('My App', $result); + } + + public function test_an_explicit_domain_change_reaches_app_host(): void + { + // APP_HOST is installer-owned: the certificate and nginx config are built from + // the resolved domain, so a stale host here would desynchronise them. The old + // value is not lost — captureDomain() offers it as the prompt default. + $result = $this->apply("APP_HOST=old.test\n", host: 'new.test'); + + $this->assertStringContainsString('APP_HOST=new.test', $result); + $this->assertStringNotContainsString('old.test', $result); + } + + public function test_fills_a_blank_value(): void + { + $result = $this->apply("APP_NAME=\nAPP_SLUG=\n"); + + $this->assertStringContainsString('APP_NAME="My App"', $result); + $this->assertStringContainsString('APP_SLUG=my-app', $result); + } + + public function test_appends_keys_that_are_absent(): void + { + $result = $this->apply("APP_ENV=local\n", host: 'my-app.test'); + + $this->assertStringContainsString('APP_SLUG=my-app', $result); + $this->assertStringContainsString('APP_HOST=my-app.test', $result); + } + + public function test_records_the_chosen_host(): void + { + $result = $this->apply("APP_HOST=localhost\n", host: 'my-app.test'); + + $this->assertStringContainsString('APP_HOST=my-app.test', $result); + } + + public function test_native_sets_app_url_only_for_a_custom_host(): void + { + // localhost: leave laravel/installer's http://localhost:8000, which is what + // `composer dev` actually binds. + $localhost = $this->apply("APP_URL=http://localhost:8000\n", native: true); + $this->assertStringContainsString('APP_URL=http://localhost:8000', $localhost); + + $custom = $this->apply("APP_URL=http://localhost:8000\n", host: 'my-app.test', native: true); + $this->assertStringContainsString('APP_URL=http://my-app.test', $custom); + } + + public function test_docker_never_writes_app_url_here(): void + { + // Docker's APP_URL is owned by applyDockerEnvDefaults(), the only place that + // knows the scheme and the published port. + $result = $this->apply("APP_URL=http://localhost:8000\n", host: 'my-app.test', native: false); + + $this->assertStringContainsString('APP_URL=http://localhost:8000', $result); + } + + // ------------------------------------------------------------------------- + // Domain normalisation + // ------------------------------------------------------------------------- + + public function test_normalizes_a_pasted_url_to_a_bare_host(): void + { + $this->assertSame('my.app.test', InstallCommand::normalizeDomain('https://My.App.test:8443/')); + $this->assertSame('localhost', InstallCommand::normalizeDomain(' LOCALHOST ')); + $this->assertSame('myapp.test', InstallCommand::normalizeDomain('myapp.test')); + } + + public function test_rejects_a_value_that_is_not_a_hostname(): void + { + $this->assertNull(InstallCommand::normalizeDomain('my app.test')); + $this->assertNull(InstallCommand::normalizeDomain('https://')); + $this->assertNull(InstallCommand::normalizeDomain('under_score.test')); + } + + // ------------------------------------------------------------------------- + // Module / stack compatibility + // ------------------------------------------------------------------------- + + private function gate(?string $stack, array $fixtures): TestableInstallCommand + { + // Captures output instead of writing it: the gate reports through error()/line(), + // and no console output is bound to a directly-instantiated command. + $command = new class extends TestableInstallCommand + { + /** @var string[] */ + public array $messages = []; + + public function error($string, $verbosity = null): void + { + $this->messages[] = $string; + } + + public function line($string, $style = null, $verbosity = null): void + { + $this->messages[] = $string; + } + }; + + $command->setSelectedStack($stack); + $command->frameworkFixtures = $fixtures; + + return $command; + } + + public function test_accepts_modules_that_support_the_chosen_stack(): void + { + $command = $this->gate('react', [ + 'saucebase/auth' => ['vue', 'react'], + 'saucebase/settings' => ['react'], + ]); + + $this->assertTrue($command->assertModulesSupportStack(['saucebase/auth', 'saucebase/settings'])); + } + + public function test_rejects_a_module_that_does_not_support_the_chosen_stack(): void + { + $command = $this->gate('react', [ + 'saucebase/auth' => ['vue', 'react'], + 'saucebase/billing' => ['vue'], + ]); + + $this->assertFalse($command->assertModulesSupportStack(['saucebase/auth', 'saucebase/billing'])); + + // Must name the offender, not just fail. + $output = implode("\n", $command->messages); + $this->assertStringContainsString('saucebase/billing', $output); + $this->assertStringNotContainsString('saucebase/auth', $output); + } + + public function test_compatibility_gate_is_a_no_op_without_a_stack_or_modules(): void + { + $this->assertTrue($this->gate(null, ['saucebase/billing' => ['vue']]) + ->assertModulesSupportStack(['saucebase/billing'])); + + $this->assertTrue($this->gate('react', [])->assertModulesSupportStack([])); + } +} diff --git a/tests/Feature/InstallCommandResumeTest.php b/tests/Feature/InstallCommandResumeTest.php new file mode 100644 index 0000000..45e0341 --- /dev/null +++ b/tests/Feature/InstallCommandResumeTest.php @@ -0,0 +1,111 @@ + $options + * @param string[] $modules + */ + private function command(array $options, ?string $stack = 'vue', array $modules = []): TestableInstallCommand + { + $command = new TestableInstallCommand; + $command->fakeOptions = $options; + $command->setSelectedStack($stack); + $command->setSelectedModules($modules); + + return $command; + } + + public function test_resume_command_bakes_in_every_answer_so_nothing_is_re_prompted(): void + { + $command = $this->command( + ['path' => '/tmp/whatsthere'], + modules: ['saucebase/auth', 'saucebase/billing'], + ); + + $this->assertSame( + 'cd /tmp/whatsthere && saucebase install vue --driver=docker --ssl=yes --modules=saucebase/auth,saucebase/billing', + $command->resumeCommand(['--driver' => 'docker', '--ssl' => 'yes']), + ); + } + + public function test_resume_command_prefers_the_modules_option_over_the_prompted_selection(): void + { + $command = $this->command( + ['path' => '/tmp/app', 'modules' => 'none'], + modules: ['saucebase/auth'], + ); + + $this->assertStringContainsString('--modules=none', $command->resumeCommand()); + $this->assertStringNotContainsString('saucebase/auth', $command->resumeCommand()); + } + + public function test_resume_command_omits_modules_when_all_modules_is_set(): void + { + $resume = $this->command(['path' => '/tmp/app', 'all-modules' => true])->resumeCommand(); + + $this->assertStringContainsString('--all-modules', $resume); + $this->assertStringNotContainsString('--modules=', $resume); + } + + public function test_resume_command_carries_the_flags_through(): void + { + $resume = $this->command(['path' => '/tmp/app', 'dev' => true, 'fresh' => true, 'force' => true])->resumeCommand(); + + $this->assertStringContainsString('--dev', $resume); + $this->assertStringContainsString('--fresh', $resume); + $this->assertStringContainsString('--force', $resume); + } + + public function test_resume_command_drops_the_cd_when_the_target_is_the_current_directory(): void + { + $resume = $this->command(['path' => getcwd()])->resumeCommand(['--driver' => 'native']); + + $this->assertSame('saucebase install vue --driver=native', $resume); + } + + public function test_resume_command_quotes_a_path_containing_spaces(): void + { + $resume = $this->command(['path' => '/tmp/my app'])->resumeCommand(); + + $this->assertStringStartsWith("cd '/tmp/my app' && saucebase install", $resume); + } + + public function test_resume_command_escapes_shell_metacharacters_in_the_path(): void + { + // A pasted command must not expand or execute anything from the path. + $resume = $this->command(['path' => '/tmp/a$(whoami)`id`;rm'])->resumeCommand(); + + $this->assertStringStartsWith("cd '/tmp/a\$(whoami)`id`;rm' && ", $resume); + } + + public function test_resume_command_leaves_an_ordinary_path_unquoted(): void + { + $this->assertStringStartsWith( + 'cd /tmp/whatsthere && ', + $this->command(['path' => '/tmp/whatsthere'])->resumeCommand(), + ); + } + + public function test_failure_callout_names_the_step_and_the_resume_command(): void + { + $content = $this->command(['path' => '/tmp/whatsthere']) + ->exposedFailureCalloutContent('Starting Docker services', ['--driver' => 'docker']); + + $this->assertSame('Failed at: Starting Docker services', $content[0]); + $this->assertStringContainsString('directory', implode(' ', $content)); + $this->assertStringContainsString('cd /tmp/whatsthere && saucebase install vue --driver=docker', end($content)); + } + + public function test_failure_callout_omits_the_step_line_when_the_step_is_unknown(): void + { + $content = $this->command(['path' => '/tmp/app'])->exposedFailureCalloutContent(null, []); + + $this->assertStringNotContainsString('Failed at:', implode(' ', $content)); + } +} diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 49374f0..9cfbf71 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -702,85 +702,3 @@ private function fakePackagistList(): void ]); } } - -/** - * Exposes protected methods for direct testing. - * - * @internal - */ -class TestableInstallCommand extends InstallCommand -{ - /** @var array Pre-built framework map (overrides HTTP for filtering tests). */ - public array $frameworkFixtures = []; - - private ?string $customModulesBasePath; - - public function setSelectedStack(?string $stack): void - { - $this->selectedStack = $stack; - } - - public function __construct(?string $modulesBasePath = null) - { - parent::__construct(); - $this->customModulesBasePath = $modulesBasePath; - } - - public function exposedFetchPackageFrameworks(string $package): array - { - return $this->fetchPackageFrameworks($package); - } - - /** @param string[] $packages */ - public function exposedFilterModulesByFramework(array $packages, string $framework): array - { - return $this->filterModulesByFramework($packages, $framework); - } - - public function exposedSuccessCalloutContent(array $steps): array - { - return $this->successCalloutContent($steps); - } - - /** @var array Fake option values for tests that bypass CLI input. */ - public array $fakeOptions = []; - - public function option($key = null): string|array|bool|null - { - if (! empty($this->fakeOptions)) { - return $key !== null ? ($this->fakeOptions[$key] ?? false) : $this->fakeOptions; - } - - return parent::option($key); - } - - /** @param string[] $available */ - public function exposedResolveModuleSelection(array $available): array - { - return $this->resolveModuleSelection($available); - } - - public function exposedSetupModules(): void - { - $this->setupModules(); - } - - protected function doInstallModules(array $selected): void - { - // no-op — prevents composer require from running in unit tests - } - - protected function fetchPackageFrameworks(string $package): array - { - if (isset($this->frameworkFixtures[$package])) { - return $this->frameworkFixtures[$package]; - } - - return parent::fetchPackageFrameworks($package); - } - - protected function modulesBasePath(): string - { - return $this->customModulesBasePath ?? parent::modulesBasePath(); - } -} diff --git a/tests/Feature/TestableInstallCommand.php b/tests/Feature/TestableInstallCommand.php new file mode 100644 index 0000000..8d8a6cc --- /dev/null +++ b/tests/Feature/TestableInstallCommand.php @@ -0,0 +1,100 @@ + Pre-built framework map (overrides HTTP for filtering tests). */ + public array $frameworkFixtures = []; + + private ?string $customModulesBasePath; + + public function setSelectedStack(?string $stack): void + { + $this->selectedStack = $stack; + } + + /** @param string[] $modules */ + public function setSelectedModules(array $modules): void + { + $this->selectedModules = $modules; + } + + public function __construct(?string $modulesBasePath = null) + { + parent::__construct(); + $this->customModulesBasePath = $modulesBasePath; + } + + public function exposedFetchPackageFrameworks(string $package): array + { + return $this->fetchPackageFrameworks($package); + } + + /** @param string[] $packages */ + public function exposedFilterModulesByFramework(array $packages, string $framework): array + { + return $this->filterModulesByFramework($packages, $framework); + } + + public function exposedSuccessCalloutContent(array $steps): array + { + return $this->successCalloutContent($steps); + } + + /** @param array $resumeOptions */ + public function exposedFailureCalloutContent(?string $step, array $resumeOptions): array + { + return $this->failureCalloutContent($step, $resumeOptions); + } + + public function exposedApplyIdentityToEnv(string $env, string $name, string $slug, string $host, bool $native): string + { + return $this->applyIdentityToEnv($env, $name, $slug, $host, $native); + } + + /** @var array Fake option values for tests that bypass CLI input. */ + public array $fakeOptions = []; + + public function option($key = null): string|array|bool|null + { + if (! empty($this->fakeOptions)) { + return $key !== null ? ($this->fakeOptions[$key] ?? false) : $this->fakeOptions; + } + + return parent::option($key); + } + + /** @param string[] $available */ + public function exposedResolveModuleSelection(array $available): array + { + return $this->resolveModuleSelection($available); + } + + public function exposedSetupModules(): void + { + $this->setupModules(); + } + + protected function doInstallModules(array $selected): void + { + // no-op — prevents composer require from running in unit tests + } + + protected function fetchPackageFrameworks(string $package): array + { + if (isset($this->frameworkFixtures[$package])) { + return $this->frameworkFixtures[$package]; + } + + return parent::fetchPackageFrameworks($package); + } + + protected function modulesBasePath(): string + { + return $this->customModulesBasePath ?? parent::modulesBasePath(); + } +} From 9a2bfa45c17f12a74a0994283cf1ef18395dc808 Mon Sep 17 00:00:00 2001 From: roble <3231587+roble@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:00:57 +0000 Subject: [PATCH 2/3] PHP Linting (Pint) --- tests/Feature/InstallCommandIdentityTest.php | 5 +++-- tests/Feature/InstallCommandResumeTest.php | 1 - tests/Feature/TestableInstallCommand.php | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Feature/InstallCommandIdentityTest.php b/tests/Feature/InstallCommandIdentityTest.php index 9313a43..65909cf 100644 --- a/tests/Feature/InstallCommandIdentityTest.php +++ b/tests/Feature/InstallCommandIdentityTest.php @@ -2,6 +2,7 @@ namespace Saucebase\Installer\Tests\Feature; +use Illuminate\Support\Str; use Saucebase\Installer\Console\Commands\InstallCommand; use Saucebase\Installer\Tests\TestCase; @@ -13,8 +14,8 @@ private function apply(string $env, string $dir = 'my-app', string $host = 'loca return $command->exposedApplyIdentityToEnv( $env, - \Illuminate\Support\Str::headline($dir), - \Illuminate\Support\Str::slug($dir), + Str::headline($dir), + Str::slug($dir), $host, $native, ); diff --git a/tests/Feature/InstallCommandResumeTest.php b/tests/Feature/InstallCommandResumeTest.php index 45e0341..94b5fe6 100644 --- a/tests/Feature/InstallCommandResumeTest.php +++ b/tests/Feature/InstallCommandResumeTest.php @@ -2,7 +2,6 @@ namespace Saucebase\Installer\Tests\Feature; -use Saucebase\Installer\Console\Commands\InstallCommand; use Saucebase\Installer\Tests\TestCase; class InstallCommandResumeTest extends TestCase diff --git a/tests/Feature/TestableInstallCommand.php b/tests/Feature/TestableInstallCommand.php index 8d8a6cc..788daaf 100644 --- a/tests/Feature/TestableInstallCommand.php +++ b/tests/Feature/TestableInstallCommand.php @@ -3,7 +3,6 @@ namespace Saucebase\Installer\Tests\Feature; use Saucebase\Installer\Console\Commands\InstallCommand; -use Saucebase\Installer\Tests\TestCase; class TestableInstallCommand extends InstallCommand { From a6d8bb644fd06a06702061db7ce0995aae408807 Mon Sep 17 00:00:00 2001 From: roble Date: Wed, 26 Aug 2026 21:01:49 +0100 Subject: [PATCH 3/3] feat: Fix nginx server port handling on re-runs and add regression test --- src/Environments/DockerEnvironment.php | 13 +++++++++---- .../Feature/Environments/DockerEnvironmentTest.php | 11 +++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Environments/DockerEnvironment.php b/src/Environments/DockerEnvironment.php index 8ff6c36..2063b4b 100644 --- a/src/Environments/DockerEnvironment.php +++ b/src/Environments/DockerEnvironment.php @@ -293,10 +293,15 @@ protected function applyNginxSettings(string $conf, string $host, int $httpPort, ); // Laravel builds URLs from what PHP is told the port is; without this a stack - // moved off 443 by the port check would still advertise 443. - return preg_replace_callback( - '/^(\s*fastcgi_param\s+SERVER_PORT\s+)(\d+)(;)/m', - fn (array $m) => $m[1].($m[2] === '443' ? $httpsPort : $httpPort).$m[3], + // moved off 443 by the port check would still advertise 443. Pick the port from + // which stub this is, not from the value already there — on a re-run that value + // is the remapped one, and matching on it would flip the block to HTTP. + // `listen 443 ssl` is stable: only the published host port ever moves. + $serverPort = str_contains($conf, 'listen 443 ssl') ? $httpsPort : $httpPort; + + return preg_replace( + '/^(\s*fastcgi_param\s+SERVER_PORT\s+)\d+(;)/m', + '${1}'.$serverPort.'$2', $conf, ); } diff --git a/tests/Feature/Environments/DockerEnvironmentTest.php b/tests/Feature/Environments/DockerEnvironmentTest.php index 47cebe9..e71545d 100644 --- a/tests/Feature/Environments/DockerEnvironmentTest.php +++ b/tests/Feature/Environments/DockerEnvironmentTest.php @@ -905,6 +905,17 @@ public function test_nginx_reports_the_port_actually_published(): void $this->assertStringNotContainsString('SERVER_PORT 443;', $result); } + public function test_nginx_server_port_survives_a_re_run(): void + { + // Regression: deciding the block from the value already present flipped the + // SSL block to the HTTP port on the second pass, because by then it read 8443. + $first = $this->exposed()->exposedApplyNginxSettings($this->nginxStub(), 'myapp.test', 8080, 8443); + $second = $this->exposed()->exposedApplyNginxSettings($first, 'myapp.test', 8080, 8443); + + $this->assertStringContainsString('fastcgi_param SERVER_PORT 8443;', $second); + $this->assertStringNotContainsString('SERVER_PORT 8080;', $second); + } + public function test_nginx_no_ssl_stub_gets_the_http_port(): void { $stub = file_get_contents(dirname(__DIR__, 3).'/stubs/docker/docker/nginx-no-ssl.conf');