diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9c2865c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# module-installer + +A Composer plugin that installs `saucebase-module` packages into `modules/` (lowercase, hyphenated). Lives at `packages/module-installer` in the Saucebase workspace. + +## Key Files + +| File | Purpose | +|---|---| +| `src/Plugin.php` | Plugin lifecycle: `activate`, `deactivate`, `uninstall`. Registers/unregisters the Installer with Composer's installation manager. | +| `src/Installer.php` | All installer logic (~780 lines). Extends `LibraryInstaller`. Overrides `install`, `update`, `uninstall`. | +| `tests/ModuleInstallerTest.php` | Full PHPUnit 12 suite. Uses `TestableInstaller` (a subclass that exposes protected methods via `call*` proxies and public override flags). | + +## Configuration Keys + +All read from the root `composer.json` `extra` section. Constants in `Installer.php`: + +| Key | Constant | Default | +|---|---|---| +| `module-dir` | `DEFAULT_ROOT` | `modules` | +| `module-type` | `DEFAULT_MODULE_TYPE` | `laravel-module` | +| `module-exclude-dirs` | `DEFAULT_EXCLUDED_DIRS` | `['.github', '.git']` | +| `module-update-strategy` | `DEFAULT_UPDATE_STRATEGY` | `merge` | +| `module-delete-on-remove` | *(no constant)* | `false` | + +The Saucebase app (`saucebase/composer.json`) uses `module-type: saucebase-module` and `module-dir: modules`. + +## Core Behaviours + +**Install** (`install()`): Downloads the package, removes excluded directories, and deploys frontend framework files if `frontend.json` is present. Skips entirely for path-repository packages. + +**Update** (`update()`): Two strategies: +- `merge` (default) — stashes the user's copy, downloads the new version, downloads the original version as a base, runs a 3-way `git merge-file`. Conflicts are staged in the git index (stages 1/2/3) so they appear in `git status`. +- `overwrite` — replaces the directory entirely. +Skips for path-repository packages. + +**Uninstall** (`uninstall()`): Two guards fire in order: +1. Path repository → skip (never delete a dev working copy). +2. `module-delete-on-remove` not set → skip by default (folder stays, package removed from Composer tracking). +Only calls `parent::uninstall()` (which deletes the folder) when `module-delete-on-remove: true` is explicitly set. + +**Frontend framework support**: Reads `frontend.json` from the project root. Copies files from the matching framework subdirectory (`vue/`, `react/`, or `svelte/`) flat into `resources/js/` and removes all framework subdirs. Silent-skips when `frontend.json` is absent or `"dev": true`. + +## Development Commands + +```bash +composer test # run PHPUnit 12 suite +./vendor/bin/pint # format with Laravel Pint +./vendor/bin/pint --test # CI-style check (no writes) +composer validate # verify composer.json +``` + +## Testing Patterns + +- Test class: `Tests\ModuleInstallerTest` (final, extends `Tests\TestCase`). +- `TestableInstaller` subclass exposes protected methods via `call*` proxies (e.g. `callShouldDeleteOnRemove()`) and override flags (e.g. `$forcePathRepository`). Use this pattern when testing new protected methods — avoid reflection. +- Mock Composer collaborators (`IOInterface`, `InstalledRepositoryInterface`, `PackageInterface`, `Composer`) with PHPUnit stubs/mocks. Avoid hitting the filesystem unless the test specifically verifies file operations. +- Group related tests with a comment banner: `// ---- section name ----`. + +## Integration with the Saucebase App + +The package is a `require-dev` dependency in `saucebase/composer.json` at `^2.6.0`. To test local changes against the app, add a path repository entry in `saucebase/composer.json`: + +```json +{ + "repositories": [ + { + "type": "path", + "url": "../../packages/module-installer" + } + ] +} +``` + +Then run `composer update saucebase/module-installer` in the `saucebase/` directory. diff --git a/README.md b/README.md index 3adad80..9f96af6 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,18 @@ [![Tests](https://github.com/saucebase-dev/module-installer/actions/workflows/php.yml/badge.svg)](https://github.com/saucebase-dev/module-installer/actions/workflows/php.yml) [![License](https://img.shields.io/badge/License-MIT-0A7EA4)](#license) -This Composer plugin installs Sauce Base modules into the correct directory. It ships with `saucebase-dev/saucebase`, so every module that your project requires is placed where Sauce Base can find and load it. The installer stays compatible with [nWidart/laravel-modules](https://github.com/nWidart/laravel-modules) and offers a Sauce Base-focused alternative to [joshbrw/laravel-module-installer](https://github.com/joshbrw/laravel-module-installer). +A Composer plugin that installs Saucebase modules into the correct directory. It ships with [`saucebase-dev/saucebase`](https://github.com/saucebase-dev/saucebase), so every module your project requires is placed where the app can find and load it. ## How It Works -- Registers a Composer installer for the module package type (defaults to `laravel-module`). -- Installs each module inside the Sauce Base modules directory (`Modules/` by default). -- Turns package names such as `saucebase/example-module` into StudlyCase directory names (`ExampleModule`). -- Lets the root package override the install path with the `extra.module-dir` option. +- Registers a Composer installer for a configurable module package type (defaults to `laravel-module`; the Saucebase app uses `saucebase-module`). +- Installs each module inside a configurable modules directory (`modules/` by default, lowercase). +- Derives the directory name from the package slug: `saucebase/example-module` → `modules/example-module`. +- On `composer update`, **merges** the new version into your existing module directory by default, preserving local edits. Conflicts are staged in the git index for review. +- Removes configurable directories (e.g. `.github`, `.git`) after each install or update. +- Deploys frontend framework files (Vue / React / Svelte) based on the project's `frontend.json` selection. +- Protects path-repository modules (symlinks or local `.git` clones) from being overwritten or deleted. +- Skips folder deletion by default when a module is removed via `composer remove` — the directory stays on disk for manual review. ## Requirements @@ -22,7 +26,7 @@ This Composer plugin installs Sauce Base modules into the correct directory. It ## Installation -`saucebase-dev/saucebase` already requires this package. When you install the core, Composer pulls in the plugin and activates it through the `Saucebase\\ModuleInstaller\\Plugin` class, so a typical Sauce Base project needs no extra configuration. +`saucebase-dev/saucebase` already requires this package. When you install the core, Composer pulls in the plugin and activates it through the `Saucebase\\ModuleInstaller\\Plugin` class — no extra configuration is needed for a typical Saucebase project. Need the installer for a different Composer project? Require it directly: @@ -30,9 +34,32 @@ Need the installer for a different Composer project? Require it directly: composer require saucebase/module-installer ``` +## Configuration Reference + +All options live in the `extra` section of the **root** `composer.json`. Every key is optional. + +| Key | Default | Description | +|---|---|---| +| `module-type` | `laravel-module` | Composer package type the installer handles. | +| `module-dir` | `modules` | Directory modules are installed into. | +| `module-exclude-dirs` | `[".github", ".git"]` | Directories removed from each module after install/update. | +| `module-update-strategy` | `merge` | `merge` preserves local edits; `overwrite` replaces entirely. | +| `module-delete-on-remove` | `false` | Set to `true` to delete the module folder on `composer remove`. | + +Example (the Saucebase app configuration): + +```json +{ + "extra": { + "module-type": "saucebase-module", + "module-dir": "modules" + } +} +``` + ## Configuring the Module Type -The installer registers the `laravel-module` package type by default. If your application needs a different type, declare it in the root package `extra` section: +The installer registers the `laravel-module` package type by default. Override it in the root package `extra` section: ```json { @@ -42,46 +69,58 @@ The installer registers the `laravel-module` package type by default. If your ap } ``` -Any modules you install must set their `composer.json` `type` to the same value. +Any module packages must set their `composer.json` `type` to the same value. ## Configuring the Install Location -By default, modules are installed under `Modules/` at the project root. You can change this by adding a `module-dir` key to your application `extra` section: +By default, modules are installed under `modules/` at the project root. Change this with the `module-dir` key: ```json { "extra": { - "module-dir": "MyModules" + "module-dir": "custom-modules" } } ``` -With the configuration above, a module published as `saucebase/example-module` installs to `MyModules/ExampleModule`. +With the configuration above, `saucebase/example-module` installs to `custom-modules/example-module`. + +## Configuring Excluded Directories + +After each install or update, the installer removes directories that should not land in production. The default list is `.github` and `.git`. Override it with `module-exclude-dirs`: + +```json +{ + "extra": { + "module-exclude-dirs": [".github", ".git", ".vscode", "tests"] + } +} +``` + +Set it to `[]` to skip all removal. The configured list replaces the default entirely. ## Configuring Update Behaviour -By default, when you run `composer update`, the installer **merges** the new package version into -your existing module directory rather than replacing it. Your customised files are preserved; any -new files shipped in the update are added on top. +By default, when you run `composer update`, the installer **merges** the new package version into your existing module directory rather than replacing it. | Strategy | What happens on `composer update` | |---|---| -| `merge` *(default)* | Existing module files are kept. New files from the package are added. Your edits always win. | -| `overwrite` | The module directory is replaced entirely with the new package contents. All local changes are lost. | +| `merge` *(default)* | New files from the package are added. Your edited files are kept as-is. Conflicts between your edits and upstream changes are staged in the git index (conflict markers in the file, both sides visible in `git diff`). | +| `overwrite` | The module directory is replaced entirely with the new package contents. All local edits are lost. | ### Keeping your customisations (default) -No configuration is needed. The `merge` strategy is active by default. Running `composer update` -will add any new files the package ships without touching files you have already edited. +No configuration needed. Running `composer update` adds new files without touching files you have already edited. -> **Note:** Because your local files always take precedence, bug fixes or updates to files you have -> customised will **not** be applied automatically. To pick up an upstream change to a specific -> file, replace it manually or delete it and re-run `composer update`. +> **Note:** Because your local files take precedence, bug fixes or updates to files you have customised will **not** be applied automatically. To pick up an upstream change to a specific file, delete it and re-run `composer update`. + +### Merge conflicts + +When both you and the upstream package changed the same file, the installer stages a 3-way conflict in the git index (stages 1/2/3). The working-tree file contains standard conflict markers and `git status` shows it as a conflict for normal resolution via `git mergetool` or your editor. ### Full overwrite (CI / reproducible builds) -If you want every `composer update` to produce an exact copy of the package — discarding any -local edits — set the strategy to `overwrite` in your root `composer.json`: +Set the strategy to `overwrite` to produce an exact copy of the package on every update: ```json { @@ -91,33 +130,85 @@ local edits — set the strategy to `overwrite` in your root `composer.json`: } ``` -This is recommended for CI pipelines and staging environments where reproducibility matters more -than preserving local changes. +This is recommended for CI pipelines and staging environments where reproducibility matters more than preserving local changes. ### Getting a completely fresh copy of a module With the `merge` strategy (default), delete the module directory and run `composer update`: ```bash -rm -rf Modules/ExampleModule +rm -rf modules/example-module composer update vendor/example-module ``` -The installer will perform a clean install into the now-empty path. +The installer performs a clean install into the now-empty path. + +## Skipping Module Deletion on Remove + +By default, running `composer remove` on a module **does not delete the module directory**. The package is removed from `composer.lock` and Composer's tracking, but the folder stays on disk for manual review. + +``` +- Skipping deletion of module directory (set module-delete-on-remove: true to enable): modules/example-module +``` + +To restore the old behaviour and delete the folder automatically: + +```json +{ + "extra": { + "module-delete-on-remove": true + } +} +``` + +> **Why skip by default?** A `composer remove` followed by `composer install` in CI will not re-download an untracked folder — but it also will not delete your work. The conservative default prevents accidental data loss; opt in to deletion explicitly. + +## Frontend Framework Support + +When a module ships JavaScript resources for multiple frameworks (Vue, React, or Svelte), the installer copies only the files for the active framework flat into `resources/js/` and removes the other framework subdirectories. + +The active framework is read from `frontend.json` in the project root: + +```json +{ + "framework": "vue" +} +``` + +Valid values are `vue`, `react`, and `svelte`. If `frontend.json` is absent, unreadable, or has `"dev": true`, no framework files are copied and all framework subdirectories are left in place. + +Module packages should ship framework files under subdirectories matching the framework name: + +``` +resources/ + js/ + vue/ + Component.vue + react/ + Component.tsx +``` + +After install with `"framework": "vue"` active, the result is: + +``` +resources/ + js/ + Component.vue ← copied flat from vue/ +``` ## Creating Sauce Base Modules To ship a module that works with this installer: -1. Set the package `type` in the module `composer.json` to whatever your application expects (defaults to `laravel-module`). -2. Follow the Sauce Base module folder conventions; your module code should live inside the directory created by the installer. -3. Ask consumers to install the module through Composer: +1. Set the package `type` in the module `composer.json` to whatever the application expects (e.g. `saucebase-module`). +2. Organise module code inside the directory the installer will create (e.g. `modules/your-module-name`). +3. Have consumers install the module through Composer: ```bash - composer require vendor/example-module + composer require vendor/your-module-name ``` - During installation, the plugin converts the package slug into the final directory name. For example, `vendor/example-module` becomes `Modules/ExampleModule` unless `module-dir` overrides it. + The plugin derives the install directory from the package slug. `vendor/your-module-name` installs to `modules/your-module-name` (or the configured `module-dir`). ## Local Development @@ -129,9 +220,9 @@ composer install Useful scripts: -- `composer test` – run the PHPUnit suite (`tests/`). -- `./vendor/bin/pint` – apply Laravel Pint formatting (add `--test` for CI-style checks). -- `composer validate` – verify Composer metadata. +- `composer test` — run the PHPUnit 12 suite (`tests/`). +- `./vendor/bin/pint` — apply Laravel Pint formatting (add `--test` for CI-style checks). +- `composer validate` — verify Composer metadata. ## License diff --git a/src/Installer.php b/src/Installer.php index 8181372..9dba716 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -738,9 +738,26 @@ function (\Throwable $e) use ($stashPath, $basePath, $initial) { ); } + /** + * Whether to physically delete the module directory on uninstall. + * Defaults to false (skip deletion) to prevent accidental data loss. + * Enable via extra['module-delete-on-remove'] = true in root composer.json. + */ + protected function shouldDeleteOnRemove(): bool + { + if (! $this->composer || ! $this->composer->getPackage()) { + return false; + } + + $extra = $this->composer->getPackage()->getExtra(); + + return (bool) ($extra['module-delete-on-remove'] ?? false); + } + /** * Override uninstall to protect path repository files from deletion. * A `composer remove` on a path repo must never wipe the working source directory. + * By default, all module directories are preserved — set module-delete-on-remove: true to enable deletion. * * {@inheritDoc} */ @@ -756,6 +773,17 @@ public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $ return \React\Promise\resolve(null); } + if (! $this->shouldDeleteOnRemove()) { + $installPath = $this->getInstallPath($package); + $this->io->write(" - Skipping deletion of module directory (set module-delete-on-remove: true to enable): {$installPath}"); + + if ($repo->hasPackage($package)) { + $repo->removePackage($package); + } + + return \React\Promise\resolve(null); + } + return parent::uninstall($repo, $package); } } diff --git a/tests/ModuleInstallerTest.php b/tests/ModuleInstallerTest.php index 3f349c9..a6675be 100644 --- a/tests/ModuleInstallerTest.php +++ b/tests/ModuleInstallerTest.php @@ -137,6 +137,11 @@ protected function isPathRepository(PackageInterface $package): bool return parent::isPathRepository($package); } + public function callShouldDeleteOnRemove(): bool + { + return parent::shouldDeleteOnRemove(); + } + public bool $copyFrameworkFilesInvoked = false; protected function copyFrameworkFiles(PackageInterface $package): void @@ -1561,6 +1566,74 @@ private function makeInstallerWithModuleDir(string $baseDir): TestableInstaller return new TestableInstaller($io, $composer); } + // ------------------------------------------------------------------------- + // uninstall() skip-deletion-by-default guard (module-delete-on-remove) + // ------------------------------------------------------------------------- + + public function test_uninstall_skips_folder_deletion_by_default_and_removes_from_repo(): void + { + $io = $this->createMock(IOInterface::class); + $io->expects($this->once()) + ->method('write') + ->with($this->stringContains('Skipping deletion of module directory')); + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getPrettyName')->willReturn('saucebase/test-module'); + + $repo = $this->createMock(InstalledRepositoryInterface::class); + $repo->method('hasPackage')->willReturn(true); + $repo->expects($this->once())->method('removePackage')->with($pkg); + + $installer = new TestableInstaller($io, null); + $installer->forcePathRepository = false; + $installer->uninstall($repo, $pkg); + } + + public function test_should_delete_on_remove_returns_false_when_flag_absent(): void + { + $composer = $this->createStub(Composer::class); + $root = new RootPackage('root/app', '1.0.0.0', '1.0.0'); + $composer->method('getPackage')->willReturn($root); + + $installer = new TestableInstaller(null, $composer); + $this->assertFalse($installer->callShouldDeleteOnRemove()); + } + + public function test_should_delete_on_remove_returns_true_when_flag_enabled(): void + { + $composer = $this->createStub(Composer::class); + $root = new RootPackage('root/app', '1.0.0.0', '1.0.0'); + $root->setExtra(['module-delete-on-remove' => true]); + $composer->method('getPackage')->willReturn($root); + + $installer = new TestableInstaller(null, $composer); + $this->assertTrue($installer->callShouldDeleteOnRemove()); + } + + public function test_uninstall_path_repository_guard_takes_precedence_over_delete_flag(): void + { + $io = $this->createMock(IOInterface::class); + $io->expects($this->once()) + ->method('write') + ->with($this->stringContains('Skipping uninstall for path repository')); + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getPrettyName')->willReturn('saucebase/test-module'); + + $repo = $this->createMock(InstalledRepositoryInterface::class); + $repo->method('hasPackage')->willReturn(true); + $repo->expects($this->once())->method('removePackage')->with($pkg); + + $composer = $this->createStub(Composer::class); + $root = new RootPackage('root/app', '1.0.0.0', '1.0.0'); + $root->setExtra(['module-delete-on-remove' => true]); + $composer->method('getPackage')->willReturn($root); + + $installer = new TestableInstaller($io, $composer); + $installer->forcePathRepository = true; + $installer->uninstall($repo, $pkg); + } + public function test_flatten_framework_files_removes_stale_root_file_from_other_framework(): void { $jsRoot = sys_get_temp_dir().'/flatten-stale-'.uniqid('', true);