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
32 changes: 25 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

`saucebase/installer` is a Laravel package (dev dependency) that provides the `saucebase:install` and `saucebase:stack` Artisan commands. It also publishes Docker configuration files (`docker-compose.yml`, `Dockerfile`, `nginx.conf`, `php.ini`, `xdebug.ini`) to the host app via `vendor:publish --tag=saucebase-docker`.

`saucebase:install` can bootstrap the entire dev environment — including starting Docker, running `composer install` inside the container, and building frontend assets — before handing off to the Laravel-specific install steps. Local PHP + Composer is a prerequisite (same as Laravel itself).
`saucebase:install` bootstraps the entire dev environment — prompting for SSL, starting Docker, running explicit artisan steps in the container, applying module patches on the host, and running per-module migrations and seeders. Local PHP + Composer is a prerequisite (same as Laravel itself).

## Commands

Expand All @@ -27,18 +27,36 @@ composer install

**`InstallCommand`** selects an environment driver, then orchestrates the install flow:

- `--driver=docker` → `DockerEnvironment`: publishes Docker stubs, generates SSL via mkcert, starts `docker compose`, runs `composer install` inside the container, re-invokes `saucebase:install --driver=native` inside the container, then runs `npm install && npm run build` on the host.
- `--driver=native` (default when not prompted) → `NativeEnvironment`: runs the Laravel install directly.
- `--driver=docker` → `DockerEnvironment`: see Docker flow below.
- `--driver=native` (default when not prompted) → `NativeEnvironment`: delegates to `InstallCommand::install()`.

Laravel install steps (run by `NativeEnvironment` or inside the container for Docker):
**Native install steps** (run by `NativeEnvironment` via `install()`):
1. `ensureEnvFile()` — copies `.env.example` → `.env` if missing
2. `generateApplicationKey()` — skips if `APP_KEY` already set
3. `setupDatabase()` — runs `migrate` (or `migrate:fresh` with `--fresh`) with seed
4. `runStack()` — calls `saucebase:stack` with the selected framework
5. `setupModules()` — fetches available modules from Packagist, `composer require`s selected ones, then runs `modules:sync` + `migrate`
5. `setupModules()` — fetches available modules from Packagist, `composer require`s selected ones, then: `dump-autoload` → `applyModulePatches()` → `modules:sync` → per-module `modules:migrate` + `modules:seed`
6. `createStorageLink()` + `clearCaches()`

**Environment drivers** live in `src/Environments/`. Each implements `Environments/Contracts/Environment` (`name()`, `label()`, `run(InstallCommand)`). Add new drivers (Valet, Herd, Sail) by creating a class there and adding a `match` arm in `InstallCommand::resolveDriver()`.
**Docker flow** (`DockerEnvironment::run()`):
1. `promptForSsl()` — asks user whether to enable HTTPS (requires mkcert); `--force` defaults to SSL on
2. If SSL requested but `mkcert` not installed → hard failure with install hint
3. `publishStubs()` — `vendor:publish --tag=saucebase-docker`; if SSL off, overwrites `docker/nginx.conf` with `nginx-no-ssl.conf` stub
4. `generateSsl()` — runs mkcert for `*.localhost` (no-op if SSL disabled or certs already exist)
5. `ensureEnvFile()` — copies `.env.example` → `.env` if missing
6. `setDockerEnvDefaults()` — calls `applyDockerEnvDefaults()` to patch `.env`: `DB_CONNECTION=mysql`, MySQL credentials, `MAIL_MAILER=smtp`, `APP_URL=https://localhost` (or `http://` if SSL off)
7. `startDocker()` — `docker compose restart` + `docker compose up -d --wait --build` (30 min timeout, streaming output)
8. `runComposerInContainer()` — `composer install` in the `app` container
9. `generateAppKey()` → `runMigrations()` → `runStack()` — artisan steps in the container via `execInContainer()`
10. `installModules()` — per module: `composer require` in container → `applyModulePatches()` on host → `modules:sync` → `modules:migrate` → `modules:seed` in container
11. `createStorageLink()` + `clearCaches()` in container
12. `reloadDocker()` — `docker compose up -d --wait`

**Stubs** live in `stubs/docker/`. Two nginx configs are shipped: `nginx.conf` (SSL, HTTPS on 443) and `nginx-no-ssl.conf` (plain HTTP on 80). `publishStubs()` always publishes the SSL version first, then overwrites with the no-SSL version if needed.

**`applyModulePatches(array $modules)`** (on `InstallCommand`, public) — for each module looks for `*.patch` files in `vendor/saucebase/{name}/patches/` and `modules/{name}/patches/`. Runs `git apply --check` first (skips if already applied), then `git apply`. Always runs on the host so git is available and volume-mounted changes are immediately visible.

**Environment drivers** live in `src/Environments/`. Each implements `Environments/Contracts/Environment` (`name()`, `label()`, `missingPrerequisites()`, `run(InstallCommand)`). Add new drivers (Valet, Herd, Sail) by creating a class there and adding a `match` arm in `InstallCommand::resolveDriver()`.

