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

## What this package is

`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/installer` is a **globally-installed Composer CLI** (like `laravel/installer`) that creates and configures new Saucebase applications. It ships a `saucebase` binary (`bin/saucebase`) exposing three commands:

`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).
- `saucebase new <name>` — creates a new project (via `laravel/installer`) and runs the full install flow against it.
- `saucebase install` — runs the install flow against an existing Saucebase app in the current directory (used internally by `new`, and available standalone).
- `saucebase stack <vue|react>` — selects/switches the frontend framework in an app directory.

It is **not** a Laravel package — there is no service provider and no package discovery. It runs standalone via a minimal `Illuminate\Console\Application` (see `src/Console/Application.php`). Local PHP + Composer are the only universal prerequisites (same as `laravel/installer` itself). Docker stubs (`docker-compose.yml`, `Dockerfile`, `nginx.conf`, `php.ini`, `xdebug.ini`) live in `stubs/docker/` and are copied directly into the target app by `DockerEnvironment::publishStubs()` (no `vendor:publish`).

Install globally with `composer global require saucebase/installer`, then `saucebase new my-app`.

## Commands

Expand All @@ -19,68 +25,66 @@ composer install

# Run a single test
./vendor/bin/phpunit --no-coverage --filter test_fetch_package_frameworks_reads_saucebase_extra_field

# Smoke-test the binary
./bin/saucebase list
```

## Architecture

**Entry point:** `InstallerServiceProvider` registers `InstallCommand` and `StackCommand` when running in console, and publishes Docker stubs under the `saucebase-docker` tag. Package discovery is automatic via `extra.laravel.providers` in `composer.json`.
**Bootstrap:** `bin/saucebase` finds the Composer autoloader (global or local), then `Saucebase\Installer\Console\Application::make()` builds a standalone `Illuminate\Console\Application` backed by `src/Console/Container.php` (a minimal container exposing `runningUnitTests()`, which the console prompt layer probes for). Commands are registered via `resolveCommands()`. The app version comes from `Composer\InstalledVersions` for `saucebase/installer`.

**`InstallCommand`** selects an environment driver, then orchestrates the install flow:
**Target-path model:** the old package ran *inside* a Laravel app and used `base_path()`. The CLI now operates on an external target directory. Every command takes a `--path` option (defaults to `getcwd()`); `InstallCommand::path()` / `targetPath()` resolve it, and artisan sub-steps run via subprocess through `InstallCommand::runArtisan([...])` (`php <path>/artisan ...` with `cwd` set), never in-process `Artisan::call()`.

