diff --git a/CHANGELOG.md b/CHANGELOG.md index 477046a9..04944d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `use_default_wp_theme_directory` key of `config/wordpress.php`. Nothing ever read it: setting it to `true` changed nothing, and themes always live in `themes/` (#297) ### Fixed +- `pollora:install` without a terminal — `--no-interaction`, CI, or `pollora new` driving it — stopped on "Site title is required" unless every option was passed: the prompts it could not show were still required. It now fills what is missing with a working local site — the project name as title (the first label of the `APP_URL` host: under DDEV the directory is always `html`), `admin` at `admin@`, a generated password shown once (also with `--install`), `en_US`, not indexed — and keeps every option it is given - `pollora:status` and the dashboard reported a post type or taxonomy under a slug derived from its class name, ignoring the attribute: `#[PostType('synthese-presse')] class SyntheseDePresse` was listed as `synthese-de-presse`, a post type that does not exist, and its `plural` label was ignored too. Both read the attribute now, through `PostType::resolveSlug()` / `Taxonomy::resolveSlug()`, the rule discovery registers with, so they cannot disagree again (#298) ## [v13.34.0](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...v13.34.0) - 2026-09-30 diff --git a/src/Services/WordPress/Installation/DTO/InstallationConfig.php b/src/Services/WordPress/Installation/DTO/InstallationConfig.php index f88a7bfe..5a2c3270 100755 --- a/src/Services/WordPress/Installation/DTO/InstallationConfig.php +++ b/src/Services/WordPress/Installation/DTO/InstallationConfig.php @@ -4,6 +4,7 @@ namespace Pollora\Services\WordPress\Installation\DTO; +use Illuminate\Support\Str; use Pollora\Services\WordPress\Installation\LanguageService; use function Laravel\Prompts\confirm; @@ -88,4 +89,41 @@ public static function fromPrompts( ), ); } + + /** + * Create configuration for an install that cannot prompt. + * + * Without a terminal — `--no-interaction`, CI, or `pollora new` driving the + * command — prompts cannot be answered, and a required one used to abort the + * install on "Site title is required". Every option given is kept; the rest + * gets a value that makes a working local site: the project's name as + * title, `admin` on the site's own domain, a generated password, `en_US`, + * not indexed. + * + * @param string $projectName The project's name, for the title + * @param string|null $siteUrl The application URL, for the admin email domain + */ + public static function withDefaults( + string $projectName, + ?string $siteUrl, + ?string $title = null, + ?string $description = null, + ?string $adminUser = null, + ?string $adminEmail = null, + ?string $adminPassword = null, + ?string $locale = null, + ?bool $isPublic = null, + ): self { + $host = is_string($siteUrl) ? parse_url($siteUrl, PHP_URL_HOST) : null; + + return new self( + title: $title ?? Str::headline($projectName), + description: $description ?? '', + adminUser: $adminUser ?? 'admin', + adminEmail: $adminEmail ?? 'admin@'.(is_string($host) && str_contains($host, '.') ? $host : 'example.com'), + adminPassword: $adminPassword ?? Str::password(20, symbols: false), + locale: $locale ?? 'en_US', + isPublic: $isPublic ?? false, + ); + } } diff --git a/src/WordPress/Commands/LaunchPadInstallCommand.php b/src/WordPress/Commands/LaunchPadInstallCommand.php index 05c6dc59..609d1443 100644 --- a/src/WordPress/Commands/LaunchPadInstallCommand.php +++ b/src/WordPress/Commands/LaunchPadInstallCommand.php @@ -15,6 +15,7 @@ use function Laravel\Prompts\error; use function Laravel\Prompts\info; +use function Laravel\Prompts\warning; #[Description('Install and configure WordPress')] #[Signature('pollora:install @@ -83,18 +84,27 @@ private function installWordPress(bool $silent = false): void info('Starting WordPress installation...'); } - $config = InstallationConfig::fromPrompts( - title: $this->option('title'), - description: $this->option('description'), - adminUser: $this->option('admin-user'), - adminEmail: $this->option('admin-email'), - adminPassword: $this->option('admin-password'), - locale: $this->option('locale'), - isPublic: $this->option('public') !== null ? filter_var($this->option('public'), FILTER_VALIDATE_BOOLEAN) : null, - ); + $options = [ + 'title' => $this->option('title'), + 'description' => $this->option('description'), + 'adminUser' => $this->option('admin-user'), + 'adminEmail' => $this->option('admin-email'), + 'adminPassword' => $this->option('admin-password'), + 'locale' => $this->option('locale'), + 'isPublic' => $this->option('public') !== null ? filter_var($this->option('public'), FILTER_VALIDATE_BOOLEAN) : null, + ]; + + $config = $this->input->isInteractive() + ? InstallationConfig::fromPrompts(...$options) + : InstallationConfig::withDefaults($this->projectName(), config('app.url'), ...$options); $this->installationService->install($config); + if ($options['adminPassword'] === null && ! $this->input->isInteractive()) { + // Generated here and stored hashed: this is the only time it is shown. + warning(sprintf('Admin account: %s / %s — generated, shown only once.', $config->adminUser, $config->adminPassword)); + } + $this->runMigrations(); $this->installTheme(); @@ -102,6 +112,24 @@ private function installWordPress(bool $silent = false): void $this->displaySuccessMessage(); } + /** + * The project's name, for a site title nobody was asked for. + * + * The first label of the application's host (`acme.ddev.site` → `acme`): + * under DDEV or Docker the project always lives in `/var/www/html`, whose + * directory name says nothing. The directory name only without a real host. + */ + private function projectName(): string + { + $host = parse_url((string) config('app.url'), PHP_URL_HOST); + + if (is_string($host) && str_contains($host, '.')) { + return explode('.', $host)[0]; + } + + return basename(base_path()); + } + private function installTheme(): void { $arguments = []; diff --git a/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php b/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php index 0e9e039e..0c79a7da 100644 --- a/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php +++ b/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php @@ -2,12 +2,14 @@ declare(strict_types=1); +use Illuminate\Config\Repository; use Illuminate\Console\Application; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; use Illuminate\Container\Container; use Illuminate\Events\Dispatcher; use Pollora\Services\WordPress\Installation\DatabaseService; +use Pollora\Services\WordPress\Installation\DTO\InstallationConfig; use Pollora\Services\WordPress\Installation\InstallationService; use Pollora\WordPress\Commands\LaunchPadInstallCommand; use Symfony\Component\Console\Input\ArrayInput; @@ -18,13 +20,16 @@ * migrate are replaced by commands recording the input they receive. * * @param array $parameters - * @return array{exit: int, theme: array|null, migrate: array|null} + * @return array{exit: int, theme: array|null, migrate: array|null, config: InstallationConfig|null, output: string} */ function runInstallCommand(array $parameters, bool $interactive, int $migrateExit = 0): array { + $config = null; $installation = Mockery::mock(InstallationService::class); $installation->shouldReceive('isInstalled')->andReturn(false); - $installation->shouldReceive('install')->once(); + $installation->shouldReceive('install')->once()->andReturnUsing(function (InstallationConfig $given) use (&$config): void { + $config = $given; + }); $database = Mockery::mock(DatabaseService::class); $database->shouldReceive('isConfigured')->andReturn(true); @@ -74,7 +79,14 @@ public function isLocal(): bool { return false; } + + public function basePath(string $path = ''): string + { + // DDEV and Docker keep every project in /var/www/html + return '/var/www/html'.($path !== '' ? '/'.$path : ''); + } }; + $container->instance('config', new Repository(['app' => ['url' => 'https://acme-site.ddev.site']])); // handleError() resolves app() to check the environment $previousContainer = Container::getInstance(); Container::setInstance($container); @@ -90,12 +102,13 @@ public function isLocal(): bool $input->setInteractive($interactive); try { - $exit = $application->find('pollora:install')->run($input, new BufferedOutput); + $output = new BufferedOutput; + $exit = $application->find('pollora:install')->run($input, $output); } finally { Container::setInstance($previousContainer); } - return ['exit' => $exit, 'theme' => $recorder->received, 'migrate' => $migrate->received]; + return ['exit' => $exit, 'theme' => $recorder->received, 'migrate' => $migrate->received, 'config' => $config, 'output' => $output->fetch()]; } describe('pollora:install theme generation', function (): void { @@ -163,3 +176,50 @@ public function isLocal(): bool ->and($result['theme'])->toBeNull(); }); }); + +describe('pollora:install without prompts', function (): void { + beforeEach(function (): void { + Brain\Monkey\Functions\when('admin_url')->justReturn('https://acme-site.ddev.site/wp-admin/'); + }); + + it('installs with working defaults instead of failing on the first required prompt', function (): void { + // `pollora new --ddev --no-interaction` ran it with no option and no + // terminal, and the install stopped on "Site title is required". + $result = runInstallCommand(['--install' => true], interactive: false); + + expect($result['exit'])->toBe(0) + ->and($result['config'])->toBeInstanceOf(InstallationConfig::class) + ->and($result['config']->title)->toBe('Acme Site') + ->and($result['config']->adminUser)->toBe('admin') + ->and($result['config']->adminEmail)->toBe('admin@acme-site.ddev.site') + ->and(strlen($result['config']->adminPassword))->toBe(20) + ->and($result['config']->locale)->toBe('en_US') + ->and($result['config']->isPublic)->toBeFalse(); + }); + + it('shows a generated password once, even in --install mode', function (): void { + $result = runInstallCommand(['--install' => true], interactive: false); + + expect($result['output'])->toContain($result['config']->adminPassword); + }); + + it('keeps every option it is given', function (): void { + $result = runInstallCommand([ + '--install' => true, + '--title' => 'Pollora', + '--admin-user' => 'olivier', + '--admin-email' => 'dev@example.org', + '--admin-password' => 'secret123', + '--locale' => 'fr_FR', + '--public' => 'true', + ], interactive: false); + + expect($result['config']->title)->toBe('Pollora') + ->and($result['config']->adminUser)->toBe('olivier') + ->and($result['config']->adminEmail)->toBe('dev@example.org') + ->and($result['config']->adminPassword)->toBe('secret123') + ->and($result['config']->locale)->toBe('fr_FR') + ->and($result['config']->isPublic)->toBeTrue() + ->and($result['output'])->not->toContain('secret123'); + }); +});