Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# module-installer

A Composer plugin that installs `saucebase-module` packages into `modules/<name>` (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.
161 changes: 126 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -22,17 +26,40 @@ 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:

```bash
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
{
Expand All @@ -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
{
Expand All @@ -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

Expand All @@ -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

Expand Down
28 changes: 28 additions & 0 deletions src/Installer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand All @@ -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(" - <info>Skipping deletion of module directory (set module-delete-on-remove: true to enable):</info> {$installPath}");

if ($repo->hasPackage($package)) {
$repo->removePackage($package);
}

return \React\Promise\resolve(null);
}

return parent::uninstall($repo, $package);
}
}
Loading