- `--driver=docker` → `DockerEnvironment`: see Docker flow below.
- `--driver=native` (default when not prompted) → `NativeEnvironment`: delegates to `InstallCommand::install()`.
### `NewCommand` (`saucebase new`)
1. `displayWelcome()` banner (shared trait `Concerns/DisplaysBanner`).
2. `checkForUpdates()` — GETs `repo.packagist.org/p2/saucebase/installer.json` (2s timeout, silent offline). **Warns** when outdated, **hard-blocks (FAILURE)** when a full major version behind. Skipped for dev versions.
3. Collects all prompts upfront (name → driver → SSL if docker → stack → modules) so the install runs unattended. Driver default comes from `~/.config/saucebase/config.json`, saved after the first successful install.
4. Prerequisite check for the chosen driver; on failure prints fix hints (Docker Desktop URL, or the php.new one-liner for the current OS).
5. `createProject()` — invokes `LaravelNewCommand` (a subclass of `laravel/installer`'s `NewCommand`) non-interactively with `--using=saucebase/saucebase --phpunit --no-node --no-boost --git`. The subclass no-ops `displayHeader()` and `checkForUpdate()` (Saucebase owns those).
6. Calls `install` against the created directory with all collected answers, then persists the driver preference.

**Native install steps** (run by `NativeEnvironment` via `install()`):
### `InstallCommand` (`saucebase install`)
`handle()` shows the banner, captures the stack, then (unless CI) resolves the driver via `Environment::make()` and runs it. Drivers live in `src/Environments/` and extend the abstract `Environment` (which has the static `make()` factory, the `run()` template method, and `resolveModules()`).

**Native flow** (`NativeEnvironment::boot()` → `InstallCommand::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, batches all selected into one `composer require` call, then: `applyModulePatches()` → `modules:sync` → `migrate --force` (auto-discovered via InterNACHI/modular) → `db:seed --module={name} --force` per module
2. `generateApplicationKey()` — skips if `APP_KEY` already set (`envHasAppKey()` reads the target `.env` directly)
3. `setupDatabase()` — `migrate` (or `migrate:fresh` with `--fresh`) with seed, via `runArtisan()`
4. `runStack()` — calls the `stack` command with `--path` pointing at the target app
5. `setupModules()` — Packagist discovery, one batched `composer require` (cwd = target), then `applyModulePatches()` → `modules:sync` → `migrate --force` → per-module `db:seed --module={name} --force`. `--modules=none` skips modules entirely.
6. `createStorageLink()` + `clearCaches()`

**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()` — single batched `composer require` for all modules in container → `applyModulePatches()` on host → `modules:sync` → `migrate --force` → `db:seed --module={name} --force` per module 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.
**Docker flow** (`DockerEnvironment::boot()`):
1. `promptForSsl()` — `--ssl=yes|no` if given, else `--force` ⇒ on, else prompt (requires mkcert)
2. SSL gate: requested but no `mkcert` → FAILURE with install hint
3. `publishStubs()` — **copies `stubs/docker/*` directly** into the target app (skips files that already exist); if SSL off, overwrites `docker/nginx.conf` with `nginx-no-ssl.conf`
4. `generateSsl()` — mkcert for `*.localhost` (no-op if disabled or certs exist)
5. `ensureEnvFile()` → `setDockerEnvDefaults()` → `applyDockerEnvDefaults()`: `DB_CONNECTION=mysql`, MySQL creds, `MAIL_MAILER=smtp`, `APP_URL=https://localhost` (or `http://` if SSL off)
6. `startDocker()` — `docker compose restart` + `up -d --wait --build` (30 min timeout, streaming), cwd = target
7. `runComposerInContainer()` → `generateAppKey()` → `runMigrations()` via `execInContainer()`
8. `runStack()` — **runs on the host** (files are volume-mounted) by delegating to `InstallCommand::runStack()`, not inside the container
9. `installModules()` — batched `composer require` in container → `applyModulePatches()` on host → `modules:sync` / `migrate` / per-module seed in container
10. `createStorageLink()` + `clearCaches()` in container

**`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.
**`applyModulePatches(array $modules)`** (public on `InstallCommand`) — for each module looks for `*.patch` in `<path>/vendor/saucebase/{name}/patches/` and `<path>/modules/{name}/patches/`. `git apply --check` first (skip if applied/conflicts), then `git apply`, with working directory = target. `git apply` does not require a git repo, but `new` passes `--git` so one exists.

**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()`.
**`ModuleRegistry`** (`src/ModuleRegistry.php`) — shared by `new` and `install`. `available()` hits `packagist.org/packages/list.json?type=saucebase-module` (excludes abandoned). `frameworks($pkg)` reads `extra.saucebase.frameworks` from a local `modules/{name}/composer.json` if a modules path was given, else GitHub raw, defaulting to `['vue']`. `filterByFramework()` and `promptSelection()` complete the API.

**`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`.
**`StackCommand`** Vue/React selection. Takes `--path`; `basePath`/`jsRoot` resolve from `--path` (or an injected constructor path in tests) at `handle()` time. Supports `--dev` (contributor mode — config only, keeps both dirs, uses git skip-worktree) and `--reset`.

**Module discovery** hits `packagist.org/packages/list.json?type=saucebase-module` and filters by the `extra.saucebase.frameworks` field in each module's `composer.json` (falling back to GitHub raw if not on disk, defaulting to `['vue']`).
### Drivers
`src/Environments/`: `Environment` (abstract base + `make()` factory), `DockerEnvironment`, `NativeEnvironment`. Add a driver (Valet, Herd, Sail) by extending `Environment` and adding a `match` arm to `Environment::make()`.

## Testing

Uses [Orchestral Testbench](https://github.com/orchestral/testbench) — no full Laravel app needed. `TestCase` in `tests/TestCase.php` registers `InstallerServiceProvider`.
Plain **PHPUnit** (no Testbench, no Laravel app). `tests/TestCase.php` builds the standalone console app and exposes `artisan($cli)` returning a `CommandResult` (`tests/CommandResult.php`) with `assertSuccessful()` / `assertFailed()` / `expectsOutputToContain()` / `doesntExpectOutputToContain()`. `bindCommand()` swaps in a stubbed command instance for a following `artisan()` call.

- `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` — 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.
- `InstallCommandTest` — `fetchPackageFrameworks()`, `filterModulesByFramework()`, stack dispatch, driver selection, `--driver=native`, module resolution. `TestableInstallCommand` (bottom of file) exposes protected methods and stubs `doInstallModules`; `fakeOptions['path']` points file-touching tests at a temp dir.
- `StackCommandTest` — dev mode, install mode, reset, git skip-worktree, module/recipe stubs. Binds a `StackCommand` constructed with a temp `basePath`/`jsRoot` and a no-op `runNpmInstall()`.
- `Environments/NativeEnvironmentTest` — `run()` delegates to `install()` and passes the code through.
- `Environments/DockerEnvironmentTest` — `resolveModules()`, `applyDockerEnvDefaults()` (all SSL branches), SSL gate, `missingPrerequisites()`, `generateAppKey()` idempotency. `FakeInstallCommand` (bottom of file) stubs output + options; pass `['path' => $tmp]` for `.env`-reading tests.

## Wiring into the host app
## Relationship to the skeleton

```json
// saucebase/composer.json
"require-dev": { "saucebase/installer": "^2.0" }
```

Setup flow after `composer install`:
```bash
php artisan saucebase:install # prompts for stack and driver
php artisan saucebase:install --driver=docker # skip prompt, use Docker
php artisan saucebase:install --driver=native # skip prompt, run natively
```
The `saucebase/saucebase` skeleton must be published on Packagist for `--using=saucebase/saucebase` to resolve. The skeleton no longer depends on this package (`require-dev` dropped) — this is a **clean break**; existing apps that were installed with the old in-app package keep working as-is. Override the skeleton with `saucebase new my-app --using=vendor/other-skeleton`.
Loading