From d4a860198bc13b9194149c8f9364dd0d219c49c7 Mon Sep 17 00:00:00 2001 From: roble Date: Tue, 21 Jul 2026 09:24:22 +0100 Subject: [PATCH 1/3] feat: add queue service to docker-compose.yml with environment configuration --- stubs/docker/docker-compose.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/stubs/docker/docker-compose.yml b/stubs/docker/docker-compose.yml index 4c5fb05..3bd1705 100644 --- a/stubs/docker/docker-compose.yml +++ b/stubs/docker/docker-compose.yml @@ -102,6 +102,38 @@ services: - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025' networks: - saucebase + queue: + build: + context: . + dockerfile: ./docker/Dockerfile + command: php artisan queue:work --tries=3 --backoff=5 + volumes: + - './:/var/www' + networks: + - saucebase + environment: + - APP_ENV=${APP_ENV:-local} + - APP_KEY=${APP_KEY} + - APP_DEBUG=${APP_DEBUG:-true} + - DB_CONNECTION=${DB_CONNECTION:-mysql} + - DB_HOST=mysql + - DB_PORT=${DB_PORT:-3306} + - DB_DATABASE=${DB_DATABASE} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=${REDIS_PORT:-6379} + - CACHE_DRIVER=${CACHE_DRIVER:-redis} + - SESSION_DRIVER=${SESSION_DRIVER:-redis} + - QUEUE_CONNECTION=${QUEUE_CONNECTION:-redis} + - MAIL_HOST=${MAIL_HOST:-mailpit} + - MAIL_PORT=${MAIL_PORT:-1025} + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped networks: saucebase: From 525279a8d68d536b03e2f3ed71818d33677104da Mon Sep 17 00:00:00 2001 From: roble Date: Tue, 21 Jul 2026 16:23:35 +0100 Subject: [PATCH 2/3] feat: Introduce ModuleRegistry for dynamic module discovery and selection - Added ModuleRegistry class to discover installable Saucebase modules from Packagist. - Implemented methods for filtering modules by framework and prompting user selection. - Enhanced Environment class with a static make method for creating environment instances. - Updated NativeEnvironment to read APP_URL from .env file. - Removed InstallerServiceProvider as it is no longer needed. - Refactored tests to accommodate changes in command structure and environment handling. - Updated test cases to ensure proper functionality of new features and maintain coverage. --- CLAUDE.md | 98 +++--- README.md | 134 ++++---- bin/saucebase | 18 + composer.json | 18 +- src/Console/Application.php | 43 +++ .../Commands/Concerns/DisplaysBanner.php | 51 +++ src/Console/Commands/InstallCommand.php | 267 ++++++--------- src/Console/Commands/LaravelNewCommand.php | 25 ++ src/Console/Commands/NewCommand.php | 323 ++++++++++++++++++ src/Console/Commands/StackCommand.php | 24 +- src/Console/Container.php | 18 + src/Environments/DockerEnvironment.php | 82 +++-- src/Environments/Environment.php | 31 ++ src/Environments/NativeEnvironment.php | 2 +- src/InstallerServiceProvider.php | 28 -- src/ModuleRegistry.php | 111 ++++++ tests/CommandResult.php | 41 +++ .../Environments/DockerEnvironmentTest.php | 14 +- .../Environments/NativeEnvironmentTest.php | 10 +- tests/Feature/InstallCommandTest.php | 69 ++-- tests/Feature/StackCommandTest.php | 144 +++++--- tests/TestCase.php | 42 ++- 22 files changed, 1124 insertions(+), 469 deletions(-) create mode 100755 bin/saucebase create mode 100644 src/Console/Application.php create mode 100644 src/Console/Commands/Concerns/DisplaysBanner.php create mode 100644 src/Console/Commands/LaravelNewCommand.php create mode 100644 src/Console/Commands/NewCommand.php create mode 100644 src/Console/Container.php delete mode 100644 src/InstallerServiceProvider.php create mode 100644 src/ModuleRegistry.php create mode 100644 tests/CommandResult.php diff --git a/CLAUDE.md b/CLAUDE.md index 075a0a1..cfe799b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` — 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 ` — 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 @@ -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 /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 `/vendor/saucebase/{name}/patches/` and `/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`. diff --git a/README.md b/README.md index 84b1722..9e75624 100644 --- a/README.md +++ b/README.md @@ -2,131 +2,115 @@ ![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. +The official CLI for creating [Saucebase](https://github.com/saucebase-dev/saucebase) applications — a globally-installed Composer package, like `laravel/installer`. -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. +```bash +composer global require saucebase/installer +saucebase new my-app +``` + +`saucebase new` creates a new project and runs the full install flow (environment, database, frontend, modules) in one pass. No PHP yet? Use the one-command bootstrap at [install.saucebase.dev](https://install.saucebase.dev). ## Requirements -- PHP ^8.4 -- Laravel ^13.0 +- PHP ^8.4 and Composer (the only universal prerequisites — same as Laravel itself) +- **Docker driver:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Docker Engine with the Compose plugin; [mkcert](https://github.com/FiloSottile/mkcert) only if you enable SSL +- **Native driver:** a local PHP capable of running the app; the CLI points you to [php.new](https://php.new) if anything is missing + +The CLI **checks and instructs** for prerequisites — it never installs them for you. ## Installation ```bash -composer require --dev saucebase/installer +composer global require saucebase/installer ``` +Make sure Composer's global bin directory is on your `PATH` (`composer global config bin-dir --absolute`). + ## Commands -### `saucebase:install` +### `saucebase new ` -Bootstraps a new Saucebase dev environment from scratch. +Creates a new Saucebase application and installs it end to end. ```bash -php artisan saucebase:install +saucebase new my-app ``` -Prompts for the frontend stack (Vue or React) and the environment driver (Docker or native PHP), then runs the full setup sequence. - -**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 (single `composer require` for all → patches → `modules:sync` → `migrate` → `db:seed --module` 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 +Runs a self-update check, then collects every choice upfront (driver → SSL if Docker → stack → modules) so the install runs unattended. It scaffolds the app via `laravel/installer` (using the `saucebase/saucebase` starter kit), then runs the full install flow against it. Your driver choice is remembered in `~/.config/saucebase/config.json` as the default for next time. **Options** | Option | Description | |--------|-------------| -| `vue` / `react` | Frontend stack (positional argument — skips the prompt) | | `--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, short or full package name) | -| `--dev` | Contributor mode — skips module installation | -| `--force` | Skip confirmations (Docker driver defaults to SSL when forced) | -| `--no-logo` | Suppress the welcome banner | +| `--stack=vue\|react` | Frontend stack (skips the prompt) | +| `--ssl=yes\|no` | Enable HTTPS via mkcert, Docker only (skips the prompt) | +| `--modules=auth,billing` | Install specific modules by name, or `none` to skip modules | +| `--all-modules` | Install every module compatible with the stack | +| `--using=vendor/skeleton` | Use a different starter kit instead of `saucebase/saucebase` | +| `--fresh` | Run `migrate:fresh` instead of `migrate` (destructive) | +| `--force` | Overwrite an existing directory and skip confirmations | +| `--dev` | Contributor mode (uses the skeleton's dev branch, skips modules) | **Examples** -Fully interactive — prompts for stack, driver, SSL, and modules: ```bash -php artisan saucebase:install -``` +# Fully interactive +saucebase new my-app -Vue + Docker, fresh database, all compatible modules: -```bash -php artisan saucebase:install vue --driver=docker --fresh --all-modules +# Vue + Docker with SSL, all compatible modules +saucebase new my-app --driver=docker --ssl=yes --stack=vue --all-modules + +# React + native PHP, no modules +saucebase new my-app --driver=native --stack=react --modules=none ``` -React + native PHP, specific modules only: +--- + +### `saucebase install` + +Runs the install flow against an **existing** Saucebase app. This is what `new` calls under the hood; run it standalone to (re)provision an app you already have. + ```bash -php artisan saucebase:install react --driver=native --modules=auth,billing +cd my-app +saucebase install ``` +Takes an optional `stack` argument (`vue`/`react`) and the same `--driver`, `--ssl`, `--modules`, `--fresh`, `--force` options as `new`, plus `--path` to target a directory other than the current one. + +**Docker driver** — copies the Docker stubs (`docker-compose.yml`, `Dockerfile`, `nginx.conf`, …), generates SSL certs if enabled, patches `.env` with MySQL/Mailpit/`APP_URL` defaults, brings up `docker compose`, then generates `APP_KEY`, migrates, wires the frontend, and installs modules. + +**Native driver** — copies `.env.example` → `.env`, generates `APP_KEY`, migrates and seeds, wires the frontend, installs modules, and links storage. + --- -### `saucebase:stack` +### `saucebase stack ` -Selects or switches the frontend framework for an existing Saucebase installation. +Selects or switches the frontend framework for an app. ```bash -php artisan saucebase:stack vue -php artisan saucebase:stack react +saucebase stack vue +saucebase stack react ``` -Copies the framework-specific JS source files, config files (`package.json`, `vite.config.js`, `tsconfig.json`, `eslint.config.js`, `components.json`), lockfile, and blade layout into place, then removes the unused framework's source directory. Writes `frontend.json` to record the selection. - -> **Note:** Framework selection is permanent for a given installation. To switch, start a new project or use `--reset`. +Copies the framework-specific source, config (`package.json`, `vite.config.js`, `tsconfig.json`, …), lockfile, and blade layout into place, removes the unused framework's directory, and records the choice in `frontend.json`. **Options** | Option | Description | |--------|-------------| -| `vue` / `react` | Framework to activate (positional argument) | -| `--dev` | Contributor mode — copies config files only, keeps both framework source directories, runs `npm install` | -| `--reset` | Reverts generated files to their pre-selection state (restores from git, deletes `package-lock.json`) | -| `--no-skip-worktree` | Do not mark generated files as `skip-worktree` in git (dev mode only) | +| `--path=` | Target app directory (defaults to the current directory) | +| `--dev` | Contributor mode — config only, keeps both source dirs, uses git `skip-worktree`, runs `npm install` | +| `--reset` | Reverts generated files to their pre-selection state | -**Examples** +> **Note:** Framework selection is permanent for a given installation. To switch, start a new project or use `--reset` (dev mode). -Activate Vue (install mode — irreversible): -```bash -php artisan saucebase:stack vue -``` +## How it works -Activate React in contributor mode (keeps both source dirs): -```bash -php artisan saucebase:stack react --dev -``` - -Undo a previous `--dev` selection: -```bash -php artisan saucebase:stack --reset -``` +`saucebase` is a standalone `Illuminate\Console\Application` (not a Laravel package — no service provider). It operates on a target directory via `--path`, running artisan steps as subprocesses (`php /artisan …`). Project creation is delegated to `laravel/installer`; everything after — environment, database, frontend, modules — is the Saucebase install flow. ## License diff --git a/bin/saucebase b/bin/saucebase new file mode 100755 index 0000000..06efa90 --- /dev/null +++ b/bin/saucebase @@ -0,0 +1,18 @@ +#!/usr/bin/env php +setCatchExceptions(true); + +exit($application->run()); diff --git a/composer.json b/composer.json index d3308f1..277ab72 100644 --- a/composer.json +++ b/composer.json @@ -1,20 +1,24 @@ { "$schema": "https://getcomposer.org/schema.json", "name": "saucebase/installer", - "description": "Dev environment installer for Saucebase applications.", + "description": "The Saucebase application installer.", "type": "library", "license": "MIT", "require": { "php": "^8.4", + "composer-runtime-api": "^2.2", + "guzzlehttp/guzzle": "^7.8", "illuminate/console": "^13.0", + "illuminate/container": "^13.0", + "illuminate/events": "^13.0", "illuminate/filesystem": "^13.0", "illuminate/http": "^13.0", "illuminate/support": "^13.0", + "laravel/installer": "^5.14", "laravel/prompts": "^0.3", "symfony/process": "^7.0" }, "require-dev": { - "orchestra/testbench": "^11.0", "phpunit/phpunit": "^11.0" }, "autoload": { @@ -27,13 +31,9 @@ "Saucebase\\Installer\\Tests\\": "tests/" } }, - "extra": { - "laravel": { - "providers": [ - "Saucebase\\Installer\\InstallerServiceProvider" - ] - } - }, + "bin": [ + "bin/saucebase" + ], "scripts": { "test": "phpunit" }, diff --git a/src/Console/Application.php b/src/Console/Application.php new file mode 100644 index 0000000..9597216 --- /dev/null +++ b/src/Console/Application.php @@ -0,0 +1,43 @@ +singleton(HttpFactory::class); + Facade::setFacadeApplication($container); + + $console = new ConsoleApplication($container, new Dispatcher($container), static::version()); + $console->setName('Saucebase Installer'); + + $console->resolveCommands([ + NewCommand::class, + InstallCommand::class, + StackCommand::class, + ]); + + return $console; + } + + public static function version(): string + { + try { + return InstalledVersions::getPrettyVersion('saucebase/installer') ?? 'dev'; + } catch (\OutOfBoundsException) { + return 'dev'; + } + } +} diff --git a/src/Console/Commands/Concerns/DisplaysBanner.php b/src/Console/Commands/Concerns/DisplaysBanner.php new file mode 100644 index 0000000..a288279 --- /dev/null +++ b/src/Console/Commands/Concerns/DisplaysBanner.php @@ -0,0 +1,51 @@ +newLine(); + + foreach ($lines as $line) { + $sauce = substr($line, 0, $split); + $base = substr($line, $split); + $this->line("{$sauce}{$base}"); + } + + $this->displayTagline(); + } + + protected function displayTagline(): void + { + $primary = '#5455c4'; + $logoWidth = 84; + $slogan = 'With Saucebase • Your foundation is ready!'; + + $padding = ''.str_repeat(' ', $logoWidth).''; + $tagline = ''.mb_str_pad($slogan, $logoWidth, ' ', STR_PAD_BOTH).''; + + $this->newLine(2); + $this->line($padding); + $this->line($tagline); + $this->line($padding); + $this->newLine(); + $this->newLine(); + } +} diff --git a/src/Console/Commands/InstallCommand.php b/src/Console/Commands/InstallCommand.php index 3ed8279..1e53959 100644 --- a/src/Console/Commands/InstallCommand.php +++ b/src/Console/Commands/InstallCommand.php @@ -3,41 +3,40 @@ namespace Saucebase\Installer\Console\Commands; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use Saucebase\Installer\Environments\DockerEnvironment; +use Saucebase\Installer\Console\Commands\Concerns\DisplaysBanner; use Saucebase\Installer\Environments\Environment; -use Saucebase\Installer\Environments\NativeEnvironment; +use Saucebase\Installer\ModuleRegistry; use Symfony\Component\Process\Process; -use function Laravel\Prompts\multiselect; use function Laravel\Prompts\select; class InstallCommand extends Command { - protected $signature = 'saucebase:install + use DisplaysBanner; + + protected $signature = 'install {stack? : The frontend stack to install (vue or react)} + {--path= : The Saucebase application directory (defaults to the current directory)} {--driver= : Environment driver (docker, native) — prompted if omitted} + {--ssl= : Enable HTTPS with mkcert for docker (yes/no) — prompted if omitted} {--fresh : Run migrate:fresh instead of migrate (destructive)} {--all-modules : Enable and migrate all available modules without prompting} - {--modules= : Comma-separated list of modules to enable (e.g. Auth,Settings)} + {--modules= : Comma-separated list of modules to enable (e.g. Auth,Settings), or "none"} {--dev : Dev environment} {--force : Skip confirmations} {--no-logo : Suppress the welcome banner}'; - protected $description = 'Install and configure Saucebase'; + protected $description = 'Install and configure an existing Saucebase application'; protected ?string $selectedStack = null; /** @var string[] */ protected array $selectedModules = []; - /** @var string[] */ - protected array $availableModules = []; + protected ?string $resolvedTargetPath = null; - /** @var array */ - protected array $moduleFrameworks = []; + protected ?ModuleRegistry $registry = null; public function handle(): int { @@ -65,6 +64,41 @@ public function handle(): int return $driver->run($this); } + public function targetPath(): string + { + if ($this->resolvedTargetPath === null) { + try { + $path = $this->option('path'); + } catch (\Throwable) { + // No input bound (command instantiated outside the console app). + $path = null; + } + + $this->resolvedTargetPath = rtrim($path ?: getcwd(), '/'); + } + + return $this->resolvedTargetPath; + } + + public function path(string $relative = ''): string + { + return $relative === '' ? $this->targetPath() : $this->targetPath().'/'.$relative; + } + + /** + * Run an artisan command inside the target application via a subprocess. + * + * @param string[] $args + */ + public function runArtisan(array $args, int $timeout = 120): bool + { + $process = new Process([PHP_BINARY, $this->path('artisan'), ...$args], $this->targetPath()); + $process->setTimeout($timeout); + $process->run(); + + return $process->isSuccessful(); + } + public function getSelectedStack(): ?string { return $this->selectedStack; @@ -87,11 +121,7 @@ protected function resolveDriver(): Environment default: 'docker', ); - return match ($name) { - 'docker' => new DockerEnvironment, - 'native' => new NativeEnvironment, - default => throw new \InvalidArgumentException("Unknown driver: {$name}"), - }; + return Environment::make($name); } protected function captureStack(): void @@ -111,11 +141,14 @@ protected function captureStack(): void $this->selectedStack = $stack; } - protected function runStack(): void + public function runStack(): void { if ($this->selectedStack) { $isDev = $this->option('dev') ? ['--dev' => true] : []; - $this->call('saucebase:stack', array_merge(['stack' => $this->selectedStack], $isDev)); + $this->call('stack', array_merge( + ['stack' => $this->selectedStack, '--path' => $this->targetPath()], + $isDev, + )); } } @@ -139,17 +172,7 @@ public function promptForModules(): void return; } - $options = collect($available) - ->mapWithKeys(fn (string $package) => [ - $package => Str::studly(Str::after($package, '/')), - ]) - ->all(); - - $this->selectedModules = multiselect( - label: 'Which modules would you like to install?', - options: $options, - default: [], - ); + $this->selectedModules = $this->registry()->promptSelection($available); } /** @@ -169,38 +192,17 @@ public function filterModulesByFramework(array $packages, string $framework): ar */ protected function fetchPackageFrameworks(string $package): array { - if (isset($this->moduleFrameworks[$package])) { - return $this->moduleFrameworks[$package]; - } - - $name = Str::after($package, '/'); - $localManifest = $this->modulesBasePath()."/{$name}/composer.json"; - - if (file_exists($localManifest)) { - $local = json_decode((string) file_get_contents($localManifest), true); - $frameworks = data_get($local, 'extra.saucebase.frameworks'); - - if (is_array($frameworks) && ! empty($frameworks)) { - return $this->moduleFrameworks[$package] = $frameworks; - } - } - - $response = Http::timeout(5)->get("https://raw.githubusercontent.com/saucebase-dev/{$name}/main/composer.json"); - - if ($response->ok()) { - $frameworks = data_get($response->json(), 'extra.saucebase.frameworks'); - - if (is_array($frameworks) && ! empty($frameworks)) { - return $this->moduleFrameworks[$package] = $frameworks; - } - } + return $this->registry()->frameworks($package); + } - return $this->moduleFrameworks[$package] = ['vue']; + protected function registry(): ModuleRegistry + { + return $this->registry ??= new ModuleRegistry($this->modulesBasePath()); } protected function modulesBasePath(): string { - return base_path('modules'); + return $this->path('modules'); } public function install(): int @@ -229,7 +231,7 @@ protected function handleCIInstallation(): int $this->info('CI environment detected - running minimal setup...'); $envOk = $this->ensureEnvFile(); - $keyOk = ! empty(config('app.key')); + $keyOk = $this->envHasAppKey(); $this->components->task('Verifying .env', fn () => $envOk); $this->components->task('Verifying app key', fn () => $keyOk); @@ -245,12 +247,12 @@ protected function handleCIInstallation(): int public function ensureEnvFile(): bool { - if (file_exists(base_path('.env'))) { + if (file_exists($this->path('.env'))) { return true; } - if (file_exists(base_path('.env.example'))) { - if (! copy(base_path('.env.example'), base_path('.env'))) { + if (file_exists($this->path('.env.example'))) { + if (! copy($this->path('.env.example'), $this->path('.env'))) { $this->error('Failed to copy .env.example to .env. Check directory permissions.'); return false; @@ -264,20 +266,21 @@ public function ensureEnvFile(): bool return false; } - protected function generateApplicationKey(): void + public function envHasAppKey(): bool { - $this->components->task('Generating application key', function () { - $env = file_get_contents(base_path('.env')); + $env = @file_get_contents($this->path('.env')); - if ($env === false) { - return Artisan::call('key:generate', ['--force' => true]) === 0; - } + return $env !== false && preg_match('/^APP_KEY=base64:.+$/m', $env) === 1; + } - if (preg_match('/^APP_KEY=base64:.+$/m', $env)) { + protected function generateApplicationKey(): void + { + $this->components->task('Generating application key', function () { + if ($this->envHasAppKey()) { return true; } - return Artisan::call('key:generate', ['--force' => true]) === 0; + return $this->runArtisan(['key:generate', '--force']); }); } @@ -289,7 +292,7 @@ protected function setupDatabase(): bool $ok = false; $this->components->task($label, function () use ($command, &$ok) { - return $ok = Artisan::call($command, ['--seed' => true, '--force' => true]) === 0; + return $ok = $this->runArtisan([$command, '--seed', '--force'], 300); }); return $ok; @@ -297,8 +300,14 @@ protected function setupDatabase(): bool protected function setupModules(): void { + $opt = $this->option('modules'); + + if ($opt === 'none') { + return; + } + // Fast path: skip Packagist discovery when all requested names are fully qualified - if ($opt = $this->option('modules')) { + if ($opt) { $names = array_values(array_filter(array_map(fn ($n) => strtolower(trim($n)), explode(',', $opt)))); if ($names && ! array_filter($names, fn ($n) => ! str_contains($n, '/'))) { $this->doInstallModules($names); @@ -307,6 +316,10 @@ protected function setupModules(): void } } + if (! $opt && empty($this->selectedModules) && ! $this->option('all-modules')) { + return; + } + $available = $this->fetchAvailableModules(); if (empty($available)) { @@ -331,7 +344,10 @@ protected function doInstallModules(array $selected): void // Phase 1: require all selected packages in one Composer run $ok = false; $this->components->task('Installing modules', function () use ($selected, &$ok) { - $process = new Process(array_merge(['composer', 'require', '--no-interaction'], $selected)); + $process = new Process( + array_merge(['composer', 'require', '--no-interaction'], $selected), + $this->targetPath(), + ); $process->setTimeout(300); $process->run(); @@ -348,21 +364,9 @@ protected function doInstallModules(array $selected): void $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); - $process->run(); - - return $process->isSuccessful(); - }); + $this->components->task('Syncing modules', fn () => $this->runArtisan(['modules:sync'], 30)); - $this->components->task('Running module migrations', function () { - $process = new Process([PHP_BINARY, base_path('artisan'), 'migrate', '--force']); - $process->setTimeout(300); - $process->run(); - - return $process->isSuccessful(); - }); + $this->components->task('Running module migrations', fn () => $this->runArtisan(['migrate', '--force'], 300)); foreach ($selected as $package) { $name = Str::after($package, '/'); @@ -372,11 +376,7 @@ protected function doInstallModules(array $selected): void } $this->components->task("Seeding {$name}", function () use ($name) { - $process = new Process([PHP_BINARY, base_path('artisan'), 'db:seed', "--module={$name}", '--force']); - $process->setTimeout(60); - $process->run(); - - return $process->isSuccessful(); + return $this->runArtisan(['db:seed', "--module={$name}", '--force'], 60); }); } } @@ -386,7 +386,7 @@ public function rewriteCrossModuleImports(): void $frameworks = ['vue', 'react', 'svelte']; $pattern = implode('|', array_map(fn ($f) => preg_quote($f, '#'), $frameworks)); $extensions = ['vue', 'ts', 'tsx', 'js']; - $moduleDirs = glob(base_path('modules/*/resources/js'), GLOB_ONLYDIR) ?: []; + $moduleDirs = glob($this->path('modules/*/resources/js'), GLOB_ONLYDIR) ?: []; foreach ($moduleDirs as $jsRoot) { $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($jsRoot)); @@ -412,8 +412,8 @@ public function moduleHasSeeder(string $name): bool { $seederFile = 'database/seeders/DatabaseSeeder.php'; - return file_exists(base_path('modules/'.strtolower($name).'/'.$seederFile)) - || file_exists(base_path('vendor/saucebase/'.strtolower($name).'/'.$seederFile)); + return file_exists($this->path('modules/'.strtolower($name).'/'.$seederFile)) + || file_exists($this->path('vendor/saucebase/'.strtolower($name).'/'.$seederFile)); } public function applyModulePatches(array $modules): void @@ -422,8 +422,8 @@ public function applyModulePatches(array $modules): void $name = Str::after($package, '/'); $dirs = array_filter([ - base_path("vendor/saucebase/{$name}/patches"), - base_path("modules/{$name}/patches"), + $this->path("vendor/saucebase/{$name}/patches"), + $this->path("modules/{$name}/patches"), ], 'is_dir'); foreach ($dirs as $dir) { @@ -431,7 +431,7 @@ public function applyModulePatches(array $modules): void $label = basename($patch); $check = new Process(['git', 'apply', '--check', '--whitespace=nowarn', $patch]); - $check->setWorkingDirectory(base_path()); + $check->setWorkingDirectory($this->targetPath()); $check->run(); if (! $check->isSuccessful()) { @@ -441,7 +441,7 @@ public function applyModulePatches(array $modules): void } $apply = new Process(['git', 'apply', '--whitespace=nowarn', $patch]); - $apply->setWorkingDirectory(base_path()); + $apply->setWorkingDirectory($this->targetPath()); $apply->run(); if ($apply->isSuccessful()) { @@ -459,23 +459,7 @@ public function applyModulePatches(array $modules): void */ public function fetchAvailableModules(): array { - if (! empty($this->availableModules)) { - return $this->availableModules; - } - - $response = Http::timeout(10) - ->get('https://packagist.org/packages/list.json?type=saucebase-module&fields[]=abandoned'); - - if (! $response->ok()) { - return []; - } - - $packages = $response->json('packages', []); - - return $this->availableModules = array_keys(array_filter( - $packages, - fn (array $p) => empty($p['abandoned']) - )); + return $this->registry()->available(); } /** @@ -512,16 +496,12 @@ protected function resolveModuleSelection(array $available): array protected function createStorageLink(): void { - $this->components->task('Creating storage link', function () { - return Artisan::call('storage:link') === 0; - }); + $this->components->task('Creating storage link', fn () => $this->runArtisan(['storage:link'])); } protected function clearCaches(): void { - $this->components->task('Clearing caches', function () { - return Artisan::call('optimize:clear') === 0; - }); + $this->components->task('Clearing caches', fn () => $this->runArtisan(['optimize:clear'])); } protected function isCI(): bool @@ -533,51 +513,6 @@ protected function isCI(): bool || ! empty(getenv('TRAVIS')); } - protected function displayWelcome(): void - { - $primary = '#5455c4'; - $secondary = '#26b9d9'; - $split = 48; - - $lines = [ - ' 888 ', - ' 888 ', - ' 888 ', - ' .d8888b 8888b. 888 888 .d8888b .d88b. 88888b. 8888b. .d8888b .d88b. ', - ' 88K "88b 888 888 d88P" d8P Y8b 888 "88b "88b 88K d8P Y8b ', - ' "Y8888b. .d888888 888 888 888 88888888 888 888 .d888888 "Y8888b. 88888888 ', - ' X88 888 888 Y88b 888 Y88b. Y8b. 888 d88P 888 888 X88 Y8b. ', - ' 88888P\' "Y888888 "Y88888 "Y8888P "Y8888 88888P" "Y888888 88888P\' "Y8888 ', - ]; - - $this->newLine(); - - foreach ($lines as $line) { - $sauce = substr($line, 0, $split); - $base = substr($line, $split); - $this->line("{$sauce}{$base}"); - } - - $this->displayTagline(); - } - - protected function displayTagline(): void - { - $primary = '#5455c4'; - $logoWidth = 84; - $slogan = 'With Saucebase • Your foundation is ready!'; - - $padding = ''.str_repeat(' ', $logoWidth).''; - $tagline = ''.mb_str_pad($slogan, $logoWidth, ' ', STR_PAD_BOTH).''; - - $this->newLine(2); - $this->line($padding); - $this->line($tagline); - $this->line($padding); - $this->newLine(); - $this->newLine(); - } - public function displaySuccess(array $steps = []): void { $this->newLine(); diff --git a/src/Console/Commands/LaravelNewCommand.php b/src/Console/Commands/LaravelNewCommand.php new file mode 100644 index 0000000..33ebce2 --- /dev/null +++ b/src/Console/Commands/LaravelNewCommand.php @@ -0,0 +1,25 @@ +displayWelcome(); + + if (($blocked = $this->checkForUpdates()) !== null) { + return $blocked; + } + + $name = $this->argument('name') ?? text( + label: 'What is the name of your project?', + placeholder: 'E.g. my-app', + required: 'The project name is required.', + ); + + $driver = $this->resolveDriver(); + + $missing = $driver->missingPrerequisites(); + if (! empty($missing)) { + foreach ($missing as $message) { + $this->error($message); + } + $this->displayPrerequisiteHints($driver); + + return self::FAILURE; + } + + $ssl = $driver->name() === 'docker' ? $this->resolveSsl($driver) : null; + + if ($ssl === true && ! $driver->hasCommand('mkcert')) { + $this->error('mkcert is required for SSL. Install it with: brew install mkcert'); + $this->info('Official mkcert installation instructions: https://github.com/FiloSottile/mkcert'); + + return self::FAILURE; + } + + $stack = $this->option('stack') ?: select( + label: 'Which frontend stack would you like to use?', + options: ['vue' => 'Vue', 'react' => 'React'], + default: 'vue', + ); + + $modules = $this->resolveModulesUpfront($stack); + + if ($this->createProject($name) !== 0) { + $this->error('Project creation failed.'); + + return self::FAILURE; + } + + $result = $this->call('install', $this->installArguments($name, $driver, $stack, $ssl, $modules)); + + if ($result === self::SUCCESS) { + $this->saveDriverPreference($driver->name()); + $this->newLine(); + $this->line(" Your application is ready in ./{$name}"); + } + + return $result; + } + + protected function resolveDriver(): Environment + { + $name = $this->option('driver') ?? select( + label: 'How would you like to run Saucebase?', + options: [ + 'docker' => 'Docker - recommended for real projects: MySQL, Redis, Mailpit, HTTPS', + 'native' => 'Native PHP - minimal setup, ideal for exploring', + ], + default: $this->savedDriverPreference() ?? 'docker', + ); + + return Environment::make($name); + } + + protected function resolveSsl(Environment $driver): bool + { + $option = $this->option('ssl'); + + return match (true) { + $option !== null && $option !== '' => filter_var($option, FILTER_VALIDATE_BOOLEAN), + (bool) $this->option('force') => true, + default => confirm( + label: 'Enable HTTPS with SSL?', + default: true, + hint: 'Requires mkcert. Install with: brew install mkcert', + ), + }; + } + + /** + * Prompt for modules upfront so the install can run unattended. + * Returns null when module selection is already driven by options. + * + * @return string[]|null + */ + protected function resolveModulesUpfront(string $stack): ?array + { + if ($this->option('all-modules') || $this->option('modules') !== null || $this->option('dev')) { + return null; + } + + $registry = $this->registry(); + $available = $registry->filterByFramework($registry->available(), $stack); + + return empty($available) ? [] : $registry->promptSelection($available); + } + + protected function registry(): ModuleRegistry + { + return new ModuleRegistry; + } + + protected function createProject(string $name): int + { + $laravel = new LaravelNewCommand; + $laravel->setApplication($this->getApplication()); + + $arguments = [ + 'name' => $name, + '--using' => $this->skeletonPackage(), + '--phpunit' => true, + '--no-node' => true, + '--no-boost' => true, + // Initialise git so module patches (git apply) and --dev worktree + // tracking operate on a real repository. + '--git' => true, + ]; + + if ($this->option('force')) { + $arguments['--force'] = true; + } + + // All prompts were collected upfront; the skeleton install runs unattended. + $input = new ArrayInput($arguments); + $input->setInteractive(false); + + return $laravel->run($input, $this->output); + } + + /** + * The skeleton package (with version constraint) to hand to laravel/installer. + * + * laravel/installer always appends `--stability=dev` to its create-project + * call, which would otherwise pull the skeleton's default (dev) branch. We + * pin to the latest stable of the installer's own major version — the + * installer and skeleton share a major by design — so real installs get a + * stable release. Contributors (--dev) and source checkouts get the dev branch. + */ + protected function skeletonPackage(): string + { + if ($using = $this->option('using')) { + return $using; + } + + $package = 'saucebase/saucebase'; + + if ($this->option('dev')) { + return $package; + } + + $major = (int) explode('.', ltrim(Application::version(), 'v'))[0]; + + return $major > 0 ? "{$package}:^{$major}.0" : $package; + } + + /** + * @param string[]|null $modules + * @return array + */ + protected function installArguments(string $name, Environment $driver, string $stack, ?bool $ssl, ?array $modules): array + { + $arguments = [ + 'stack' => $stack, + '--path' => getcwd().'/'.$name, + '--driver' => $driver->name(), + '--no-logo' => true, + ]; + + if ($ssl !== null) { + $arguments['--ssl'] = $ssl ? 'yes' : 'no'; + } + + if ($modules !== null) { + $arguments['--modules'] = empty($modules) ? 'none' : implode(',', $modules); + } elseif ($this->option('modules') !== null) { + $arguments['--modules'] = $this->option('modules'); + } + + foreach (['all-modules', 'dev', 'fresh', 'force'] as $flag) { + if ($this->option($flag)) { + $arguments['--'.$flag] = true; + } + } + + return $arguments; + } + + protected function displayPrerequisiteHints(Environment $driver): void + { + $this->newLine(); + + if ($driver->name() === 'docker') { + $this->line('Install Docker Desktop: https://www.docker.com/products/docker-desktop/'); + + return; + } + + $this->line('Install PHP, Composer and the required tooling with one command via https://php.new:'); + $this->line(match (PHP_OS_FAMILY) { + 'Windows' => ' Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString(\'https://php.new/install/windows\'))', + 'Darwin' => ' /bin/bash -c "$(curl -fsSL https://php.new/install/mac)"', + default => ' /bin/bash -c "$(curl -fsSL https://php.new/install/linux)"', + }); + } + + /** + * Warn when a newer installer exists; block when a major version behind. + */ + protected function checkForUpdates(): ?int + { + $current = ltrim(Application::version(), 'v'); + + if ($current === '' || str_contains($current, 'dev')) { + return null; + } + + $latest = $this->latestVersion(); + + if ($latest === null || version_compare($current, $latest, '>=')) { + return null; + } + + if ((int) explode('.', $latest)[0] > (int) explode('.', $current)[0]) { + $this->error("Your installer ({$current}) is a major version behind the latest release ({$latest}) and may not support current Saucebase applications."); + $this->line('Update it first: composer global update saucebase/installer'); + + return self::FAILURE; + } + + $this->components->warn("A new version of the Saucebase installer is available ({$current} → {$latest}). Update with: composer global update saucebase/installer"); + + return null; + } + + protected function latestVersion(): ?string + { + try { + $response = Http::timeout(2)->get('https://repo.packagist.org/p2/saucebase/installer.json'); + + if (! $response->ok()) { + return null; + } + + $version = $response->json('packages.saucebase/installer.0.version'); + + return is_string($version) ? ltrim($version, 'v') : null; + } catch (\Throwable) { + return null; + } + } + + protected function configFilePath(): string + { + $home = $_SERVER['HOME'] ?? (getenv('HOME') ?: sys_get_temp_dir()); + + return $home.'/.config/saucebase/config.json'; + } + + protected function savedDriverPreference(): ?string + { + $config = @json_decode((string) @file_get_contents($this->configFilePath()), true); + + $driver = $config['driver'] ?? null; + + return in_array($driver, ['docker', 'native'], true) ? $driver : null; + } + + protected function saveDriverPreference(string $driver): void + { + $path = $this->configFilePath(); + + @mkdir(dirname($path), 0755, true); + + $config = @json_decode((string) @file_get_contents($path), true) ?: []; + $config['driver'] = $driver; + + @file_put_contents($path, json_encode($config, JSON_PRETTY_PRINT).PHP_EOL); + } +} diff --git a/src/Console/Commands/StackCommand.php b/src/Console/Commands/StackCommand.php index 4dcb0e0..3678ae7 100644 --- a/src/Console/Commands/StackCommand.php +++ b/src/Console/Commands/StackCommand.php @@ -10,11 +10,13 @@ class StackCommand extends Command { - protected $signature = 'saucebase:stack + protected $signature = 'stack {stack? : The frontend stack to install (vue or react)} + {--path= : The Saucebase application directory (defaults to the current directory)} {--dev : Contributor dev mode — copy config files only, keep both source dirs} {--reset : Reset to clean state with no framework selected} - {--no-skip-worktree : Do not mark generated files as skip-worktree in git}'; + {--no-skip-worktree : Do not mark generated files as skip-worktree in git} + {--no-install : Skip npm install after selecting the framework (dev mode)}'; protected $description = 'Select the frontend framework stack (vue or react) for this Saucebase installation'; @@ -26,9 +28,9 @@ class StackCommand extends Command private const SUPPORTED = ['vue', 'react']; - private string $basePath; + private ?string $basePath; - private string $jsRoot; + private ?string $jsRoot; public function __construct( private readonly Filesystem $files, @@ -36,12 +38,15 @@ public function __construct( ?string $jsRoot = null, ) { parent::__construct(); - $this->basePath = $basePath ?? base_path(); - $this->jsRoot = $jsRoot ?? resource_path('js'); + $this->basePath = $basePath; + $this->jsRoot = $jsRoot; } public function handle(): int { + $this->basePath = $this->option('path') ?: ($this->basePath ?? getcwd()); + $this->jsRoot ??= $this->basePath.'/resources/js'; + if ($this->option('reset')) { return $this->runReset(); } @@ -111,7 +116,7 @@ private function runDevMode(string $framework): int $current = $this->getSelectedFramework(); if ($current !== null) { - $this->error("Framework already set to \"{$current}\". Run `php artisan saucebase:stack --reset` first."); + $this->error("Framework already set to \"{$current}\". Run `saucebase stack --reset` first."); return self::FAILURE; } @@ -129,7 +134,10 @@ private function runDevMode(string $framework): int $this->writeFrontendJson($framework, dev: true); $this->skipGeneratedFiles($framework); - $this->runNpmInstall(); + + if (! $this->option('no-install')) { + $this->runNpmInstall(); + } $this->info("Framework set to {$framework} (dev mode)"); diff --git a/src/Console/Container.php b/src/Console/Container.php new file mode 100644 index 0000000..b92fd61 --- /dev/null +++ b/src/Console/Container.php @@ -0,0 +1,18 @@ +ssl = $command->option('force') - ? true - : confirm( + $option = $command->option('ssl'); + + $this->ssl = match (true) { + $option !== null && $option !== '' => filter_var($option, FILTER_VALIDATE_BOOLEAN), + (bool) $command->option('force') => true, + default => confirm( label: 'Enable HTTPS with SSL?', default: true, hint: 'Requires mkcert. Install with: brew install mkcert', - ); + ), + }; } protected function publishStubs(InstallCommand $command): void { $command->info('Publishing Docker stubs...'); - Artisan::call('vendor:publish', ['--tag' => 'saucebase-docker', '--no-interaction' => true]); + + $stubs = dirname(__DIR__, 2).'/stubs/docker'; + + foreach ([ + 'docker-compose.yml', + 'docker/Dockerfile', + 'docker/nginx.conf', + 'docker/php.ini', + 'docker/xdebug.ini', + ] as $file) { + $destination = $command->path($file); + + if (file_exists($destination)) { + continue; + } + + @mkdir(dirname($destination), 0755, true); + + if (! copy($stubs.'/'.$file, $destination)) { + $command->warn("Failed to publish {$file}."); + } + } if (! $this->ssl) { $copied = copy( - __DIR__.'/../../../stubs/docker/docker/nginx-no-ssl.conf', - base_path('docker/nginx.conf'), + $stubs.'/docker/nginx-no-ssl.conf', + $command->path('docker/nginx.conf'), ); if (! $copied) { $command->warn('Failed to write nginx.conf (no-SSL). Check that Docker stubs were published first.'); @@ -127,8 +151,8 @@ protected function generateSsl(InstallCommand $command): void return; } - $certFile = base_path('docker/ssl/app.pem'); - $keyFile = base_path('docker/ssl/app.key.pem'); + $certFile = $command->path('docker/ssl/app.pem'); + $keyFile = $command->path('docker/ssl/app.key.pem'); if (file_exists($certFile) && file_exists($keyFile)) { return; @@ -156,11 +180,11 @@ protected function startDocker(InstallCommand $command): bool { $command->info('Starting Docker services (this may take a few minutes while pulling images and starting containers)...'); - $restart = new Process(['docker', 'compose', 'restart']); + $restart = new Process(['docker', 'compose', 'restart'], $command->targetPath()); $restart->setTimeout(60); $restart->run(); - $up = new Process(['docker', 'compose', 'up', '-d', '--wait', '--build']); + $up = new Process(['docker', 'compose', 'up', '-d', '--wait', '--build'], $command->targetPath()); $up->setTimeout(30 * 60); // 30 minutes — first run pulls images + builds layers $up->run(fn ($_type, $buffer) => $command->line(trim($buffer))); $command->newLine(); @@ -177,7 +201,7 @@ protected function startDocker(InstallCommand $command): bool protected function runComposerInContainer(InstallCommand $command): bool { $command->info('Installing PHP dependencies...'); - $process = new Process(['docker', 'compose', 'exec', '-T', 'app', 'composer', 'install']); + $process = new Process(['docker', 'compose', 'exec', '-T', 'app', 'composer', 'install'], $command->targetPath()); $process->setTimeout(300); $process->run(fn ($_type, $buffer) => $command->line(trim($buffer))); @@ -190,7 +214,7 @@ protected function runComposerInContainer(InstallCommand $command): bool protected function execInContainer(InstallCommand $command, array $args, int $timeout = 120): bool { - $process = new Process(array_merge(['docker', 'compose', 'exec', '-T', 'app'], $args)); + $process = new Process(array_merge(['docker', 'compose', 'exec', '-T', 'app'], $args), $command->targetPath()); $process->setTimeout($timeout); $process->run(fn ($_type, $buffer) => $command->line(trim($buffer))); @@ -200,8 +224,8 @@ protected function execInContainer(InstallCommand $command, array $args, int $ti protected function generateAppKey(InstallCommand $command): bool { $command->info('Generating application key...'); - $env = @file_get_contents(base_path('.env')); - if ($env !== false && preg_match('/^APP_KEY=base64:.+$/m', $env)) { + + if ($command->envHasAppKey()) { return true; } @@ -227,13 +251,10 @@ protected function runStack(InstallCommand $command): void } $command->info("Setting up {$stack} stack..."); - $args = ['php', 'artisan', 'saucebase:stack', $stack]; - - if ($command->option('dev')) { - $args[] = '--dev'; - } - $this->execInContainer($command, $args); + // File operations on the host — the app directory is volume-mounted, + // and the stack command no longer exists inside the container. + $command->runStack(); } protected function installModules(InstallCommand $command): bool @@ -277,7 +298,7 @@ protected function installModules(InstallCommand $command): bool protected function nextSteps(InstallCommand $command): array { - $appUrl = $this->readEnvValue('APP_URL') ?? ($this->ssl ? 'https://localhost' : 'http://localhost'); + $appUrl = $this->readEnvValue($command, 'APP_URL') ?? ($this->ssl ? 'https://localhost' : 'http://localhost'); return [ 'Compile frontend assets: npm install && npm run dev', @@ -286,19 +307,6 @@ protected function nextSteps(InstallCommand $command): array ]; } - private function readEnvValue(string $key): ?string - { - $env = @file_get_contents(base_path('.env')); - if ($env === false) { - return null; - } - if (preg_match('/^'.preg_quote($key, '/').'=(.+)$/m', $env, $m)) { - return trim($m[1], "\"'"); - } - - return null; - } - protected function createStorageLink(InstallCommand $command): void { $command->info('Creating storage link...'); @@ -313,7 +321,7 @@ protected function clearCaches(InstallCommand $command): void protected function setDockerEnvDefaults(InstallCommand $command): void { - $path = base_path('.env'); + $path = $command->path('.env'); $original = file_get_contents($path); if ($original === false) { diff --git a/src/Environments/Environment.php b/src/Environments/Environment.php index 0eb2ab8..1e9e627 100644 --- a/src/Environments/Environment.php +++ b/src/Environments/Environment.php @@ -6,6 +6,15 @@ abstract class Environment { + public static function make(string $name): self + { + return match ($name) { + 'docker' => new DockerEnvironment, + 'native' => new NativeEnvironment, + default => throw new \InvalidArgumentException("Unknown driver: {$name}"), + }; + } + abstract public function name(): string; abstract public function label(): string; @@ -50,6 +59,10 @@ protected function resolveModules(InstallCommand $command): array } if ($raw = $command->option('modules')) { + if ($raw === 'none') { + return []; + } + return array_values(array_filter(array_map(function (string $name): string { $name = strtolower(trim($name)); @@ -63,6 +76,24 @@ protected function resolveModules(InstallCommand $command): array /** @return string[] */ abstract protected function nextSteps(InstallCommand $command): array; + protected function readEnvValue(InstallCommand $command, string $key): ?string + { + $env = @file_get_contents($command->path('.env')); + if ($env === false) { + return null; + } + if (preg_match('/^'.preg_quote($key, '/').'=(.+)$/m', $env, $m)) { + return trim($m[1], "\"'"); + } + + return null; + } + + public function hasCommand(string $name): bool + { + return $this->commandExists($name); + } + 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 24837ce..42e69cd 100644 --- a/src/Environments/NativeEnvironment.php +++ b/src/Environments/NativeEnvironment.php @@ -36,7 +36,7 @@ protected function nextSteps(InstallCommand $command): array { return [ 'Start the dev server: composer dev', - 'Open your app: '.config('app.url').'', + 'Open your app: '.($this->readEnvValue($command, 'APP_URL') ?? 'http://localhost').'', ]; } } diff --git a/src/InstallerServiceProvider.php b/src/InstallerServiceProvider.php deleted file mode 100644 index 138a04b..0000000 --- a/src/InstallerServiceProvider.php +++ /dev/null @@ -1,28 +0,0 @@ -app->runningInConsole()) { - $this->commands([ - InstallCommand::class, - StackCommand::class, - ]); - - $this->publishes([ - __DIR__.'/../stubs/docker/docker-compose.yml' => base_path('docker-compose.yml'), - __DIR__.'/../stubs/docker/docker/Dockerfile' => base_path('docker/Dockerfile'), - __DIR__.'/../stubs/docker/docker/nginx.conf' => base_path('docker/nginx.conf'), - __DIR__.'/../stubs/docker/docker/php.ini' => base_path('docker/php.ini'), - __DIR__.'/../stubs/docker/docker/xdebug.ini' => base_path('docker/xdebug.ini'), - ], 'saucebase-docker'); - } - } -} diff --git a/src/ModuleRegistry.php b/src/ModuleRegistry.php new file mode 100644 index 0000000..b23fd0c --- /dev/null +++ b/src/ModuleRegistry.php @@ -0,0 +1,111 @@ + */ + protected array $frameworks = []; + + public function __construct(protected ?string $modulesPath = null) {} + + /** @return string[] Fully-qualified package names, abandoned ones excluded. */ + public function available(): array + { + if (! empty($this->available)) { + return $this->available; + } + + $response = Http::timeout(10) + ->get('https://packagist.org/packages/list.json?type=saucebase-module&fields[]=abandoned'); + + if (! $response->ok()) { + return []; + } + + $packages = $response->json('packages', []); + + return $this->available = array_keys(array_filter( + $packages, + fn (array $p) => empty($p['abandoned']) + )); + } + + /** @return string[] Frameworks the module supports; defaults to ['vue']. */ + public function frameworks(string $package): array + { + if (isset($this->frameworks[$package])) { + return $this->frameworks[$package]; + } + + $name = Str::after($package, '/'); + + if ($this->modulesPath !== null) { + $localManifest = $this->modulesPath."/{$name}/composer.json"; + + if (file_exists($localManifest)) { + $local = json_decode((string) file_get_contents($localManifest), true); + $frameworks = data_get($local, 'extra.saucebase.frameworks'); + + if (is_array($frameworks) && ! empty($frameworks)) { + return $this->frameworks[$package] = $frameworks; + } + } + } + + $response = Http::timeout(5)->get("https://raw.githubusercontent.com/saucebase-dev/{$name}/main/composer.json"); + + if ($response->ok()) { + $frameworks = data_get($response->json(), 'extra.saucebase.frameworks'); + + if (is_array($frameworks) && ! empty($frameworks)) { + return $this->frameworks[$package] = $frameworks; + } + } + + return $this->frameworks[$package] = ['vue']; + } + + /** + * @param string[] $packages + * @return string[] + */ + public function filterByFramework(array $packages, string $framework): array + { + return array_values(array_filter( + $packages, + fn (string $pkg) => in_array($framework, $this->frameworks($pkg), true) + )); + } + + /** + * @param string[] $available + * @return string[] + */ + public function promptSelection(array $available): array + { + $options = collect($available) + ->mapWithKeys(fn (string $package) => [ + $package => Str::studly(Str::after($package, '/')), + ]) + ->all(); + + return multiselect( + label: 'Which modules would you like to install?', + options: $options, + default: [], + ); + } +} diff --git a/tests/CommandResult.php b/tests/CommandResult.php new file mode 100644 index 0000000..e63cb3b --- /dev/null +++ b/tests/CommandResult.php @@ -0,0 +1,41 @@ +exitCode, "Command failed with exit code {$this->exitCode}.\n{$this->output}"); + + return $this; + } + + public function assertFailed(): static + { + Assert::assertNotSame(0, $this->exitCode, "Command unexpectedly succeeded.\n{$this->output}"); + + return $this; + } + + public function expectsOutputToContain(string $needle): static + { + Assert::assertStringContainsString($needle, $this->output); + + return $this; + } + + public function doesntExpectOutputToContain(string $needle): static + { + Assert::assertStringNotContainsString($needle, $this->output); + + return $this; + } +} diff --git a/tests/Feature/Environments/DockerEnvironmentTest.php b/tests/Feature/Environments/DockerEnvironmentTest.php index c94e686..8b73ae8 100644 --- a/tests/Feature/Environments/DockerEnvironmentTest.php +++ b/tests/Feature/Environments/DockerEnvironmentTest.php @@ -309,7 +309,9 @@ protected function runInstallInContainer(InstallCommand $command): void public function test_generate_app_key_skips_when_key_already_set(): void { $spy = (object) ['execCalled' => false]; - $envPath = base_path('.env'); + $appDir = sys_get_temp_dir().'/sb-docker-test-'.uniqid(); + mkdir($appDir, 0755, true); + $envPath = $appDir.'/.env'; file_put_contents($envPath, "APP_KEY=base64:abc123==\n"); try { @@ -330,19 +332,22 @@ public function exposedGenerateAppKey(InstallCommand $command): bool } }; - $result = $env->exposedGenerateAppKey(new FakeInstallCommand(null, [], [])); + $result = $env->exposedGenerateAppKey(new FakeInstallCommand(null, [], ['path' => $appDir])); $this->assertTrue($result); $this->assertFalse($spy->execCalled, 'key:generate must not run when APP_KEY is already set'); } finally { @unlink($envPath); + @rmdir($appDir); } } public function test_generate_app_key_runs_when_key_missing(): void { $spy = (object) ['execCalled' => false]; - $envPath = base_path('.env'); + $appDir = sys_get_temp_dir().'/sb-docker-test-'.uniqid(); + mkdir($appDir, 0755, true); + $envPath = $appDir.'/.env'; file_put_contents($envPath, "APP_NAME=Test\n"); try { @@ -363,11 +368,12 @@ public function exposedGenerateAppKey(InstallCommand $command): bool } }; - $env->exposedGenerateAppKey(new FakeInstallCommand(null, [], [])); + $env->exposedGenerateAppKey(new FakeInstallCommand(null, [], ['path' => $appDir])); $this->assertTrue($spy->execCalled, 'key:generate must run when APP_KEY is not set'); } finally { @unlink($envPath); + @rmdir($appDir); } } diff --git a/tests/Feature/Environments/NativeEnvironmentTest.php b/tests/Feature/Environments/NativeEnvironmentTest.php index c3ede33..d44f7f5 100644 --- a/tests/Feature/Environments/NativeEnvironmentTest.php +++ b/tests/Feature/Environments/NativeEnvironmentTest.php @@ -65,8 +65,7 @@ public function test_run_delegates_to_install_and_returns_success(): void { $spy = (object) ['installCalled' => false]; - app()->bind(InstallCommand::class, function () use ($spy) { - return new class($spy) extends InstallCommand + $command = new class($spy) extends InstallCommand { public function __construct(public object $spy) {} @@ -81,10 +80,8 @@ public function install(): int return Command::SUCCESS; } }; - }); $env = new NativeEnvironment; - $command = app(InstallCommand::class); $result = $env->run($command); $this->assertTrue($spy->installCalled); @@ -93,8 +90,7 @@ public function install(): int public function test_run_passes_through_failure_from_install(): void { - app()->bind(InstallCommand::class, function () { - return new class extends InstallCommand + $command = new class extends InstallCommand { public function promptForModules(): void {} @@ -105,10 +101,8 @@ public function install(): int return Command::FAILURE; } }; - }); $env = new NativeEnvironment; - $command = app(InstallCommand::class); $this->assertSame(Command::FAILURE, $env->run($command)); } diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 3426e6f..be82522 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -150,7 +150,7 @@ public function test_stack_command_is_not_called_during_stack_capture(): void $this->fakePackagistList(); $spy = (object) ['stackCallCount' => 0]; - app()->bind(InstallCommand::class, function () use ($spy) { + $this->bindCommand((function () use ($spy) { $cmd = new class extends InstallCommand { public object $spy; @@ -164,7 +164,7 @@ public function handle(): int public function call($command, array $arguments = [], $outputBuffer = null): int { - if ($command === 'saucebase:stack') { + if ($command === 'stack') { $this->spy->stackCallCount++; } @@ -174,9 +174,9 @@ public function call($command, array $arguments = [], $outputBuffer = null): int $cmd->spy = $spy; return $cmd; - }); + })()); - $this->artisan('saucebase:install vue')->assertSuccessful(); + $this->artisan('install vue')->assertSuccessful(); $this->assertSame(0, $spy->stackCallCount, 'saucebase:stack must not fire during captureStack()'); } @@ -186,7 +186,7 @@ public function test_stack_command_is_called_exactly_once_during_install(): void $this->fakePackagistList(); $spy = (object) ['stackCallCount' => 0]; - app()->bind(InstallCommand::class, function () use ($spy) { + $this->bindCommand((function () use ($spy) { $cmd = new class extends InstallCommand { public object $spy; @@ -223,7 +223,7 @@ protected function resolveDriver(): Environment public function call($command, array $arguments = [], $outputBuffer = null): int { - if ($command === 'saucebase:stack') { + if ($command === 'stack') { $this->spy->stackCallCount++; } @@ -233,9 +233,9 @@ public function call($command, array $arguments = [], $outputBuffer = null): int $cmd->spy = $spy; return $cmd; - }); + })()); - $this->artisan('saucebase:install vue --all-modules')->assertSuccessful(); + $this->artisan('install vue --all-modules')->assertSuccessful(); $this->assertSame(1, $spy->stackCallCount, 'saucebase:stack must be called exactly once during install()'); } @@ -248,7 +248,7 @@ public function test_driver_native_skips_the_select_prompt(): void { $spy = (object) ['selectCalled' => false]; - app()->bind(InstallCommand::class, function () use ($spy) { + $this->bindCommand((function () use ($spy) { $cmd = new class extends InstallCommand { public object $spy; @@ -293,9 +293,9 @@ public function displaySuccess(array $steps = []): void {} $cmd->spy = $spy; return $cmd; - }); + })()); - $this->artisan('saucebase:install vue --driver=native --all-modules')->assertSuccessful(); + $this->artisan('install vue --driver=native --all-modules')->assertSuccessful(); $this->assertFalse($spy->selectCalled, '--driver=native must not reach the select() prompt'); } @@ -303,7 +303,7 @@ public function displaySuccess(array $steps = []): void {} public function test_driver_native_runs_install_without_prompting(): void { - app()->bind(InstallCommand::class, function () { + $this->bindCommand((function () { return new class extends InstallCommand { protected function isCI(): bool @@ -331,9 +331,9 @@ protected function clearCaches(): void {} public function displaySuccess(array $steps = []): void {} }; - }); + })()); - $this->artisan('saucebase:install vue --driver=native --all-modules')->assertSuccessful(); + $this->artisan('install vue --driver=native --all-modules')->assertSuccessful(); } // ------------------------------------------------------------------------- @@ -344,7 +344,7 @@ public function test_handle_returns_failure_and_skips_run_when_prerequisites_not { $spy = (object) ['runCalled' => false]; - app()->bind(InstallCommand::class, function () use ($spy) { + $this->bindCommand((function () use ($spy) { return new class($spy) extends InstallCommand { public function __construct(private object $spy) @@ -399,9 +399,9 @@ protected function nextSteps(InstallCommand $command): array }; } }; - }); + })()); - $this->artisan('saucebase:install vue --all-modules')->assertFailed(); + $this->artisan('install vue --all-modules')->assertFailed(); $this->assertFalse($spy->runCalled, 'run() must not be called when prerequisites are not met'); } @@ -411,7 +411,7 @@ protected function nextSteps(InstallCommand $command): array public function test_ci_installation_returns_failure_when_env_file_missing(): void { - app()->bind(InstallCommand::class, function () { + $this->bindCommand((function () { return new class extends InstallCommand { protected function isCI(): bool @@ -424,9 +424,9 @@ public function ensureEnvFile(): bool return false; } }; - }); + })()); - $this->artisan('saucebase:install vue')->assertFailed(); + $this->artisan('install vue')->assertFailed(); } // ------------------------------------------------------------------------- @@ -437,7 +437,7 @@ public function test_install_returns_failure_and_skips_modules_when_database_set { $spy = (object) ['modulesSetupCalled' => false]; - app()->bind(InstallCommand::class, function () use ($spy) { + $this->bindCommand((function () use ($spy) { $cmd = new class extends InstallCommand { public object $spy; @@ -483,9 +483,9 @@ public function call($command, array $arguments = [], $outputBuffer = null): int $cmd->spy = $spy; return $cmd; - }); + })()); - $this->artisan('saucebase:install vue --driver=native --all-modules')->assertFailed(); + $this->artisan('install vue --driver=native --all-modules')->assertFailed(); $this->assertFalse($spy->modulesSetupCalled, 'setupModules() must not run after a failed migration'); } @@ -581,22 +581,27 @@ public function test_resolve_matches_multiple_full_package_names(): void public function test_module_has_seeder_checks_vendor_path(): void { $name = 'sb-test-seeder-'.uniqid(); - $vendorSeederDir = base_path("vendor/saucebase/{$name}/database/seeders"); + $appDir = sys_get_temp_dir().'/sb-install-test-'.uniqid(); + $vendorSeederDir = $appDir."/vendor/saucebase/{$name}/database/seeders"; @mkdir($vendorSeederDir, 0755, true); file_put_contents($vendorSeederDir.'/DatabaseSeeder.php', 'fakeOptions = ['path' => $appDir]; $this->assertFalse( - file_exists(base_path("modules/{$name}/database/seeders/DatabaseSeeder.php")), + file_exists($appDir."/modules/{$name}/database/seeders/DatabaseSeeder.php"), 'modules/ path must not exist for this test to be valid' ); $this->assertTrue($cmd->moduleHasSeeder($name), 'moduleHasSeeder() must detect seeder in vendor/saucebase/'); } finally { @unlink($vendorSeederDir.'/DatabaseSeeder.php'); @rmdir($vendorSeederDir); - @rmdir(base_path("vendor/saucebase/{$name}/database")); - @rmdir(base_path("vendor/saucebase/{$name}")); + @rmdir($appDir."/vendor/saucebase/{$name}/database"); + @rmdir($appDir."/vendor/saucebase/{$name}"); + @rmdir($appDir.'/vendor/saucebase'); + @rmdir($appDir.'/vendor'); + @rmdir($appDir); } } @@ -665,7 +670,8 @@ public function fetchAvailableModules(): array public function test_rewrite_cross_module_imports_strips_all_framework_segments(): void { - $jsRoot = base_path('modules/sb-test-rewrite/resources/js'); + $appDir = sys_get_temp_dir().'/sb-install-test-'.uniqid(); + $jsRoot = $appDir.'/modules/sb-test-rewrite/resources/js'; @mkdir($jsRoot, 0755, true); file_put_contents($jsRoot.'/app.ts', implode("\n", [ "import Foo from '@modules/other/resources/js/vue/components/Foo.vue';", @@ -674,6 +680,7 @@ public function test_rewrite_cross_module_imports_strips_all_framework_segments( try { $cmd = new TestableInstallCommand; + $cmd->fakeOptions = ['path' => $appDir]; $cmd->rewriteCrossModuleImports(); $result = file_get_contents($jsRoot.'/app.ts'); @@ -684,8 +691,10 @@ public function test_rewrite_cross_module_imports_strips_all_framework_segments( } finally { @unlink($jsRoot.'/app.ts'); @rmdir($jsRoot); - @rmdir(base_path('modules/sb-test-rewrite/resources')); - @rmdir(base_path('modules/sb-test-rewrite')); + @rmdir($appDir.'/modules/sb-test-rewrite/resources'); + @rmdir($appDir.'/modules/sb-test-rewrite'); + @rmdir($appDir.'/modules'); + @rmdir($appDir); } } diff --git a/tests/Feature/StackCommandTest.php b/tests/Feature/StackCommandTest.php index 3fea826..286446d 100644 --- a/tests/Feature/StackCommandTest.php +++ b/tests/Feature/StackCommandTest.php @@ -38,7 +38,7 @@ protected function setUp(): void exec("git -C {$this->tmpDir} commit -q -m 'initial' 2>/dev/null"); $tmpDir = $this->tmpDir; - app()->bind(StackCommand::class, fn () => new class(new Filesystem, $tmpDir, $tmpDir.'/resources/js') extends StackCommand + $this->bindCommand(new class(new Filesystem, $tmpDir, $tmpDir.'/resources/js') extends StackCommand { protected function runNpmInstall(): void {} }); @@ -56,7 +56,7 @@ protected function tearDown(): void public function test_rejects_invalid_framework(): void { - $this->artisan('saucebase:stack svelte') + $this->artisan('stack svelte') ->assertFailed() ->expectsOutputToContain('Invalid framework'); } @@ -69,7 +69,7 @@ public function test_dev_mode_writes_frontend_json(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $data = json_decode(file_get_contents($this->tmpDir.'/frontend.json'), true); $this->assertSame('vue', $data['framework']); @@ -80,7 +80,7 @@ public function test_dev_mode_writes_app_and_ssr_shims(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertStringContainsString("import './vue/app'", file_get_contents($this->tmpDir.'/resources/js/app.ts')); $this->assertStringContainsString("import './vue/ssr'", file_get_contents($this->tmpDir.'/resources/js/ssr.ts')); @@ -90,7 +90,7 @@ public function test_dev_mode_react_writes_tsx_entry_points(): void { $this->seedFakeStubs('react'); - $this->artisan('saucebase:stack react --dev')->assertSuccessful(); + $this->artisan('stack react --dev')->assertSuccessful(); $this->assertStringContainsString("import './react/app'", file_get_contents($this->tmpDir.'/resources/js/app.tsx')); $this->assertStringContainsString("import './react/ssr'", file_get_contents($this->tmpDir.'/resources/js/ssr.tsx')); @@ -102,8 +102,7 @@ public function test_dev_mode_runs_npm_install(): void $spy = (object) ['called' => false]; $tmpDir = $this->tmpDir; - app()->bind(StackCommand::class, function () use ($tmpDir, $spy) { - return new class(new Filesystem, $tmpDir, $tmpDir.'/resources/js', $spy) extends StackCommand + $this->bindCommand(new class(new Filesystem, $tmpDir, $tmpDir.'/resources/js', $spy) extends StackCommand { private object $spy; @@ -117,10 +116,9 @@ protected function runNpmInstall(): void { $this->spy->called = true; } - }; - }); + }); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertTrue($spy->called); } @@ -129,7 +127,7 @@ public function test_dev_mode_does_not_copy_source_files(): void $this->seedFakeStubs('vue'); $this->seedFakeSourceDir('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertFileDoesNotExist($this->tmpDir.'/resources/js/pages/Index.vue'); } @@ -138,7 +136,7 @@ public function test_dev_mode_copies_config_files_to_root(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertFileExists($this->tmpDir.'/vite.config.js'); $this->assertFileExists($this->tmpDir.'/tsconfig.json'); @@ -151,7 +149,7 @@ public function test_dev_mode_copies_package_lock_to_root(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertFileExists($this->tmpDir.'/package-lock.json'); } @@ -161,7 +159,7 @@ public function test_dev_mode_copies_view_files_from_stubs(): void $this->seedFakeStubs('vue'); $this->seedFakeViewStub('vue', 'app.ts'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $blade = file_get_contents($this->tmpDir.'/resources/views/app.blade.php'); $this->assertStringContainsString('app.ts', $blade); @@ -173,7 +171,7 @@ public function test_dev_mode_does_not_remove_source_dirs(): void $this->seedFakeSourceDir('vue'); $this->seedFakeSourceDir('react'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertDirectoryExists($this->tmpDir.'/resources/js/vue'); $this->assertDirectoryExists($this->tmpDir.'/resources/js/react'); @@ -183,7 +181,7 @@ public function test_dev_mode_does_not_rewrite_paths_in_config_files(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $viteConfig = file_get_contents($this->tmpDir.'/vite.config.js'); $this->assertStringContainsString('resources/js/vue/', $viteConfig); @@ -194,7 +192,7 @@ public function test_dev_mode_writes_module_entry_point(): void $this->seedFakeStubs('vue'); $this->seedFakeModule('testmodule', 'vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $entryPoint = file_get_contents($this->tmpDir.'/modules/testmodule/resources/js/app.ts'); $this->assertStringContainsString("from './vue/app'", $entryPoint); @@ -206,7 +204,7 @@ public function test_dev_mode_skips_module_without_framework_dir(): void // module exists but only has react/, not vue/ $this->seedFakeModule('reactonly', 'react'); - $this->artisan('saucebase:stack vue --dev') + $this->artisan('stack vue --dev') ->assertSuccessful(); // app.ts should NOT be written for a module missing the target framework dir @@ -221,9 +219,9 @@ public function test_dev_mode_guards_against_running_twice(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); - $this->artisan('saucebase:stack react --dev') + $this->artisan('stack react --dev') ->assertFailed() ->expectsOutputToContain('already set to "vue"'); } @@ -232,9 +230,9 @@ public function test_dev_mode_same_framework_twice_also_fails(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); - $this->artisan('saucebase:stack vue --dev') + $this->artisan('stack vue --dev') ->assertFailed() ->expectsOutputToContain('already set to "vue"'); } @@ -248,13 +246,55 @@ public function test_no_skip_worktree_option_is_accepted(): void $this->seedFakeStubs('vue'); // should succeed without errors even with the flag - $this->artisan('saucebase:stack vue --dev --no-skip-worktree') + $this->artisan('stack vue --dev --no-skip-worktree') ->assertSuccessful(); $data = json_decode(file_get_contents($this->tmpDir.'/frontend.json'), true); $this->assertSame('vue', $data['framework']); } + // ------------------------------------------------------------------------- + // --no-install + // ------------------------------------------------------------------------- + + public function test_dev_mode_runs_npm_install_by_default(): void + { + $this->seedFakeStubs('vue'); + $spy = $this->bindNpmSpy(); + + $this->artisan('stack vue --dev')->assertSuccessful(); + + $this->assertSame(1, $spy->npmCalls); + } + + public function test_no_install_option_skips_npm_install(): void + { + $this->seedFakeStubs('vue'); + $spy = $this->bindNpmSpy(); + + $this->artisan('stack vue --dev --no-install')->assertSuccessful(); + + $this->assertSame(0, $spy->npmCalls); + } + + /** Bind a StackCommand that counts runNpmInstall() calls instead of shelling out. */ + private function bindNpmSpy(): StackCommand + { + $tmpDir = $this->tmpDir; + $spy = new class(new Filesystem, $tmpDir, $tmpDir.'/resources/js') extends StackCommand + { + public int $npmCalls = 0; + + protected function runNpmInstall(): void + { + $this->npmCalls++; + } + }; + $this->bindCommand($spy); + + return $spy; + } + // ------------------------------------------------------------------------- // Warnings // ------------------------------------------------------------------------- @@ -264,7 +304,7 @@ public function test_dev_mode_warns_for_untracked_entry_points(): void $this->seedFakeStubs('react'); // app.tsx and ssr.tsx are created by the command but not in git — warn for each - $this->artisan('saucebase:stack react --dev') + $this->artisan('stack react --dev') ->assertSuccessful() ->expectsOutputToContain('Could not skip-worktree resources/js/app.tsx') ->expectsOutputToContain('Could not skip-worktree resources/js/ssr.tsx'); @@ -276,7 +316,7 @@ public function test_dev_mode_warns_for_untracked_module_entry_points(): void $this->seedFakeModule('testmodule', 'vue'); // module entry point not committed — should warn - $this->artisan('saucebase:stack vue --dev') + $this->artisan('stack vue --dev') ->assertSuccessful() ->expectsOutputToContain('Could not skip-worktree modules/testmodule/resources/js/app.ts'); } @@ -286,7 +326,7 @@ public function test_dev_mode_no_warnings_for_tracked_config_files(): void $this->seedFakeStubs('vue'); // config files are committed in setUp — should not warn - $this->artisan('saucebase:stack vue --dev') + $this->artisan('stack vue --dev') ->assertSuccessful() ->doesntExpectOutputToContain('Could not skip-worktree package.json') ->doesntExpectOutputToContain('Could not skip-worktree vite.config.js') @@ -296,12 +336,12 @@ public function test_dev_mode_no_warnings_for_tracked_config_files(): void public function test_reset_warns_when_file_cannot_be_restored(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); // Manually delete the entry point before reset so git checkout fails AND file is gone unlink($this->tmpDir.'/resources/js/app.ts'); - $this->artisan('saucebase:stack --reset') + $this->artisan('stack --reset') ->assertSuccessful() ->expectsOutputToContain('Could not restore resources/js/app.ts'); } @@ -310,7 +350,7 @@ public function test_no_skip_worktree_suppresses_all_warnings(): void { $this->seedFakeStubs('react'); - $this->artisan('saucebase:stack react --dev --no-skip-worktree') + $this->artisan('stack react --dev --no-skip-worktree') ->assertSuccessful() ->doesntExpectOutputToContain('Could not skip-worktree'); } @@ -323,14 +363,14 @@ public function test_install_mode_guards_when_framework_already_set(): void { file_put_contents($this->tmpDir.'/frontend.json', json_encode(['framework' => 'vue'])); - $this->artisan('saucebase:stack react') + $this->artisan('stack react') ->assertFailed() ->expectsOutputToContain('already set to "vue"'); } public function test_install_mode_fails_when_source_dir_missing(): void { - $this->artisan('saucebase:stack vue') + $this->artisan('stack vue') ->assertFailed() ->expectsOutputToContain('Source directory not found'); } @@ -340,7 +380,7 @@ public function test_install_mode_copies_source_files_flat(): void $this->seedFakeStubs('vue'); $this->seedFakeSourceDir('vue'); - $this->artisan('saucebase:stack vue')->assertSuccessful(); + $this->artisan('stack vue')->assertSuccessful(); $this->assertFileExists($this->tmpDir.'/resources/js/pages/Index.vue'); } @@ -350,7 +390,7 @@ public function test_install_mode_rewrites_paths_in_config_files(): void $this->seedFakeStubs('vue'); $this->seedFakeSourceDir('vue'); - $this->artisan('saucebase:stack vue')->assertSuccessful(); + $this->artisan('stack vue')->assertSuccessful(); $viteConfig = file_get_contents($this->tmpDir.'/vite.config.js'); $this->assertStringNotContainsString('resources/js/vue/', $viteConfig); @@ -364,7 +404,7 @@ public function test_install_mode_removes_both_framework_subdirs(): void $this->seedFakeSourceDir('vue'); $this->seedFakeSourceDir('react'); - $this->artisan('saucebase:stack vue')->assertSuccessful(); + $this->artisan('stack vue')->assertSuccessful(); $this->assertDirectoryDoesNotExist($this->tmpDir.'/resources/js/vue'); $this->assertDirectoryDoesNotExist($this->tmpDir.'/resources/js/react'); @@ -375,7 +415,7 @@ public function test_install_mode_removes_stack_stubs_directory(): void $this->seedFakeStubs('vue'); $this->seedFakeSourceDir('vue'); - $this->artisan('saucebase:stack vue')->assertSuccessful(); + $this->artisan('stack vue')->assertSuccessful(); $this->assertDirectoryDoesNotExist($this->tmpDir.'/stubs/saucebase/stack'); } @@ -390,7 +430,7 @@ public function test_install_mode_removes_framework_subdirs_from_modules(): void $this->files->ensureDirectoryExists($reactDir); file_put_contents($reactDir.'/app.tsx', "export default {};\n"); - $this->artisan('saucebase:stack vue')->assertSuccessful(); + $this->artisan('stack vue')->assertSuccessful(); $this->assertDirectoryDoesNotExist($this->tmpDir.'/modules/testmodule/resources/js/vue'); $this->assertDirectoryDoesNotExist($this->tmpDir.'/modules/testmodule/resources/js/react'); @@ -401,7 +441,7 @@ public function test_install_mode_copies_package_lock_to_root(): void $this->seedFakeStubs('vue'); $this->seedFakeSourceDir('vue'); - $this->artisan('saucebase:stack vue')->assertSuccessful(); + $this->artisan('stack vue')->assertSuccessful(); $this->assertFileExists($this->tmpDir.'/package-lock.json'); } @@ -411,7 +451,7 @@ public function test_install_mode_writes_framework_to_frontend_json(): void $this->seedFakeStubs('vue'); $this->seedFakeSourceDir('vue'); - $this->artisan('saucebase:stack vue')->assertSuccessful(); + $this->artisan('stack vue')->assertSuccessful(); $data = json_decode(file_get_contents($this->tmpDir.'/frontend.json'), true); $this->assertSame('vue', $data['framework']); @@ -424,7 +464,7 @@ public function test_install_mode_react_removes_stale_recipe_proxy(): void $this->seedFakeSourceDir('react'); $this->seedFakeRecipeStubs(); - $this->artisan('saucebase:stack react')->assertSuccessful(); + $this->artisan('stack react')->assertSuccessful(); $this->assertFileDoesNotExist($this->tmpDir.'/stubs/saucebase/recipes/basic/resources/js/app.ts'); } @@ -435,7 +475,7 @@ public function test_install_mode_vue_keeps_recipe_proxy(): void $this->seedFakeSourceDir('vue'); $this->seedFakeRecipeStubs(); - $this->artisan('saucebase:stack vue')->assertSuccessful(); + $this->artisan('stack vue')->assertSuccessful(); $this->assertFileExists($this->tmpDir.'/stubs/saucebase/recipes/basic/resources/js/app.ts'); // Confirm flattenRecipeStubs() actually ran (it deletes subdirs). @@ -450,7 +490,7 @@ public function test_reset_does_nothing_when_no_framework_selected(): void { $this->files->delete($this->tmpDir.'/frontend.json'); - $this->artisan('saucebase:stack --reset') + $this->artisan('stack --reset') ->assertSuccessful() ->expectsOutputToContain('nothing to reset'); } @@ -458,12 +498,12 @@ public function test_reset_does_nothing_when_no_framework_selected(): void public function test_reset_restores_config_files_via_git_checkout(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); // config files were overwritten with stub content $this->assertStringContainsString('resources/js/vue/', file_get_contents($this->tmpDir.'/vite.config.js')); - $this->artisan('saucebase:stack --reset')->assertSuccessful(); + $this->artisan('stack --reset')->assertSuccessful(); // git checkout restored originals $this->assertEquals('// vite', file_get_contents($this->tmpDir.'/vite.config.js')); @@ -474,12 +514,12 @@ public function test_reset_restores_config_files_via_git_checkout(): void public function test_reset_deletes_generated_entry_points(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertFileExists($this->tmpDir.'/resources/js/app.ts'); $this->assertFileExists($this->tmpDir.'/resources/js/ssr.ts'); - $this->artisan('saucebase:stack --reset')->assertSuccessful(); + $this->artisan('stack --reset')->assertSuccessful(); $this->assertFileDoesNotExist($this->tmpDir.'/resources/js/app.ts'); $this->assertFileDoesNotExist($this->tmpDir.'/resources/js/ssr.ts'); @@ -490,10 +530,10 @@ public function test_reset_preserves_module_entry_points(): void $this->seedFakeStubs('vue'); $this->seedFakeModule('testmodule', 'vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertFileExists($this->tmpDir.'/modules/testmodule/resources/js/app.ts'); - $this->artisan('saucebase:stack --reset')->assertSuccessful(); + $this->artisan('stack --reset')->assertSuccessful(); // Module app.ts is tracked in the module's own repo — reset must not delete it. $this->assertFileExists($this->tmpDir.'/modules/testmodule/resources/js/app.ts'); @@ -502,10 +542,10 @@ public function test_reset_preserves_module_entry_points(): void public function test_reset_deletes_package_lock(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertFileExists($this->tmpDir.'/package-lock.json'); - $this->artisan('saucebase:stack --reset')->assertSuccessful(); + $this->artisan('stack --reset')->assertSuccessful(); $this->assertFileDoesNotExist($this->tmpDir.'/package-lock.json'); } @@ -513,11 +553,11 @@ public function test_reset_deletes_package_lock(): void public function test_reset_allows_selecting_framework_again(): void { $this->seedFakeStubs('vue'); - $this->artisan('saucebase:stack vue --dev')->assertSuccessful(); - $this->artisan('saucebase:stack --reset')->assertSuccessful(); + $this->artisan('stack vue --dev')->assertSuccessful(); + $this->artisan('stack --reset')->assertSuccessful(); $this->seedFakeStubs('react'); - $this->artisan('saucebase:stack react --dev')->assertSuccessful(); + $this->artisan('stack react --dev')->assertSuccessful(); $data = json_decode(file_get_contents($this->tmpDir.'/frontend.json'), true); $this->assertSame('react', $data['framework']); diff --git a/tests/TestCase.php b/tests/TestCase.php index a6f31de..665ff3e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,13 +2,47 @@ namespace Saucebase\Installer\Tests; -use Orchestra\Testbench\TestCase as BaseTestCase; -use Saucebase\Installer\InstallerServiceProvider; +use Illuminate\Console\Application as ConsoleApplication; +use Illuminate\Console\Command; +use Illuminate\Support\Facades\Facade; +use PHPUnit\Framework\TestCase as BaseTestCase; +use Saucebase\Installer\Console\Application; +use Symfony\Component\Console\Input\StringInput; +use Symfony\Component\Console\Output\BufferedOutput; abstract class TestCase extends BaseTestCase { - protected function getPackageProviders($app): array + protected ConsoleApplication $console; + + protected function setUp(): void + { + parent::setUp(); + + Facade::clearResolvedInstances(); + $this->console = Application::make(); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + + parent::tearDown(); + } + + /** Replace a registered command with a (usually stubbed) instance for subsequent artisan() calls. */ + protected function bindCommand(Command $command): void { - return [InstallerServiceProvider::class]; + $this->console->add($command); + } + + protected function artisan(string $cli): CommandResult + { + $input = new StringInput($cli); + $input->setInteractive(false); + $output = new BufferedOutput; + + $exitCode = $this->console->run($input, $output); + + return new CommandResult($exitCode, $output->fetch()); } } From f29e2b3f2db7482b92764292c6c02b6412f2c9f7 Mon Sep 17 00:00:00 2001 From: sauce-base <226096358+sauce-base@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:25:30 +0000 Subject: [PATCH 3/3] PHP Linting (Pint) --- .../Environments/NativeEnvironmentTest.php | 36 +++++++++---------- tests/Feature/StackCommandTest.php | 26 +++++++------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/tests/Feature/Environments/NativeEnvironmentTest.php b/tests/Feature/Environments/NativeEnvironmentTest.php index d44f7f5..65f5e98 100644 --- a/tests/Feature/Environments/NativeEnvironmentTest.php +++ b/tests/Feature/Environments/NativeEnvironmentTest.php @@ -66,20 +66,20 @@ public function test_run_delegates_to_install_and_returns_success(): void $spy = (object) ['installCalled' => false]; $command = new class($spy) extends InstallCommand - { - public function __construct(public object $spy) {} + { + public function __construct(public object $spy) {} - public function promptForModules(): void {} + public function promptForModules(): void {} - public function displaySuccess(array $steps = []): void {} + public function displaySuccess(array $steps = []): void {} - public function install(): int - { - $this->spy->installCalled = true; + public function install(): int + { + $this->spy->installCalled = true; - return Command::SUCCESS; - } - }; + return Command::SUCCESS; + } + }; $env = new NativeEnvironment; $result = $env->run($command); @@ -91,16 +91,16 @@ public function install(): int public function test_run_passes_through_failure_from_install(): void { $command = new class extends InstallCommand - { - public function promptForModules(): void {} + { + public function promptForModules(): void {} - public function displaySuccess(array $steps = []): void {} + public function displaySuccess(array $steps = []): void {} - public function install(): int - { - return Command::FAILURE; - } - }; + public function install(): int + { + return Command::FAILURE; + } + }; $env = new NativeEnvironment; diff --git a/tests/Feature/StackCommandTest.php b/tests/Feature/StackCommandTest.php index 286446d..638f753 100644 --- a/tests/Feature/StackCommandTest.php +++ b/tests/Feature/StackCommandTest.php @@ -103,20 +103,20 @@ public function test_dev_mode_runs_npm_install(): void $tmpDir = $this->tmpDir; $this->bindCommand(new class(new Filesystem, $tmpDir, $tmpDir.'/resources/js', $spy) extends StackCommand + { + private object $spy; + + public function __construct(Filesystem $files, string $basePath, string $jsRoot, object $spy) + { + parent::__construct($files, $basePath, $jsRoot); + $this->spy = $spy; + } + + protected function runNpmInstall(): void { - private object $spy; - - public function __construct(Filesystem $files, string $basePath, string $jsRoot, object $spy) - { - parent::__construct($files, $basePath, $jsRoot); - $this->spy = $spy; - } - - protected function runNpmInstall(): void - { - $this->spy->called = true; - } - }); + $this->spy->called = true; + } + }); $this->artisan('stack vue --dev')->assertSuccessful(); $this->assertTrue($spy->called);