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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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**

Expand Down
29 changes: 23 additions & 6 deletions src/Console/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -40,14 +41,27 @@ class InstallCommand extends Command

public function handle(): int
{
$this->displayWelcome();
if (! $this->option('no-logo')) {
$this->displayWelcome();
}

$this->captureStack();

if ($this->isCI()) {
return $this->handleCIInstallation();
}

$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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/Environments/Contracts/Environment.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@ public function name(): string;

public function label(): string;

/** @return array<string> Human-readable error messages for each unmet prerequisite; empty means all good. */
public function missingPrerequisites(): array;

public function run(InstallCommand $command): int;
}
105 changes: 98 additions & 7 deletions src/Environments/DockerEnvironment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand All @@ -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");
}
Expand Down
16 changes: 16 additions & 0 deletions src/Environments/NativeEnvironment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Loading