Skip to content

Seller portal — monorepo membership & release flow

The seller-portal Vue 3 SPA lives in this monorepo at portals/seller-portal and ships inside the FlyokaiMarketplace plugin: the compiled bundle is served same-origin by the plugin, so a buyer/seller hits /seller-portal on the Shopware host and the SPA talks back to /flyok-app, /flyok-portal and /store-api on that same origin (no CORS, no separate deploy).

Why portals/ and not packages/ or plugins/

monorepo-builder.php lists only packages/ + plugins/ as package directories, and bin/flyokai-split.sh / .github/workflows/split.yml mirror each packages/* and plugins/* leaf (keyed by its composer.json name) out to a read-only flyokai/* repo. portals/seller-portal is an npm package (no composer.json) and a deliverable, not a consumable library — so it sits under portals/, is excluded from the split, and has no read-only mirror. The SPA <-> plugin /flyok-portal/* request/response DTO contract now versions atomically in one repo.

Importing it (one-time, git subtree)

# from a CLEAN monorepo working tree:
git subtree add --prefix=portals/seller-portal git@github.fly:flyokai/seller-portal.git main

After this the standalone flyokai/seller-portal repo can be retired (or kept as an archived mirror). Future SPA changes are normal monorepo commits under portals/seller-portal.

How the bundle is served (runtime)

  • bin/build-portal.sh runs vite build --base=/bundles/flyokaimarketplace/portal/ and copies dist/ into plugins/FlyokaiMarketplace/src/Resources/public/portal (gitignored).
  • bin/console assets:install publishes that to public/bundles/flyokaimarketplace/portal. Hashed /assets/* are served statically by the webserver.
  • SellerPortalController (route scope flyok-portal, auth_required=false) serves the HTML shell at /seller-portal and /seller-portal/{path} (deep links / refresh), injecting per-install runtime config:
<script>window.__PORTAL_CONFIG__ = {
  "apiBase": "",                         // same-origin
  "routerBase": "/seller-portal/",       // Vue Router history base
  "oauthClientId": "…",                  // FlyokaiMarketplace.config.sellerOauthClientId
  "oauthClientSecret": "…",              // FlyokaiMarketplace.config.sellerOauthClientSecret (public by design)
  "salesChannelAccessKey": "…",          // first active storefront sales channel
  "marketplacePrefix": "/flyok-portal"   // where the Base plugin serves the marketplace API
};</script>
  • The SPA reads these via src/config.ts (portalConfig), falling back to import.meta.env.VITE_* so npm run dev against the Vite proxy still works unchanged. One built bundle works on any install — credentials are not baked in.

Asset base vs router base

Two independent prefixes: - Vite base = /bundles/flyokaimarketplace/portal/ — where /assets/* load from (set via the --base build flag; vite.config.ts is untouched). - Router base = /seller-portal/ — where the app is mounted (createWebHistory(portalConfig.routerBase)).

Marketplace API prefix (/api/marketplace → /flyok-portal)

Service files keep calling the stable cluster-era path /api/marketplace/*. In dev, Vite's proxy rewrites that to /flyok-portal. In the bundled build there is no proxy, so the http client rewrites the prefix at request time using portalConfig.marketplacePrefix (the host injects /flyok-portal). Without this every API call would 404 against the same-origin host (which only serves /flyok-portal/*). /flyok-app, /store-api, /flyok-portal paths are passed through unchanged.

Operator setup on a fresh install

bin/console plugin:install --activate FlyokaiMarketplace
bin/console marketplace:oauth:client:create     # stores client id + secret (+ plaintext for injection)
bin/console marketplace:user:create --role=seller …
bin/console assets:install
# → browse to https://<shop>/seller-portal

PHP dependency vendoring (bin/vendor-plugin.sh)

The plugin's PHP deps — the private flyokai/* packages, the flyokai forks (revolt-event-loop, laminas-db) and the third-party closure (amphp/*, laminas/*, league/uri, commerceguys/addressing, cuyz/valinor) — are not resolvable from Packagist on a clean install, and executeComposerCommands() returns false, so Shopware never runs composer. The zip therefore ships a self-contained vendor/ that FlyokaiMarketplace::patchAutoloader() registers (PSR-4 prefixes + amphp function files) against the Shopware kernel classloader at boot.

⚠️ Assembled in the build stage, never the live plugin. The live plugins/FlyokaiMarketplace is symlinked into running installs (vendor/flyokai/sw-marketplace → monorepo). A vendor/ there makes patchAutoloader double-load classes the install already resolves from root vendor → fatal "Cannot redeclare". So vendor-plugin.sh writes into a destination plugin dir (default build/FlyokaiMarketplace) and refuses to target the live source.

composer install is not viable here: the flyokai packages require each other via self.version (monorepo-only) and patchAutoloader expects a specific on-disk layout. So the source of truth for which packages + layout is a working symlinked plugin vendor (the dev setup). bin/vendor-plugin.sh [DEST]:

  1. dereferences REF_VENDOR (default /www/shopware/main/custom/plugins/FlyokaiMarketplace/vendor) into real copies under DEST/vendor — capturing the exact set the dev plugin loads;
  2. overlays the fresher flyokai/* straight from the monorepo packages/;
  3. verifies every key patchAutoloader path resolves.

flyokai/application is trimmed, not shipped whole. The full package is the cluster runtime (~250 files, 1.2 MB), but application-core holds the real impls of the leaf classes the embedded plugin uses, and consumers reference \Flyokai\ApplicationCore\* directly (the legacy class_alias shims were removed in 546342e3). From application the embedded path now loads only 6 files: Base/ExtensionRepositoryTrait.php (real bridge trait — SellerRepositoryImpl still uses the legacy namespace; traits can't be class_alias'd) and 5 custom Laminas Ddl Column classes (json/longtext/mediumtext/smallint/timestamp) that db-schema's ColumnFactory instantiates during applyMarketplaceSchema() on install/update. Everything else (Acl, Bootstrap, Console, Cluster, Setup, …) is cluster-only and never autoloaded in embedded mode, so vendor-plugin.sh copies just the keep-list and the build checks fail loudly if a keep-list file goes missing. The clean activate in the smoke test — which runs schema apply — is the proof the trimmed set is sufficient.

doctrine/collections (+doctrine/deprecations) must be in REF_VENDOR. commerceguys/addressing's Subdivision::getChildren() returns a Doctrine\Common\Collections\ArrayCollection, and a clean Shopware install has no doctrine/collections (core ships dbal, not the ORM). patchAutoloader registers both prefixes from the bundled vendor; the smoke test's /locations/countries/US/subdivisions check is the one that exercises this path.

Shopware-provided libraries (symfony/*, twig/*) are intentionally not bundled — they come from the host's root vendor on any Shopware install.

Override the source when the default reference isn't present: REF_VENDOR=/path/to/working/plugin/vendor bin/vendor-plugin.sh.

Deploy + smoke (clean-room validation)

Two scripts — both take the target Shopware install as a required positional arg (no env-var defaults; you say where it goes):

bin/deploy-plugin.sh <SW_DIR>             # build zip, install, activate, build admin
bin/smoke-portal.sh   <SW_DIR> [BASE_URL] # HTTP checks against a deployed install
bin/smoke-portal.sh --deploy <SW_DIR>     # deploy + check in one command

The canonical target is /www/sw6710st/sw6710st (→ https://sw6710st.loc) — a Shopware install that does NOT symlink the monorepo and has NO flyokai/* in its root vendor, so the plugin runs purely off its bundled vendor/ + patchAutoloader. Both scripts refuse any install that fails the clean-room guards (symlinked plugin, or flyokai/* in root vendor would mask gaps). (/www/sw6710/sw6710 stays a dev box — the portal runs there as a Vite dev server from portals/seller-portal, not the bundled route.)

deploy-plugin.sh does: package-plugin.sh → unzip to custom/plugins/ → plugin:refresh → plugin:install --activate (or :update on re-runs) — a clean activate proves the shipped vendor is complete — → build-administration.sh (compiles per-plugin admin into Resources/public/administration/, else /admin shows no FlyokaiMarketplace menu items) → assets:install → cache:clear.

smoke-portal.sh HTTP-checks /seller-portal (shell + injected window.__PORTAL_CONFIG__ incl. marketplacePrefix=/flyok-portal), the marketplace API (/flyok-portal/locations/countries), the doctrine-bundled path (/locations/countries/US/subdivisions), a hashed portal asset, and the compiled admin bundle (/bundles/flyokaimarketplace/administration/assets/ flyokai-marketplace-*.js) — the last one is the proof that admin built and the sidebar entries will render.

TODO — ship pre-built admin in the zip. deploy-plugin.sh runs build-administration.sh on the install (~30 s, needs Node + the install's npm deps). The Shopware Store recommends shipping pre-built admin so customer installs don't need a JS build toolchain. Tooling option: shopware-cli extension build builds a single extension in isolation, suitable for packaging into the zip.

Login prerequisite: the bundled portal injects the OAuth client_secret from system config, persisted only for clients created after the plaintext-secret change. On a fresh/pre-existing install run bin/console marketplace:oauth:client:create once so login works (the smoke test warns when it's empty).

TODO before actual submission: fix the cosmetic favicon 404 below.

Versioning & cutting a release

Shopware reads the plugin version from the zip's composer.json version. plugin:refresh records it as the available version; when it exceeds the installed version the plugin shows "upgradeable" and plugin:update runs Plugin::update() + new migrations. The Store requires each upload to have a higher version than the last.

The version lives in plugins/FlyokaiMarketplace/VERSION — not in the live composer.json, which deliberately has no version so the monorepo self.version deps resolve as dev-main on the symlinked dev install. The packaging step stamps VERSION into the shipped composer.json and the zip name.

bin/release-plugin.sh patch     # 1.0.0 -> 1.0.1, then package
bin/release-plugin.sh minor     # -> 1.1.0
bin/release-plugin.sh 2.3.0     # set explicitly
bin/release-plugin.sh           # no bump — repackage at current version

It prints the suggested git commit/git tag v<version> (it does not touch git itself). Then run bin/smoke-portal.sh --deploy /www/sw6710st/sw6710st to validate the new build on the clean-room install (it plugin:updates an already-installed version, rebuilds admin, then HTTP-checks).

Packaging the Store zip

bin/package-plugin.sh        # → build/FlyokaiMarketplace-<VERSION>.zip

It stages the plugin source, stamps the VERSION into the shipped composer.json and strips the private flyokai/* requires (they ship in vendor/ and load via patchAutoloader, not composer — Shopware's plugin requirement check otherwise rejects the self.version entries as "missing or not installed"), runs build-portal.sh (SPA) + vendor-plugin.sh (PHP vendor) into the stage, and zips it. CI equivalent: .github/workflows/release.yml.

Known cosmetic gap

The built index.html keeps a root-absolute <link rel="icon" href="/favicon.svg"> (Vite does not rewrite absolute URLs in index.html). It 404s under the bundle path — harmless. Fix later by making the favicon relative or dropping it.