**`StackCommand`** manages frontend framework selection (Vue/React). Prompts when no stack argument is given. Supports `--dev` (contributor mode — copies config only, keeps both framework dirs) and `--reset`.

Expand All @@ -51,7 +69,7 @@ Uses [Orchestral Testbench](https://github.com/orchestral/testbench) — no full
- `InstallCommandTest` — covers `fetchPackageFrameworks()`, `filterModulesByFramework()`, stack dispatch, driver selection, and `--driver=native` behaviour. Uses anonymous class overrides to stub heavy operations without mocking internals. `TestableInstallCommand` at the bottom exposes protected methods for direct unit testing.
- `StackCommandTest` — covers dev mode, install mode, reset, git skip-worktree, module and recipe stub processing.
- `Environments/NativeEnvironmentTest` — verifies `run()` delegates to `install()` and passes through the return code.
- `Environments/DockerEnvironmentTest` — unit-tests `buildContainerArgs()` for all flag-forwarding combinations using `FakeInstallCommand`.
- `Environments/DockerEnvironmentTest` — tests `resolveModules()`, `applyDockerEnvDefaults()` (all SSL/no-SSL branches), SSL gate in `run()`, and `missingPrerequisites()`. Uses `FakeInstallCommand` (at bottom of file) as a stub with no-op output methods.

## Wiring into the host app

Expand Down
49 changes: 32 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# saucebase/installer

![Tests](https://github.com/saucebase-dev/installer/actions/workflows/php.yml/badge.svg)
![PHP](https://img.shields.io/badge/PHP-%5E8.4-777BB4?logo=php&logoColor=white)
![Laravel](https://img.shields.io/badge/Laravel-13-FF2D20?logo=laravel&logoColor=white)
[![Saucebase](https://img.shields.io/packagist/v/saucebase/saucebase?color=5455c4&label=saucebase%2Fsaucebase)](https://packagist.org/packages/saucebase/saucebase)

Dev-environment installer for [Saucebase](https://github.com/saucebase-dev/saucebase) applications.

Expand All @@ -27,15 +30,32 @@ Bootstraps a new Saucebase dev environment from scratch.
php artisan saucebase:install
```

Prompts for the frontend stack (Vue or React) and the environment driver (Docker or native PHP), then runs the full setup sequence:
Prompts for the frontend stack (Vue or React) and the environment driver (Docker or native PHP), then runs the full setup sequence.

1. Publishes Docker config files and generates SSL certificates (Docker driver only)
2. Starts `docker compose` and installs PHP dependencies inside the container
3. Copies `.env.example` → `.env` and generates `APP_KEY`
4. Runs migrations and seeds the database
5. Wires up the selected frontend stack
6. Installs any selected modules
7. Creates the storage symlink and clears caches
**Docker driver** (`--driver=docker`):

1. Prompts whether to enable HTTPS via SSL (requires [mkcert](https://github.com/FiloSottile/mkcert))
2. Publishes Docker config files (`docker-compose.yml`, `Dockerfile`, `nginx.conf`, etc.)
3. Generates SSL certificates with mkcert (if SSL enabled)
4. Patches `.env` with Docker-appropriate defaults — MySQL credentials, `MAIL_MAILER=smtp` for Mailpit, and `APP_URL` matching the SSL choice
5. Starts `docker compose` and installs PHP dependencies inside the container
6. Generates `APP_KEY`, runs migrations, wires up the frontend stack
7. Installs any selected modules (`composer require` → patches → `modules:sync` → `modules:migrate` → `modules:seed` per module)
8. Creates the storage symlink and clears caches

**Native driver** (`--driver=native`):

1. Copies `.env.example` → `.env` and generates `APP_KEY`
2. Runs migrations and seeds the database
3. Wires up the selected frontend stack
4. Installs any selected modules (same patch + migrate + seed flow as Docker)
5. Creates the storage symlink and clears caches

**Docker prerequisites**

- [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Docker Engine with Compose plugin
- [mkcert](https://github.com/FiloSottile/mkcert) — only if you choose SSL (`brew install mkcert`)
- npm

**Options**

Expand All @@ -45,14 +65,14 @@ Prompts for the frontend stack (Vue or React) and the environment driver (Docker
| `--driver=docker\|native` | Environment driver (skips the prompt) |
| `--fresh` | Run `migrate:fresh` instead of `migrate` (destructive — wipes all data) |
| `--all-modules` | Install every available module for the selected stack without prompting |
| `--modules=auth,billing` | Install specific modules by name (comma-separated) |
| `--modules=auth,billing` | Install specific modules by name (comma-separated, short or full package name) |
| `--dev` | Contributor mode — skips module installation |
| `--force` | Skip confirmations |
| `--no-logo` | Suppress the welcome banner (passed automatically when running inside a container) |
| `--force` | Skip confirmations (Docker driver defaults to SSL when forced) |
| `--no-logo` | Suppress the welcome banner |

**Examples**

Fully interactive — prompts for stack and driver:
Fully interactive — prompts for stack, driver, SSL, and modules:
```bash
php artisan saucebase:install
```
Expand All @@ -67,11 +87,6 @@ React + native PHP, specific modules only:
php artisan saucebase:install react --driver=native --modules=auth,billing
```

Re-run inside an already-running container (skips Docker steps):
```bash
php artisan saucebase:install vue --driver=native --force
```

---

### `saucebase:stack`
Expand Down
10 changes: 5 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
"license": "MIT",
"require": {
"php": "^8.4",
"illuminate/console": "^12.0|^13.0",
"illuminate/filesystem": "^12.0|^13.0",
"illuminate/http": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0",
"illuminate/console": "^13.0",
"illuminate/filesystem": "^13.0",
"illuminate/http": "^13.0",
"illuminate/support": "^13.0",
"laravel/prompts": "^0.3",
"symfony/process": "^7.0"
},
"require-dev": {
"orchestra/testbench": "^10.0",
"orchestra/testbench": "^11.0",
"phpunit/phpunit": "^11.0"
},
"autoload": {
Expand Down
77 changes: 63 additions & 14 deletions src/Console/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ protected function promptForModules(): void
* @param string[] $packages
* @return string[]
*/
protected function filterModulesByFramework(array $packages, string $framework): array
public function filterModulesByFramework(array $packages, string $framework): array
{
return array_values(array_filter(
$packages,
Expand Down Expand Up @@ -346,7 +346,10 @@ protected function setupModules(): void
return $process->isSuccessful();
});

// Phase 3: sync module configs, then migrate all in one pass
// Phase 2.5: apply any patches the modules ship for the host app
$this->applyModulePatches($selected);

// Phase 3: sync module configs, then migrate + seed each module individually
$this->components->task('Syncing modules', function () {
$process = new Process([PHP_BINARY, base_path('artisan'), 'modules:sync']);
$process->setTimeout(30);
Expand All @@ -355,19 +358,69 @@ protected function setupModules(): void
return $process->isSuccessful();
});

$this->components->task('Running module migrations', function () {
$process = new Process([PHP_BINARY, base_path('artisan'), 'migrate', '--seed', '--force']);
$process->setTimeout(120);
$process->run();
foreach ($selected as $package) {
$name = Str::after($package, '/');

return $process->isSuccessful();
});
$this->components->task("Migrating {$name}", function () use ($name) {
$process = new Process([PHP_BINARY, base_path('artisan'), 'modules:migrate', "--module={$name}", '--force']);
$process->setTimeout(120);
$process->run();

return $process->isSuccessful();
});

$this->components->task("Seeding {$name}", function () use ($name) {
$process = new Process([PHP_BINARY, base_path('artisan'), 'modules:seed', "--module={$name}"]);
$process->setTimeout(60);
$process->run();

return $process->isSuccessful();
});
}
}

public function applyModulePatches(array $modules): void
{
foreach ($modules as $package) {
$name = Str::after($package, '/');

$dirs = array_filter([
base_path("vendor/saucebase/{$name}/patches"),
base_path("modules/{$name}/patches"),
], 'is_dir');

foreach ($dirs as $dir) {
foreach (glob("{$dir}/*.patch") ?: [] as $patch) {
$label = basename($patch);

$check = new Process(['git', 'apply', '--check', '--whitespace=nowarn', $patch]);
$check->setWorkingDirectory(base_path());
$check->run();

if (! $check->isSuccessful()) {
$this->warn("Skipping {$label}: already applied or conflicts.");

continue;
}

$apply = new Process(['git', 'apply', '--whitespace=nowarn', $patch]);
$apply->setWorkingDirectory(base_path());
$apply->run();

if ($apply->isSuccessful()) {
$this->info("Applied patch: {$label}");
} else {
$this->warn("Failed to apply {$label}: ".$apply->getErrorOutput());
}
}
}
}
}

/**
* @return string[]
*/
protected function fetchAvailableModules(): array
public function fetchAvailableModules(): array
{
if (! empty($this->availableModules)) {
return $this->availableModules;
Expand Down Expand Up @@ -495,11 +548,7 @@ protected function displaySuccess(): void
$this->newLine();
$this->line('Next steps:');
$this->line(' 1. Ensure <fg=yellow>APP_URL</> is set correctly in <fg=yellow>.env</>');
if ($this->option('driver') === 'docker') {
$this->line(' 2. Start the dev server: <fg=yellow>npm run dev</>');
} else {
$this->line(' 2. Start the dev server: <fg=yellow>php artisan serve or composer dev</>');
}
$this->line(' 2. Start the dev server: <fg=yellow>'.($this->option('driver') === 'docker' ? 'npm run dev' : 'php artisan serve or composer dev').'</>');
$this->line(' 3. Open your app in the browser: <fg=yellow>'.config('app.url').'</>');
$this->newLine();
$this->line('Learn more: <fg=cyan>https://github.com/saucebase-dev/saucebase</>');
Expand Down
Loading