Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<APP_URL host>`, 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
Expand Down
38 changes: 38 additions & 0 deletions src/Services/WordPress/Installation/DTO/InstallationConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
}
}
46 changes: 37 additions & 9 deletions src/WordPress/Commands/LaunchPadInstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,25 +84,52 @@ 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();

$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 = [];
Expand Down
68 changes: 64 additions & 4 deletions tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,13 +20,16 @@
* migrate are replaced by commands recording the input they receive.
*
* @param array<string, mixed> $parameters
* @return array{exit: int, theme: array<string, mixed>|null, migrate: array<string, mixed>|null}
* @return array{exit: int, theme: array<string, mixed>|null, migrate: array<string, mixed>|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);
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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');
});
});
Loading