fix: boot app + AI driver registration on invoiceshelf/modules 3.0.3 (via VCS) (#655)

* fix(deps): require invoiceshelf/modules ^3.0.2 (adds registerExchangeRateDriver)

DriverRegistryProvider::registerExchangeRateDrivers() calls
Registry::registerExchangeRateDriver(), which only exists in
invoiceshelf/modules >= 3.0.2. The constraint (^3.0) and the committed
lock (3.0.1) allowed/pinned versions without it, so a fresh
`composer install` (CI, Docker, new clones) boots into:

  Call to undefined method InvoiceShelf\Modules\Registry::registerExchangeRateDriver()

Pin to ^3.0.2 and update the lock so every install gets a version that
has the method. `php artisan package:discover` verified clean.

* fix(ai): register the AI driver via the generic Registry::registerDriver('ai', ...)

registerAiDriver() is a convenience wrapper that is NOT present in any
published invoiceshelf/modules release (only the generic registerDriver()
and registerExchangeRateDriver() ship), so DriverRegistryProvider crashed
app boot on a clean composer install:

  Call to undefined method InvoiceShelf\Modules\Registry::registerAiDriver()

Use registerDriver('ai', 'openrouter', ...) instead -- it stores under the
'ai' type exactly like the wrapper would, and AiConfigurationService /
AiDriverFactory read it back via allDrivers('ai') / driverMeta('ai', ...).

Verified by clean-reinstalling invoiceshelf/modules (no local patch) and
running `php artisan package:discover` -> boots clean.

* style: fix pre-existing Pint violations in backup services

BackupService.php and BackupConfigurationFactory.php (untouched by this
PR's boot fix) carried style violations from an earlier domain-reorg
refactor (6d1816bd). `pint --test` checks the whole tree and runs on any
PR that touches PHP, so these failed CI here. Auto-fixed with Pint
(braces_position, no_unused_imports, single_line_empty_body) so the check
goes green.

* build(deps): pull invoiceshelf/modules ^3.0.3 via VCS, restore registerAiDriver()

The 3.0.3 release adds Registry::registerAiDriver() (the method DriverRegistryProvider
and AiDriverFactoryTest call). Packagist has the package frozen, so resolve it directly
from the canonical GitHub repo via a composer VCS repository and require ^3.0.3 (the tag
exists; the freeze is Packagist-side only).

Now that the method ships, restore DriverRegistryProvider to Registry::registerAiDriver()
— reverting the temporary generic registerDriver('ai', ...) workaround — so it matches the
package's intended API and the existing tests. The provider is now net-identical to 3.x.

Verified: php artisan package:discover boots clean; the AI suite (incl. the previously
failing AiDriverFactoryTest) passes.

* test: provision modules_statuses.json in the test bootstrap

The Modules/HelloWorld integration test needs the module enabled at the nwidart
level, read from storage/app/modules_statuses.json at app boot. That file is
gitignored (created locally by `module:make`), so it's absent on CI and fresh
clones — HelloWorld stays disabled and the 5 integration tests fail with 404s.

Provision it (only if missing) in tests/Pest.php before any test boots the app,
so CI matches a local dev environment. Full suite: 462 pass.

* fix(test): enable HelloWorld via a committed modules_statuses.json

The Modules/HelloWorld integration test needs the module enabled at the nwidart
level — read from storage/app/modules_statuses.json by FileActivator. That file
is gitignored, so it's absent in CI / fresh clones, leaving HelloWorld disabled
and the 5 integration tests failing with 404s.

The previous tests/Pest.php provisioning (60a7f0d6) only worked under
`./vendor/bin/pest`. CI runs `php artisan test`, which boots the console app
first; FileActivator reads and caches the (absent) statuses at construction
BEFORE Pest.php runs, so test-runtime provisioning is too late. The file must
exist before any boot.

Commit the file via a storage/app/.gitignore negation, and revert the
ineffective Pest.php hack. Prod-safe: Modules/ is gitignored and not copied by
release.yaml, so a phantom "HelloWorld: true" status is ignored by nwidart (no
such module on disk). bootstrap/cache/modules.php is gitignored (absent in CI),
so nothing overrides the committed file.

Verified with `php artisan test --filter=HelloWorld` (the CI command) and the
full suite: 462 pass; pint clean.

* fix(test): commit the Modules/HelloWorld sample module

HelloWorldIntegrationTest exercises Modules/HelloWorld end-to-end, but Modules/
was gitignored (/Modules), so the module was absent from the repo and from CI —
the 5 integration tests 404'd, and the committed modules_statuses.json merely
enabled a module that wasn't there.

Track Modules/HelloWorld (the test fixture) via a `/Modules/*` + `!HelloWorld`
negation. Not shipped to prod (release.yaml omits Modules/). Now the module is
present, autoloaded (merge-plugin), and enabled (statuses file), so its provider
boots and the menu/settings/routes register.

* build: drop the boost:update post-update-cmd hook (breaks CI)

laravel/boost gates its commands to the local environment, so `php artisan
boost:update` fails in CI ("There are no commands defined in the boost
namespace"), making composer's post-update-cmd return exit 1.

It only began failing the build once Modules/HelloWorld/composer.json was
committed: the wikimedia merge-plugin then runs composer's update path on a
plain `composer install`, triggering post-update-cmd. Drop the auto-update
hook (run `boost:update` manually when needed); vendor:publish stays.
This commit is contained in:
Darko Gjorgjijoski
2026-06-05 14:57:06 +02:00
committed by GitHub
parent ac2a8ca939
commit b73dcb5278
28 changed files with 695 additions and 14 deletions

View File

@@ -0,0 +1,56 @@
<?php
namespace Modules\HelloWorld\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class HelloWorldController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('helloworld::index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('helloworld::create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request) {}
/**
* Show the specified resource.
*/
public function show($id)
{
return view('helloworld::show');
}
/**
* Show the form for editing the specified resource.
*/
public function edit($id)
{
return view('helloworld::edit');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, $id) {}
/**
* Remove the specified resource from storage.
*/
public function destroy($id) {}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Modules\HelloWorld\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
/**
* The event handler mappings for the application.
*
* @var array<string, array<int, string>>
*/
protected $listen = [];
/**
* Indicates if events should be discovered.
*
* @var bool
*/
protected static $shouldDiscoverEvents = true;
/**
* Configure the proper event listeners for email verification.
*/
protected function configureEmailVerification(): void {}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Modules\HelloWorld\Providers;
use Illuminate\Support\Str;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use InvoiceShelf\Modules\Support\ModuleServiceProvider;
/**
* Reference module that exercises every InvoiceShelf module-system surface.
*
* This file started life as the output of `php artisan module:make HelloWorld`
* — which, thanks to the custom stubs shipped from the invoiceshelf/modules
* package, already includes the Registry::registerMenu/registerSettings
* skeleton. The schema below has been expanded beyond the stub default to
* exercise more field types for test coverage.
*/
class HelloWorldServiceProvider extends ModuleServiceProvider
{
protected string $name = 'HelloWorld';
protected string $nameLower = 'helloworld';
protected array $providers = [
EventServiceProvider::class,
RouteServiceProvider::class,
];
public function boot(): void
{
parent::boot();
$slug = Str::kebab($this->name);
// ----------------------------------------------------------------
// Module script (Vue page registration)
// ----------------------------------------------------------------
// Registers a compiled JS file that the host app injects as a
// <script type="module"> tag. The script calls
// window.InvoiceShelf.booting() to add Vue routes before mount.
ModuleRegistry::registerScript(
$slug,
module_path($this->name, 'resources/dist/init.js')
);
// ----------------------------------------------------------------
// Sidebar menu item
// ----------------------------------------------------------------
// Adds a link in the company sidebar under the "Modules" section.
// The `title` uses a Laravel translation key (namespace::file.key)
// resolved server-side before being sent to the Vue frontend.
// The `link` points to the module's own Vue page (registered by init.js).
// The `icon` is any Heroicon name available in BaseIcon.
ModuleRegistry::registerMenu($slug, [
'title' => $this->nameLower.'::menu.title',
'link' => '/admin/modules/'.$slug.'/dashboard',
'icon' => 'HandRaisedIcon',
]);
// ----------------------------------------------------------------
// Schema-driven settings
// ----------------------------------------------------------------
// Registers a settings form that appears in the company modules
// page. Each company configures settings independently. Values are
// persisted as CompanySetting keys: module.{slug}.{field_key}.
//
// Supported field types: text, textarea, select, switch, number
// Labels use Laravel translation keys, resolved server-side.
ModuleRegistry::registerSettings($slug, [
'sections' => [
[
'title' => $this->nameLower.'::settings.greeting_section',
'fields' => [
[
'key' => 'greeting',
'type' => 'text',
'label' => $this->nameLower.'::settings.greeting',
'rules' => ['required', 'max:120'],
'default' => 'Hello, world!',
],
[
'key' => 'recipient',
'type' => 'text',
'label' => $this->nameLower.'::settings.recipient',
'rules' => ['max:60'],
'default' => 'friend',
],
[
'key' => 'show_emoji',
'type' => 'switch',
'label' => $this->nameLower.'::settings.show_emoji',
'default' => true,
],
],
],
[
'title' => $this->nameLower.'::settings.style_section',
'fields' => [
[
'key' => 'tone',
'type' => 'select',
'label' => $this->nameLower.'::settings.tone',
'rules' => ['required'],
'default' => 'friendly',
'options' => [
'friendly' => 'Friendly',
'formal' => 'Formal',
'enthusiastic' => 'Enthusiastic',
],
],
[
'key' => 'note',
'type' => 'textarea',
'label' => $this->nameLower.'::settings.note',
'rules' => ['max:500'],
],
],
],
],
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Modules\HelloWorld\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
protected string $name = 'HelloWorld';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*/
public function boot(): void
{
parent::boot();
}
/**
* Define the routes for the application.
*/
public function map(): void
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*/
protected function mapWebRoutes(): void
{
Route::middleware('web')->group(module_path($this->name, '/routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*/
protected function mapApiRoutes(): void
{
Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php'));
}
}