diff --git a/README.md b/README.md index f6786e3..c7e6f95 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # saucebase/installer +![Tests](https://github.com/saucebase-dev/installer/actions/workflows/php.yml/badge.svg) + Dev-environment installer for [Saucebase](https://github.com/saucebase-dev/saucebase) applications. Provides two Artisan commands: `saucebase:install` bootstraps the entire dev environment (Docker, dependencies, database, frontend), and `saucebase:stack` switches the frontend framework after the environment is running. @@ -46,6 +48,7 @@ Prompts for the frontend stack (Vue or React) and the environment driver (Docker | `--modules=auth,billing` | Install specific modules by name (comma-separated) | | `--dev` | Contributor mode — skips module installation | | `--force` | Skip confirmations | +| `--no-logo` | Suppress the welcome banner (passed automatically when running inside a container) | **Examples** diff --git a/src/Console/Commands/InstallCommand.php b/src/Console/Commands/InstallCommand.php index 67ac5a3..69ff996 100644 --- a/src/Console/Commands/InstallCommand.php +++ b/src/Console/Commands/InstallCommand.php @@ -23,7 +23,8 @@ class InstallCommand extends Command {--all-modules : Enable and migrate all available modules without prompting} {--modules= : Comma-separated list of modules to enable (e.g. Auth,Settings)} {--dev : Dev environment} - {--force : Skip confirmations}'; + {--force : Skip confirmations} + {--no-logo : Suppress the welcome banner}'; protected $description = 'Install and configure Saucebase'; @@ -40,7 +41,10 @@ class InstallCommand extends Command public function handle(): int { - $this->displayWelcome(); + if (! $this->option('no-logo')) { + $this->displayWelcome(); + } + $this->captureStack(); if ($this->isCI()) { @@ -48,6 +52,16 @@ public function handle(): int } $driver = $this->resolveDriver(); + + $missing = $driver->missingPrerequisites(); + if (! empty($missing)) { + foreach ($missing as $message) { + $this->error($message); + } + + return self::FAILURE; + } + $this->promptForModules(); return $driver->run($this); @@ -68,8 +82,11 @@ protected function resolveDriver(): Environment { $name = $this->option('driver') ?? select( label: 'How would you like to run Saucebase?', - options: ['docker' => 'Docker (recommended)', 'native' => 'Native PHP'], - default: 'docker', + options: [ + 'native' => 'Native PHP - minimal setup, ideal for exploring', + 'docker' => 'Docker - recommended for real projects: MySQL, Redis, Mailpit, HTTPS', + ], + default: 'native', ); return match ($name) { @@ -212,7 +229,7 @@ protected function handleCIInstallation(): int { $this->info('CI environment detected - running minimal setup...'); - $envOk = file_exists(base_path('.env')); + $envOk = $this->ensureEnvFile(); $keyOk = ! empty(config('app.key')); $this->components->task('Verifying .env', fn () => $envOk); @@ -227,7 +244,7 @@ protected function handleCIInstallation(): int return self::SUCCESS; } - protected function ensureEnvFile(): bool + public function ensureEnvFile(): bool { if (file_exists(base_path('.env'))) { return true; diff --git a/src/Environments/Contracts/Environment.php b/src/Environments/Contracts/Environment.php index 034c9b8..425d634 100644 --- a/src/Environments/Contracts/Environment.php +++ b/src/Environments/Contracts/Environment.php @@ -10,5 +10,8 @@ public function name(): string; public function label(): string; + /** @return array Human-readable error messages for each unmet prerequisite; empty means all good. */ + public function missingPrerequisites(): array; + public function run(InstallCommand $command): int; } diff --git a/src/Environments/DockerEnvironment.php b/src/Environments/DockerEnvironment.php index 1ab0c21..3a2b827 100644 --- a/src/Environments/DockerEnvironment.php +++ b/src/Environments/DockerEnvironment.php @@ -19,11 +19,34 @@ public function label(): string return 'Docker'; } + public function missingPrerequisites(): array + { + $missing = []; + + if (! $this->commandExists('docker')) { + $missing[] = 'docker is not installed or not in PATH.'; + } elseif (! $this->dockerComposeAvailable()) { + $missing[] = '"docker compose" subcommand is not available. Ensure Docker Desktop or a Compose plugin is installed.'; + } + + if (! $this->commandExists('npm')) { + $missing[] = 'npm is not installed or not in PATH.'; + } + + return $missing; + } + public function run(InstallCommand $command): int { $this->publishStubs($command); $this->generateSsl($command); + if (! $command->ensureEnvFile()) { + return InstallCommand::FAILURE; + } + + $this->setDockerEnvDefaults($command); + if (! $this->startDocker($command)) { return InstallCommand::FAILURE; } @@ -80,12 +103,15 @@ protected function generateSsl(InstallCommand $command): void protected function startDocker(InstallCommand $command): bool { - $command->info('Starting Docker services...'); - (new Process(['docker', 'compose', 'restart']))->run(); + $command->info('Starting Docker services (this may take a few minutes while pulling images and starting containers)...'); + + $restart = new Process(['docker', 'compose', 'restart']); + $restart->setTimeout(60); + $restart->run(); - $up = new Process(['docker', 'compose', 'up', '-d', '--wait']); - $up->setTimeout(120); - $up->run(); + $up = new Process(['docker', 'compose', 'up', '-d', '--wait', '--build']); + $up->setTimeout(30 * 60); // 30 minutes — first run pulls images + builds layers + $up->run(fn ($_type, $buffer) => $command->line(trim($buffer))); if (! $up->isSuccessful()) { $command->error('Docker failed to start: '.$up->getErrorOutput()); @@ -154,7 +180,7 @@ protected function runNpmOnHost(InstallCommand $command): void /** @return string[] */ public function buildContainerArgs(InstallCommand $command): array { - $args = ['php', 'artisan', 'saucebase:install', '--driver=native', '--force']; + $args = ['php', 'artisan', 'saucebase:install', '--driver=native', '--force', '--no-logo']; if ($stack = $command->getSelectedStack()) { $args[] = $stack; @@ -174,7 +200,72 @@ public function buildContainerArgs(InstallCommand $command): array return $args; } - private function commandExists(string $name): bool + protected function setDockerEnvDefaults(InstallCommand $command): void + { + $path = base_path('.env'); + $original = file_get_contents($path); + + if ($original === false) { + return; + } + + $modified = $this->applyDockerEnvDefaults($original); + + if ($modified !== $original) { + file_put_contents($path, $modified); + $command->info('Docker database credentials written to .env.'); + } + } + + protected function applyDockerEnvDefaults(string $env): string + { + $slug = 'saucebase'; + if (preg_match('/^APP_SLUG=([^\s]+)/m', $env, $m)) { + $slug = 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); + } elseif (! preg_match('/^DB_CONNECTION=/m', $env)) { + $env .= "\nDB_CONNECTION=mysql"; + } + + // Docker routes mail through the Mailpit container via SMTP + if (preg_match('/^MAIL_MAILER=(.*)$/m', $env, $m) && trim($m[1]) !== 'smtp') { + $env = preg_replace('/^MAIL_MAILER=.*$/m', 'MAIL_MAILER=smtp', $env); + } elseif (! preg_match('/^MAIL_MAILER=/m', $env)) { + $env .= "\nMAIL_MAILER=smtp"; + } + + // Set missing or blank values; respect anything the user has already configured + $defaults = [ + 'DB_HOST' => 'mysql', + 'DB_PORT' => '3306', + 'DB_DATABASE' => $slug, + 'DB_USERNAME' => $slug, + 'DB_PASSWORD' => 'secret', + ]; + + foreach ($defaults as $key => $value) { + if (preg_match('/^'.preg_quote($key, '/').'=(.*)$/m', $env, $m)) { + if (trim($m[1]) === '') { + $env = preg_replace('/^'.preg_quote($key, '/').'=.*$/m', "{$key}={$value}", $env); + } + } else { + $env .= "\n{$key}={$value}"; + } + } + + return $env; + } + + protected function dockerComposeAvailable(): bool + { + return (bool) shell_exec('docker compose version 2>/dev/null'); + } + + protected function commandExists(string $name): bool { return (bool) shell_exec("which {$name} 2>/dev/null"); } diff --git a/src/Environments/NativeEnvironment.php b/src/Environments/NativeEnvironment.php index 72ce6c3..3bb34b3 100644 --- a/src/Environments/NativeEnvironment.php +++ b/src/Environments/NativeEnvironment.php @@ -17,8 +17,24 @@ public function label(): string return 'Native PHP'; } + public function missingPrerequisites(): array + { + $missing = []; + + if (! $this->commandExists('composer')) { + $missing[] = 'composer is not installed or not in PATH.'; + } + + return $missing; + } + public function run(InstallCommand $command): int { return $command->install(); } + + protected function commandExists(string $name): bool + { + return (bool) shell_exec("which {$name} 2>/dev/null"); + } } diff --git a/tests/Feature/Environments/DockerEnvironmentTest.php b/tests/Feature/Environments/DockerEnvironmentTest.php index 189781a..31e0a86 100644 --- a/tests/Feature/Environments/DockerEnvironmentTest.php +++ b/tests/Feature/Environments/DockerEnvironmentTest.php @@ -127,6 +127,110 @@ public function test_multiple_flags_are_all_forwarded(): void $this->assertContains('--force', $args); } + // ------------------------------------------------------------------------- + // missingPrerequisites + // ------------------------------------------------------------------------- + + public function test_missing_prerequisites_returns_empty_when_all_tools_present(): void + { + $env = new class extends DockerEnvironment + { + protected function commandExists(string $name): bool + { + return true; + } + + protected function dockerComposeAvailable(): bool + { + return true; + } + }; + + $this->assertSame([], $env->missingPrerequisites()); + } + + public function test_missing_prerequisites_reports_docker_missing(): void + { + $env = new class extends DockerEnvironment + { + protected function commandExists(string $name): bool + { + return $name !== 'docker'; + } + + protected function dockerComposeAvailable(): bool + { + return true; + } + }; + + $missing = $env->missingPrerequisites(); + $this->assertCount(1, $missing); + $this->assertStringContainsString('docker', $missing[0]); + } + + public function test_missing_prerequisites_reports_docker_compose_missing(): void + { + $env = new class extends DockerEnvironment + { + protected function commandExists(string $name): bool + { + return true; + } + + protected function dockerComposeAvailable(): bool + { + return false; + } + }; + + $missing = $env->missingPrerequisites(); + $this->assertCount(1, $missing); + $this->assertStringContainsString('docker compose', $missing[0]); + } + + public function test_missing_prerequisites_reports_npm_missing(): void + { + $env = new class extends DockerEnvironment + { + protected function commandExists(string $name): bool + { + return $name !== 'npm'; + } + + protected function dockerComposeAvailable(): bool + { + return true; + } + }; + + $missing = $env->missingPrerequisites(); + $this->assertCount(1, $missing); + $this->assertStringContainsString('npm', $missing[0]); + } + + public function test_missing_prerequisites_skips_compose_check_when_docker_itself_missing(): void + { + $env = new class extends DockerEnvironment + { + protected function commandExists(string $name): bool + { + return false; + } + + protected function dockerComposeAvailable(): bool + { + return false; + } + }; + + $missing = $env->missingPrerequisites(); + // docker + npm missing; docker compose check is skipped via elseif + $this->assertCount(2, $missing); + $this->assertStringContainsString('docker', $missing[0]); + $this->assertStringContainsString('npm', $missing[1]); + } + // ------------------------------------------------------------------------- // run() failure propagation // ------------------------------------------------------------------------- @@ -143,6 +247,8 @@ protected function publishStubs(InstallCommand $command): void {} protected function generateSsl(InstallCommand $command): void {} + protected function setDockerEnvDefaults(InstallCommand $command): void {} + protected function startDocker(InstallCommand $command): bool { return false; @@ -174,6 +280,8 @@ protected function publishStubs(InstallCommand $command): void {} protected function generateSsl(InstallCommand $command): void {} + protected function setDockerEnvDefaults(InstallCommand $command): void {} + protected function startDocker(InstallCommand $command): bool { return true; @@ -196,10 +304,143 @@ protected function runInstallInContainer(InstallCommand $command): void $this->assertFalse($spy->installCalled, 'in-container install must not run when composer install fails'); } + // ------------------------------------------------------------------------- + // applyDockerEnvDefaults + // ------------------------------------------------------------------------- + + public function test_replaces_sqlite_connection_with_mysql(): void + { + $result = $this->applyDefaults("DB_CONNECTION=sqlite\n"); + + $this->assertStringContainsString('DB_CONNECTION=mysql', $result); + $this->assertStringNotContainsString('DB_CONNECTION=sqlite', $result); + } + + public function test_leaves_mysql_connection_unchanged(): void + { + $input = "DB_CONNECTION=mysql\nDB_HOST=mysql\nDB_PORT=3306\nDB_DATABASE=myapp\nDB_USERNAME=myapp\nDB_PASSWORD=secret\nMAIL_MAILER=smtp\n"; + $result = $this->applyDefaults($input); + + $this->assertSame($input, $result); + } + + public function test_appends_db_connection_when_missing(): void + { + $result = $this->applyDefaults("APP_NAME=Test\n"); + + $this->assertStringContainsString('DB_CONNECTION=mysql', $result); + } + + public function test_uses_app_slug_for_db_database_and_username(): void + { + $result = $this->applyDefaults("APP_SLUG=myproject\nDB_CONNECTION=sqlite\n"); + + $this->assertStringContainsString('DB_DATABASE=myproject', $result); + $this->assertStringContainsString('DB_USERNAME=myproject', $result); + } + + public function test_falls_back_to_saucebase_slug_when_app_slug_missing(): void + { + $result = $this->applyDefaults("DB_CONNECTION=sqlite\n"); + + $this->assertStringContainsString('DB_DATABASE=saucebase', $result); + $this->assertStringContainsString('DB_USERNAME=saucebase', $result); + } + + public function test_sets_blank_db_vars_to_defaults(): void + { + $input = "DB_CONNECTION=sqlite\nDB_HOST=\nDB_PORT=\nDB_DATABASE=\nDB_USERNAME=\nDB_PASSWORD=\n"; + $result = $this->applyDefaults($input); + + $this->assertStringContainsString('DB_HOST=mysql', $result); + $this->assertStringContainsString('DB_PORT=3306', $result); + $this->assertStringContainsString('DB_PASSWORD=secret', $result); + } + + public function test_does_not_overwrite_existing_db_values(): void + { + $input = "DB_CONNECTION=mysql\nDB_HOST=custom-host\nDB_PORT=3307\nDB_DATABASE=mydb\nDB_USERNAME=myuser\nDB_PASSWORD=mypass\n"; + $result = $this->applyDefaults($input); + + $this->assertStringContainsString('DB_HOST=custom-host', $result); + $this->assertStringContainsString('DB_PORT=3307', $result); + $this->assertStringContainsString('DB_DATABASE=mydb', $result); + $this->assertStringContainsString('DB_USERNAME=myuser', $result); + $this->assertStringContainsString('DB_PASSWORD=mypass', $result); + } + + public function test_appends_missing_db_vars(): void + { + $result = $this->applyDefaults("DB_CONNECTION=sqlite\n"); + + $this->assertStringContainsString('DB_HOST=mysql', $result); + $this->assertStringContainsString('DB_PORT=3306', $result); + $this->assertStringContainsString('DB_PASSWORD=secret', $result); + } + + public function test_replaces_log_mailer_with_smtp(): void + { + $result = $this->applyDefaults("MAIL_MAILER=log\n"); + + $this->assertStringContainsString('MAIL_MAILER=smtp', $result); + $this->assertStringNotContainsString('MAIL_MAILER=log', $result); + } + + public function test_leaves_smtp_mailer_unchanged(): void + { + $input = "MAIL_MAILER=smtp\n"; + $result = $this->applyDefaults($input); + + $this->assertStringContainsString('MAIL_MAILER=smtp', $result); + } + + public function test_appends_mail_mailer_when_missing(): void + { + $result = $this->applyDefaults("APP_NAME=Test\n"); + + $this->assertStringContainsString('MAIL_MAILER=smtp', $result); + } + + public function test_real_env_example_pattern_produces_valid_docker_env(): void + { + $input = implode("\n", [ + 'APP_SLUG=acme', + 'DB_CONNECTION=sqlite', + '# DB_HOST=localhost', + '# DB_DATABASE=${APP_SLUG}', + '# DB_USERNAME=${APP_SLUG}', + '# DB_PASSWORD=secret', + 'MAIL_MAILER=log', + '', + ]); + + $result = $this->applyDefaults($input); + + $this->assertStringContainsString('DB_CONNECTION=mysql', $result); + $this->assertStringContainsString('DB_DATABASE=acme', $result); + $this->assertStringContainsString('DB_USERNAME=acme', $result); + $this->assertStringContainsString('DB_HOST=mysql', $result); + $this->assertStringContainsString('DB_PASSWORD=secret', $result); + $this->assertStringContainsString('MAIL_MAILER=smtp', $result); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- + private function applyDefaults(string $env): string + { + $exposed = new class extends DockerEnvironment + { + public function applyDockerEnvDefaults(string $env): string + { + return parent::applyDockerEnvDefaults($env); + } + }; + + return $exposed->applyDockerEnvDefaults($env); + } + /** * @param string[] $modules * @param array $options diff --git a/tests/Feature/Environments/NativeEnvironmentTest.php b/tests/Feature/Environments/NativeEnvironmentTest.php index 5a09c7e..3f585b2 100644 --- a/tests/Feature/Environments/NativeEnvironmentTest.php +++ b/tests/Feature/Environments/NativeEnvironmentTest.php @@ -25,6 +25,42 @@ public function test_implements_environment_contract(): void $this->assertInstanceOf(Environment::class, new NativeEnvironment); } + // ------------------------------------------------------------------------- + // missingPrerequisites + // ------------------------------------------------------------------------- + + public function test_missing_prerequisites_returns_empty_when_composer_present(): void + { + $env = new class extends NativeEnvironment + { + protected function commandExists(string $name): bool + { + return true; + } + }; + + $this->assertSame([], $env->missingPrerequisites()); + } + + public function test_missing_prerequisites_reports_composer_missing(): void + { + $env = new class extends NativeEnvironment + { + protected function commandExists(string $name): bool + { + return false; + } + }; + + $missing = $env->missingPrerequisites(); + $this->assertCount(1, $missing); + $this->assertStringContainsString('composer', $missing[0]); + } + + // ------------------------------------------------------------------------- + // run() + // ------------------------------------------------------------------------- + public function test_run_delegates_to_install_and_returns_success(): void { $spy = (object) ['installCalled' => false]; diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index ed76e48..d45bd00 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -2,6 +2,7 @@ namespace Saucebase\Installer\Tests\Feature; +use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; use Saucebase\Installer\Console\Commands\InstallCommand; use Saucebase\Installer\Environments\Contracts\Environment; @@ -195,7 +196,7 @@ protected function isCI(): bool return false; } - protected function ensureEnvFile(): bool + public function ensureEnvFile(): bool { return true; } @@ -269,7 +270,7 @@ protected function resolveDriver(): Environment return new NativeEnvironment; } - protected function ensureEnvFile(): bool + public function ensureEnvFile(): bool { return true; } @@ -310,7 +311,7 @@ protected function isCI(): bool return false; } - protected function ensureEnvFile(): bool + public function ensureEnvFile(): bool { return true; } @@ -335,6 +336,65 @@ protected function displaySuccess(): void {} $this->artisan('saucebase:install vue --driver=native --all-modules')->assertSuccessful(); } + // ------------------------------------------------------------------------- + // missingPrerequisites gate in handle() + // ------------------------------------------------------------------------- + + public function test_handle_returns_failure_and_skips_run_when_prerequisites_not_met(): void + { + $spy = (object) ['runCalled' => false]; + + app()->bind(InstallCommand::class, function () use ($spy) { + return new class($spy) extends InstallCommand + { + public function __construct(private object $spy) + { + parent::__construct(); + } + + protected function isCI(): bool + { + return false; + } + + protected function resolveDriver(): Environment + { + $spy = $this->spy; + + return new class($spy) implements Environment + { + public function __construct(private object $spy) {} + + public function name(): string + { + return 'fake'; + } + + public function label(): string + { + return 'Fake'; + } + + public function missingPrerequisites(): array + { + return ['fake-tool is not installed or not in PATH.']; + } + + public function run(InstallCommand $command): int + { + $this->spy->runCalled = true; + + return Command::SUCCESS; + } + }; + } + }; + }); + + $this->artisan('saucebase:install vue --all-modules')->assertFailed(); + $this->assertFalse($spy->runCalled, 'run() must not be called when prerequisites are not met'); + } + // ------------------------------------------------------------------------- // handleCIInstallation exit codes // ------------------------------------------------------------------------- @@ -348,10 +408,14 @@ protected function isCI(): bool { return true; } + + public function ensureEnvFile(): bool + { + return false; + } }; }); - // No .env in the Testbench app root — task will return false $this->artisan('saucebase:install vue')->assertFailed(); } @@ -368,7 +432,12 @@ public function test_install_returns_failure_and_skips_modules_when_database_set { public object $spy; - protected function ensureEnvFile(): bool + protected function isCI(): bool + { + return false; + } + + public function ensureEnvFile(): bool { return true; }