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,23 @@
/**
* HelloWorld module entry point.
*
* This file is loaded by the host app via a <script type="module"> tag
* injected by Registry::allScripts(). It runs BEFORE the Vue app mounts,
* so router.addRoute() works reliably.
*
* All Base* components (BaseCard, BaseIcon, BasePage, etc.) are globally
* registered by the host — no imports needed in your Vue templates.
*/
import DashboardPage from './pages/DashboardPage.vue'
window.InvoiceShelf.booting((_app, router) => {
// Register a Vue page route
router.addRoute('admin', {
path: 'modules/hello-world/dashboard',
name: 'modules.hello-world.dashboard',
component: DashboardPage,
meta: {
requiresAuth: true,
},
})
})

View File

@@ -0,0 +1,49 @@
<template>
<BasePage>
<BasePageHeader title="Hello World">
<BaseBreadcrumb>
<BaseBreadcrumbItem title="Home" to="dashboard" />
<BaseBreadcrumbItem title="Hello World" to="#" active />
</BaseBreadcrumb>
</BasePageHeader>
<BaseCard class="mt-6">
<div class="p-6">
<div class="flex items-center gap-3">
<BaseIcon name="HandRaisedIcon" class="h-8 w-8 text-primary-500" />
<h2 class="text-xl font-semibold text-heading">{{ greeting }}</h2>
</div>
<p class="mt-3 text-sm text-muted leading-relaxed">
This page is provided by the <strong>HelloWorld</strong> module.
It demonstrates how modules can ship their own Vue pages that
render inside the InvoiceShelf SPA using globally registered
Base components.
</p>
<div class="mt-6 rounded-lg bg-surface-secondary p-4">
<h3 class="text-sm font-semibold text-heading mb-2">How it works</h3>
<ul class="space-y-1.5 text-sm text-muted">
<li class="flex items-start gap-2">
<BaseIcon name="CheckIcon" class="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
Module ships a compiled <code class="text-primary-600">init.js</code> that calls <code class="text-primary-600">window.InvoiceShelf.booting()</code>
</li>
<li class="flex items-start gap-2">
<BaseIcon name="CheckIcon" class="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
The callback receives <code class="text-primary-600">(app, router)</code> and adds routes via <code class="text-primary-600">router.addRoute()</code>
</li>
<li class="flex items-start gap-2">
<BaseIcon name="CheckIcon" class="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
Vue pages use globally registered <code class="text-primary-600">Base*</code> components no imports needed
</li>
</ul>
</div>
</div>
</BaseCard>
</BasePage>
</template>
<script setup>
import { ref } from 'vue'
const greeting = ref('Hello, world!')
</script>

View File

@@ -0,0 +1,37 @@
/**
* Vue runtime shim for InvoiceShelf modules.
*
* The host app exposes its Vue instance on window.__invoiceshelf_vue.
* This shim provides a Proxy-based default export that lazily resolves
* Vue APIs on first access — avoiding the crash that happens when the
* module script evaluates before the host has set the global.
*
* For named exports (used by SFC compiled templates), we use a Proxy
* as the module namespace. Vite's lib mode with a default export
* from a Proxy works because the compiled SFC template accesses
* the APIs at render time (long after the host has initialized),
* not at module evaluation time.
*/
function getVue() {
const vue = window.__invoiceshelf_vue
if (!vue) {
throw new Error(
'[InvoiceShelf Module] Host Vue runtime not available. ' +
'Ensure the module script loads after the host app.'
)
}
return vue
}
// Proxy that forwards all property access to the host's Vue at call time
const vueProxy = new Proxy({}, {
get(_, key) {
return getVue()[key]
},
has(_, key) {
return key in getVue()
},
})
export default vueProxy