diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2464a488..af263c5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,22 @@ jobs: - name: ShellCheck bin scripts run: shellcheck -x bin/*.sh bin/lib/*.sh + docs-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + - run: npm ci --no-audit --no-fund + # Prettier owns the shape -- 100 columns, wrapped prose, one style across every markdown + # file. The changelogs keep one line per entry (see .prettierrc.json). + - name: Markdown formatting + run: npm run lint:md + - name: Markdown links + run: bin/ci-assert-markdown-links.sh + verify-main-validation: runs-on: ubuntu-latest outputs: @@ -137,6 +153,7 @@ jobs: expected_checks=( "shell-lint" + "docs-lint" "admin-static" "php-quality (php-8.1)" "php-quality (php-8.2)" diff --git a/.github/workflows/package-zip.yml b/.github/workflows/package-zip.yml index e583c24d..da4e208b 100644 --- a/.github/workflows/package-zip.yml +++ b/.github/workflows/package-zip.yml @@ -20,14 +20,6 @@ on: types: [opened, reopened, synchronize, labeled] workflow_dispatch: inputs: - sdk_ref: - description: 'ucp-php-sdk ref to vendor into the zip' - required: false - default: '0.0.7' - sdk_version: - description: 'Version to attribute to that ref (its packages are untagged)' - required: false - default: '0.0.7' overwrite_version: description: 'Plugin version to stamp (Shopware rejects semver pre-release suffixes; use e.g. 1.3.99)' required: false @@ -52,42 +44,6 @@ jobs: # full history so shopware-cli can generate the changelog fetch-depth: 0 - # The SDK's release track is ahead of anything published, so a plain resolve gets - # whatever packagist last saw rather than the code this archive is meant to carry. - # Checked out and resolved from source instead, and placed *inside* the extension: - # shopware-cli copies the extension to a temp directory before running composer, so - # a path repository pointing at a sibling (`../ucp-php-sdk`) resolves during - # planning and then materialises nothing. - - name: Check out the SDK ref to vendor - uses: actions/checkout@v6 - with: - repository: agentic-commerce-alliance/ucp-php-sdk - ref: ${{ github.event.inputs.sdk_ref || '0.0.7' }} - path: ucp-php-sdk - - - name: Vendor that SDK ref into the extension - env: - SDK_VERSION: ${{ github.event.inputs.sdk_version || '0.0.7' }} - run: | - set -euo pipefail - mkdir -p .sdk - cp -a ucp-php-sdk/packages/core .sdk/core - cp -a ucp-php-sdk/packages/symfony-bundle .sdk/symfony-bundle - rm -rf .sdk/core/tests .sdk/symfony-bundle/tests ucp-php-sdk - # `options.versions` attributes a version without editing the SDK's own - # composer.json -- its packages carry none and derive it from tags, and the - # bundle already requires core >=0.0.7 at the point a tag is cut. - composer config repositories.ucp-sdk-core "{\"type\":\"path\",\"url\":\"./.sdk/core\",\"options\":{\"symlink\":false,\"versions\":{\"ucp-php-sdk/core\":\"${SDK_VERSION}\"}}}" - composer config repositories.ucp-sdk-symfony "{\"type\":\"path\",\"url\":\"./.sdk/symfony-bundle\",\"options\":{\"symlink\":false,\"versions\":{\"ucp-php-sdk/symfony-bundle\":\"${SDK_VERSION}\"}}}" - # The source checkout puts development tooling in .tools/vendor. The archive's - # bootstrap loads runtime dependencies from vendor/autoload.php. - composer config vendor-dir vendor - # Fresh checkouts have no lock file, so Composer must resolve the full set. - composer update --with "ucp-php-sdk/symfony-bundle:${SDK_VERSION}" --with "ucp-php-sdk/core:${SDK_VERSION}" --no-dev --no-scripts --no-interaction --prefer-dist - - - name: Keep only SDK packages in the bundled runtime - run: bin/ci-bundle-sdk-only.sh - - name: Install shopware-cli uses: shopware/shopware-cli-action@v3 @@ -98,8 +54,8 @@ jobs: if [[ -n "${{ github.event.inputs.overwrite_version }}" ]]; then version_flag=(--overwrite-version "${{ github.event.inputs.overwrite_version }}") fi - # Use the prepared checkout: selecting a git commit discards the SDK and - # Composer changes made by the preceding step. + # --disable-git packs the checkout as it stands. Nothing in this job modifies it any + # more, and bin/ci-assert-zip-no-vendor.sh fails the build if anything strays in. shopware-cli extension zip . --disable-git --release "${version_flag[@]}" shopware-cli extension validate SwagAgenticCommerce.zip @@ -108,11 +64,11 @@ jobs: - name: Assert the zip ships a usable administration bundle run: bin/ci-assert-zip-admin-bundle.sh SwagAgenticCommerce.zip - # Neither `extension validate` nor the admin-bundle guard looks at vendor/, so an - # archive whose autoloader points at an SDK that was never copied in passes both and - # then fatals on the first request with `Class "Ucp\\Sdk\\..." not found`. - - name: Assert the zip carries the SDK it autoloads - run: bin/ci-assert-zip-vendors-sdk.sh SwagAgenticCommerce.zip + # `extension validate` does not read the archive's dependency shape, so a build that + # reintroduced a bundled vendor/ -- and with it a second Composer autoloader in every + # shop -- would pass it. + - name: Assert the zip bundles no dependencies + run: bin/ci-assert-zip-no-vendor.sh SwagAgenticCommerce.zip - name: Unpack so the downloaded artifact is directly installable run: | @@ -126,19 +82,15 @@ jobs: path: dist include-hidden-files: true - # Neither guard above can prove the archive installs. The SDK was once present, correctly - # pointed at, and still never loaded, because Shopware does not require a plugin's own - # vendor/autoload.php -- so both checks passed while `plugin:install` failed on every - # Shopware version. Only an install onto a shop that does not separately provide the SDK - # settles it, and that is a local step until this workflow boots a shop of its own. - - name: How to verify this archive actually installs + # The zip-install job below installs this archive on 6.5, 6.6 and trunk. This tells a + # developer how to repeat it against their own lane, against the downloaded artifact. + - name: How to verify this archive on a local lane run: | { - echo "### Before handing this archive to anyone" + echo "### Installing this archive on your own lane" echo - echo 'The checks in this job prove the SDK is *in* the archive and that the autoloader' - echo '*points* at it. They cannot prove it loads. Run the install smoke against a lane' - echo 'whose shop does not already provide the SDK:' + echo 'The zip-install job already did this on 6.5.x, 6.6.x and trunk. To repeat it' + echo 'against a local lane, on a shop that does not already provide the SDK:' echo echo '```' echo 'bin/test-zip-install.sh --zip .zip \' @@ -150,3 +102,67 @@ jobs: echo 'It strips the project-level SDK first and refuses to run if the shop can still' echo 'resolve it, because that is how the last defect stayed invisible.' } >> "$GITHUB_STEP_SUMMARY" + + # Neither the admin-bundle guard nor the SDK guard can prove the archive installs. The SDK was + # once present, correctly pointed at, and still never loaded, because Shopware does not require + # a plugin's own vendor/autoload.php -- so both checks passed while `plugin:install` failed on + # every Shopware version. Only an install onto a shop that does not separately provide the SDK + # settles it, on each supported line. + zip-install: + name: Install the zip (${{ matrix.lane }}) + needs: package-zip + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + lane: ['6.5.x', '6.6.x', 'trunk'] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + path: agentic-commerce + - name: Check out the lane + uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ matrix.lane }} + path: shopware + - name: Download the built archive + uses: actions/download-artifact@v7 + with: + name: SwagAgenticCommerce + path: archive + # The artifact is uploaded unpacked (see the note at the top of this file), so re-zip the + # plugin directory to get back the single-rooted archive the upload endpoint expects. + - name: Re-zip the archive + run: cd archive && zip -qr "$GITHUB_WORKSPACE/SwagAgenticCommerce.zip" SwagAgenticCommerce + - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-write-compose.sh agentic-commerce/bin/test-zip-install.sh + - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" + # CI_SMOKE_SKIP_PLUGIN removes the plugin and both SDK packages from the project before + # installing it, which is the whole point: on a shop that can resolve the SDK by another + # route, a green install proves nothing about the archive. + - name: Boot a shop that has never seen the SDK + run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" + env: + SHOPWARE_REF: ${{ matrix.lane }} + # SKIP_PLUGIN is only accepted together with BOOTSTRAP_ONLY: a run that installs + # nothing has no smoke assertions left to make. + CI_SMOKE_SKIP_PLUGIN: '1' + CI_SMOKE_BOOTSTRAP_ONLY: '1' + CI_SMOKE_MODE: cold + CI_SMOKE_KEEP_STACK: '1' + - name: Install the archive the way a merchant does + working-directory: shopware + run: | + set -euo pipefail + container="$(docker compose ps -a -q web)" + if [[ -z "${container}" ]]; then + echo "The lane has no web container; the bootstrap step did not leave the stack up." >&2 + exit 1 + fi + url="$(sed -nE 's/^[[:space:]]*APP_URL:[[:space:]]*(.+)$/\1/p' compose.yaml | head -n 1)" + "$GITHUB_WORKSPACE/agentic-commerce/bin/test-zip-install.sh" \ + --zip "$GITHUB_WORKSPACE/SwagAgenticCommerce.zip" \ + --container "${container}" \ + --shop-url "${url:-http://localhost:8000}" diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index d7e76bea..c0925081 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -76,13 +76,6 @@ jobs: echo "Validated main HEAD ${GITHUB_SHA} with CI gate ${gate_id}." - - name: Prepare the released SDK runtime - run: | - set -euo pipefail - composer config vendor-dir vendor - composer update --with ucp-php-sdk/symfony-bundle:0.0.7 --with ucp-php-sdk/core:0.0.7 --no-dev --no-scripts --no-interaction --prefer-dist - bin/ci-bundle-sdk-only.sh - - name: Install shopware-cli uses: shopware/shopware-cli-action@v3 @@ -92,7 +85,7 @@ jobs: shopware-cli extension zip . --disable-git --release shopware-cli extension validate SwagAgenticCommerce.zip bin/ci-assert-zip-admin-bundle.sh SwagAgenticCommerce.zip - bin/ci-assert-zip-vendors-sdk.sh SwagAgenticCommerce.zip + bin/ci-assert-zip-no-vendor.sh SwagAgenticCommerce.zip mkdir -p dist unzip -q SwagAgenticCommerce.zip -d dist diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..aa48348d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +# Not ours to format. +node_modules/ +.tools/ +vendor/ +var/ +dist/ +src/Resources/app/**/node_modules/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..7940623a --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "proseWrap": "always", + "overrides": [ + { + "files": ["CHANGELOG.md", "CHANGELOG_de-DE.md"], + "options": { + "proseWrap": "preserve" + } + } + ] +} diff --git a/.shopware-extension.yml b/.shopware-extension.yml index df29a2cf..024e3dc3 100644 --- a/.shopware-extension.yml +++ b/.shopware-extension.yml @@ -23,13 +23,15 @@ build: pack: excludes: paths: - # Build-only: the workflow copies the SDK packages here and composer resolves - # them from here into vendor/. Shipping this as well would put two copies of - # the SDK in the archive. - - .sdk + # Development tooling, never part of a release. The source manifest points Composer + # at .tools/vendor and npm fills node_modules; `extension zip --disable-git` copies + # the working tree verbatim, so without these a local build ships PHPUnit, PHPStan + # and the whole npm tree. + - .tools - bin - docs - eslint.config.mjs + - node_modules - package.json - package-lock.json - playwright.config.js diff --git a/AGENTS.md b/AGENTS.md index 709d85f0..67f311c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,32 +1,27 @@ # Agent Notes -This repository is a Shopware plugin, not Shopware core. Keep changes compatible -with Shopware `6.5.x`, `6.6.x`, and the latest/trunk line from one codebase. -Do not duplicate administration modules, controllers, transport logic, or UCP -runtime code per Shopware version. Use shared code plus explicit feature -detection. +This repository is a Shopware plugin, not Shopware core. Keep changes compatible with Shopware +`6.5.x`, `6.6.x`, and the latest/trunk line from one codebase. Do not duplicate administration +modules, controllers, transport logic, or UCP runtime code per Shopware version. Use shared code +plus explicit feature detection. ## Shopware Plugin Context -- Shopware uses a custom Data Abstraction Layer. Do not add Doctrine ORM, - Doctrine annotations, or ORM-style repositories. +- Shopware uses a custom Data Abstraction Layer. Do not add Doctrine ORM, Doctrine annotations, or + ORM-style repositories. - Use DAL `Criteria` and `EntityRepository` for Shopware entity access. -- Prefer Shopware extension mechanisms that already exist: events, - subscribers, routes, DAL entities, Twig blocks/templates, and service - decoration only when event timing is not enough. -- Keep public plugin contracts explicit. REST/Admin/Store API routes, DAL - entities, template context, and documented SDK/UCP behavior are the BC - surface. Controllers, subscribers, loaders, renderers, and discovery services - should be internal unless they are intended extension points. -- Follow `docs/public-api-boundaries.md` for PHP API scope. Classes, - interfaces, and traits in internal-by-default namespaces must carry - `@internal`; keep package annotations out of scope unless a task explicitly - asks for them. -- Keep services unit-testable without external systems. Translate framework - objects (`Request`, IO, database, filesystem, HTTP) at the edge before calling - application services. -- Prefer public readonly properties for simple transparent value objects. Do - not add DTOs only to model a private handoff inside one class. +- Prefer Shopware extension mechanisms that already exist: events, subscribers, routes, DAL + entities, Twig blocks/templates, and service decoration only when event timing is not enough. +- Keep public plugin contracts explicit. REST/Admin/Store API routes, DAL entities, template + context, and documented SDK/UCP behavior are the BC surface. Controllers, subscribers, loaders, + renderers, and discovery services should be internal unless they are intended extension points. +- Follow `docs/public-api-boundaries.md` for PHP API scope. Classes, interfaces, and traits in + internal-by-default namespaces must carry `@internal`; keep package annotations out of scope + unless a task explicitly asks for them. +- Keep services unit-testable without external systems. Translate framework objects (`Request`, IO, + database, filesystem, HTTP) at the edge before calling application services. +- Prefer public readonly properties for simple transparent value objects. Do not add DTOs only to + model a private handoff inside one class. ## Validation @@ -41,178 +36,159 @@ composer test:integration # DB-backed integration suite (real connection, e.g. composer test:functional # functional suite, boots a real test kernel + Symfony browser ``` -The scripts delegate through `bin/run.php`, which can resolve tooling from this -plugin checkout or from a linked Shopware lane. If no local tooling or Shopware -lane exists, run the narrowest fallback checks, such as `php -l` on touched PHP -files, and explain what was not runnable. +The scripts delegate through `bin/run.php`, which can resolve tooling from this plugin checkout or +from a linked Shopware lane. If no local tooling or Shopware lane exists, run the narrowest fallback +checks, such as `php -l` on touched PHP files, and explain what was not runnable. -For PHP changes, run the smallest relevant test suite first. Broaden to static -analysis, integration tests, or lane smoke checks when the touched code affects -shared runtime behavior, persistence, routes, or administration assets. +For PHP changes, run the smallest relevant test suite first. Broaden to static analysis, integration +tests, or lane smoke checks when the touched code affects shared runtime behavior, persistence, +routes, or administration assets. ### Test layering: prefer functional over smoke -Cover behavior at the lowest layer that can express it, and prefer a PHP test -over a shell smoke check whenever the behavior fits one — PHP tests are readable, -debuggable, and run without a deployed HTTP stack: - -1. **`unit`** (`tests/Unit`, `composer test`) — pure logic with mocks; no kernel. - Mock-only collaboration tests (no kernel boot) live here too — a test that only - wires mocks is a unit test regardless of how many collaborators it stubs. -2. **`integration`** (`tests/Integration`, `composer test:integration`) — DB-backed - tests that use a real Doctrine connection through the kernel (e.g. the migration - tests). Reserve this tier for tests that genuinely touch the database/kernel; - mock-only tests belong in `unit`. -3. **`functional`** (`tests/Functional`, `composer test:functional`) — boots a - real Shopware test kernel and drives UCP runtime routes end-to-end through a - real Symfony `KernelBrowser` (the full HttpKernel request/response cycle, - kernel events included), against `APP_URL` — the test database's default - storefront sales-channel domain — exactly as Shopware's own functional tests - do. This is the **preferred** home for route/request-context/capability - behavior that used to be asserted by shell smoke. It already covers the - request-context guards (missing UCP-Agent → 422, OAuth metadata → 501) and the - **catalog/cart/checkout capability flows** — including completing a checkout - into a **real Shopware order** and reading it back via its persisted context - token. Requires the booting bootstrap (`SHOPWARE_PROJECT_DIR` unset + - `APP_ENV=test`). Like core, the suite assumes a booted kernel (no per-test - skip-guards) — run it via `composer test:functional` against a configured lane - (e.g. the `shopware-6-6-branch-web` container), never under the fast-path - bootstrap. It gates in CI on **every** `shopware-matrix` lane +Cover behavior at the lowest layer that can express it, and prefer a PHP test over a shell smoke +check whenever the behavior fits one — PHP tests are readable, debuggable, and run without a +deployed HTTP stack: + +1. **`unit`** (`tests/Unit`, `composer test`) — pure logic with mocks; no kernel. Mock-only + collaboration tests (no kernel boot) live here too — a test that only wires mocks is a unit test + regardless of how many collaborators it stubs. +2. **`integration`** (`tests/Integration`, `composer test:integration`) — DB-backed tests that use a + real Doctrine connection through the kernel (e.g. the migration tests). Reserve this tier for + tests that genuinely touch the database/kernel; mock-only tests belong in `unit`. +3. **`functional`** (`tests/Functional`, `composer test:functional`) — boots a real Shopware test + kernel and drives UCP runtime routes end-to-end through a real Symfony `KernelBrowser` (the full + HttpKernel request/response cycle, kernel events included), against `APP_URL` — the test + database's default storefront sales-channel domain — exactly as Shopware's own functional tests + do. This is the **preferred** home for route/request-context/capability behavior that used to be + asserted by shell smoke. It already covers the request-context guards (missing UCP-Agent → 422, + OAuth metadata → 501) and the **catalog/cart/checkout capability flows** — including completing a + checkout into a **real Shopware order** and reading it back via its persisted context token. + Requires the booting bootstrap (`SHOPWARE_PROJECT_DIR` unset + `APP_ENV=test`). Like core, the + suite assumes a booted kernel (no per-test skip-guards) — run it via `composer test:functional` + against a configured lane (e.g. the `shopware-6-6-branch-web` container), never under the + fast-path bootstrap. It gates in CI on **every** `shopware-matrix` lane (`CI_SMOKE_RUN_FUNCTIONAL=1`). - The flow tests share `UcpFlowTestBehaviour`, which reproduces the SDK - request-context handshake offline: it sets the sales-channel config the smoke - sets (`active`, `signaturePolicy=log`, `continueUrlTemplate`), seeds a product - with the core `ProductBuilder` fixture, and hands the merchant's own - capability-bearing `PlatformProfile` (built via `ProfileBuilderInterface` with - `enabledCapabilities`) to a test `AgentProfileFetcherInterface`. The stub (not - the SDK profile cache) is required: the real fetcher runs an SSRF URL-safety - check that rejects the lane's `*.localhost` host. The override is wired the way - core overrides services for tests — a `test`-environment-only service swap - (`TestAgentProfileFetcherCompilerPass` replaces the SDK's `HttpAgentProfileFetcher` - with `Ucp\Test\StaticAgentProfileFetcher`), so the test just calls `setProfile()` - on it; no kernel reboot is needed. - - **Runs on the lane's own phpunit, not the plugin's.** The suite uses Shopware - core's test base classes (`IntegrationTestBehaviour`), which are coupled to the - lane's phpunit major — 6.5 pins 9.x (the removed `getName()`), 6.6 10.x, trunk - 11.x — so a single pinned phpunit cannot span all lanes. `bin/run.php` prefers - the platform phpunit binary when inside a lane, and `tests/bootstrap.php` - registers the plugin's `src` + `Tests` namespaces on the platform autoloader, - so the suite runs on whatever phpunit the lane ships. ci-smoke installs - Shopware's dev deps (the smoke stack is `--no-dev`) to make that binary - available. This is distinct from the **unit/mock** suites, which deliberately - run on the plugin's pinned `.tools` phpunit (fast, lane-independent) — do not - remove that pin (the PHP-8.1 lane runs against 6.5/phpunit-9, where our - attribute-based tests would otherwise break). - -**Shell smoke is the last resort, not the default.** Almost any smoke assertion can -be expressed as a Symfony-browser functional test, so default to that: add a check -to `bin/lib/smoke/*` only when it genuinely needs the deployed stack (real on-the-wire -delivery, a live external endpoint, a per-lane JS build). Anything provable through a -booted kernel belongs in the `functional` suite. When you migrate a smoke assertion -into a functional test, remove the now-redundant smoke check once the functional suite -gates in CI, so coverage moves rather than duplicates. + The flow tests share `UcpFlowTestBehaviour`, which reproduces the SDK request-context handshake + offline: it sets the sales-channel config the smoke sets (`active`, `signaturePolicy=log`, + `continueUrlTemplate`), seeds a product with the core `ProductBuilder` fixture, and hands the + merchant's own capability-bearing `PlatformProfile` (built via `ProfileBuilderInterface` with + `enabledCapabilities`) to a test `AgentProfileFetcherInterface`. The stub (not the SDK profile + cache) is required: the real fetcher runs an SSRF URL-safety check that rejects the lane's + `*.localhost` host. The override is wired the way core overrides services for tests — a + `test`-environment-only service swap (`TestAgentProfileFetcherCompilerPass` replaces the SDK's + `HttpAgentProfileFetcher` with `Ucp\Test\StaticAgentProfileFetcher`), so the test just calls + `setProfile()` on it; no kernel reboot is needed. + + **Runs on the lane's own phpunit, not the plugin's.** The suite uses Shopware core's test base + classes (`IntegrationTestBehaviour`), which are coupled to the lane's phpunit major — 6.5 pins + 9.x (the removed `getName()`), 6.6 10.x, trunk 11.x — so a single pinned phpunit cannot span all + lanes. `bin/run.php` prefers the platform phpunit binary when inside a lane, and + `tests/bootstrap.php` registers the plugin's `src` + `Tests` namespaces on the platform + autoloader, so the suite runs on whatever phpunit the lane ships. ci-smoke installs Shopware's + dev deps (the smoke stack is `--no-dev`) to make that binary available. This is distinct from the + **unit/mock** suites, which deliberately run on the plugin's pinned `.tools` phpunit (fast, + lane-independent) — do not remove that pin (the PHP-8.1 lane runs against 6.5/phpunit-9, where + our attribute-based tests would otherwise break). + +**Shell smoke is the last resort, not the default.** Almost any smoke assertion can be expressed as +a Symfony-browser functional test, so default to that: add a check to `bin/lib/smoke/*` only when it +genuinely needs the deployed stack (real on-the-wire delivery, a live external endpoint, a per-lane +JS build). Anything provable through a booted kernel belongs in the `functional` suite. When you +migrate a smoke assertion into a functional test, remove the now-redundant smoke check once the +functional suite gates in CI, so coverage moves rather than duplicates. #### What still lives in shell smoke today, and why -These are the checks not yet migrated because they exercise genuine deployed-stack / -on-the-wire behavior that a booted kernel does not observe directly. They are not -"impossible as functional tests" in principle — an e2e/browser harness could cover -most of them — but the booted-kernel suite is the wrong layer for them today: - -- **Outbound signed order webhook** (`smoke/checkout.sh`) — asserts the webhook is - actually *delivered* to an external capture endpoint with `signature`, - `signature-input`, and `content-digest` headers. A booted-kernel test can at most - assert the webhook was *dispatched*; the signed HTTP on the wire is e2e. -- **Tokenize 501** (`smoke/identity.sh`) — the payment endpoint requires a - *signed* request; the smoke only reaches it because it fetches a real profile - with signing keys over HTTP. -- **Profile / discovery** (`smoke/discovery.sh`) — lane-aware MCP transport - detection (depends on the live Store-API MCP endpoint) and the - storefront-rendered `/llms.txt` + `/agents.md` fallbacks (real `Content-Type` - and rendering). -- **Admin & storefront** (`bin/ci-admin-smoke.sh`, `bin/ci-storefront-smoke.sh`) — - per-lane JS builds (webpack/Vite) and the rendered admin/storefront shells. - (These are closer to a Playwright/browser-e2e concern than a bash one; treat the - bash check as a pragmatic build-plus-shell gate, not the ideal long-term home.) +These are the checks not yet migrated because they exercise genuine deployed-stack / on-the-wire +behavior that a booted kernel does not observe directly. They are not "impossible as functional +tests" in principle — an e2e/browser harness could cover most of them — but the booted-kernel suite +is the wrong layer for them today: + +- **Outbound signed order webhook** (`smoke/checkout.sh`) — asserts the webhook is actually + _delivered_ to an external capture endpoint with `signature`, `signature-input`, and + `content-digest` headers. A booted-kernel test can at most assert the webhook was _dispatched_; + the signed HTTP on the wire is e2e. +- **Tokenize 501** (`smoke/identity.sh`) — the payment endpoint requires a _signed_ request; the + smoke only reaches it because it fetches a real profile with signing keys over HTTP. +- **Profile / discovery** (`smoke/discovery.sh`) — lane-aware MCP transport detection (depends on + the live Store-API MCP endpoint) and the storefront-rendered `/llms.txt` + `/agents.md` fallbacks + (real `Content-Type` and rendering). +- **Admin & storefront** (`bin/ci-admin-smoke.sh`, `bin/ci-storefront-smoke.sh`) — per-lane JS + builds (webpack/Vite) and the rendered admin/storefront shells. (These are closer to a + Playwright/browser-e2e concern than a bash one; treat the bash check as a pragmatic + build-plus-shell gate, not the ideal long-term home.) - **Signed-request conformance** (`bin/validate-ucp-store.sh … conformance`). **No capability duplication:** the catalog and cart smoke stages have been removed — their -capability coverage lives entirely in the `functional` suite now. The `checkout` stage stays -in smoke solely to drive the signed order webhook; it resolves the seeded product's title and -price itself (a single `catalog.lookup` as data setup, not a catalog assertion), so it no -longer depends on a `catalog → cart → checkout` stage chain. +capability coverage lives entirely in the `functional` suite now. The `checkout` stage stays in +smoke solely to drive the signed order webhook; it resolves the seeded product's title and price +itself (a single `catalog.lookup` as data setup, not a catalog assertion), so it no longer depends +on a `catalog → cart → checkout` stage chain. ### Shell smoke and lint tooling The `bin/` smoke scripts share helpers from `bin/lib/`: -- `bin/lib/ucp-http.sh` — curl wrappers (`curl_required`, `ucp_status`, - `ucp_expect_status`, `ucp_jsonrpc`), assertions, and `next_idempotency_key`. - `ucp_http_init` builds the `UCP-Agent` header and the wrappers auto-inject it, - so a runtime request can never silently omit it (the SDK rejects a missing - header with `422`). -- `bin/lib/lane.sh` — container helpers (`web`, `db_query`, …) operating on the - sourcing script's `compose` array and `container_runtime`. -- `bin/lib/smoke/*.sh` — `bin/ci-smoke.sh` is a thin orchestrator that, after - bootstrap, sources and runs named stage modules (`discovery`, `identity`, - `checkout`). Each prints a `>>> smoke: ` banner, so a - failure names the area. Stages share the orchestrator's shell scope (they are - sourced, not subprocesses); add a new check by adding a `smoke_` module - and calling it from the orchestrator. Before adding a smoke check, confirm it - cannot be a `functional` test (see *Test layering* above) — smoke is - for deployed-stack concerns only. With `CI_SMOKE_RUN_FUNCTIONAL=1` (set on - every `shopware-matrix` lane) the orchestrator installs Shopware's dev deps and - runs the functional suite on the lane's own phpunit after the HTTP smoke. - -Lint every shell script with `shellcheck -x bin/*.sh bin/lib/*.sh` (the CI -`shell-lint` job; `.shellcheckrc` disables `SC2016` for jq filters). `-x` follows -the `# shellcheck source=` directives so the sourced modules are validated in -context. - -Signed / strict-signature request verification is **not** covered by the smoke -(it sends unsigned requests under log policy); use the conformance suite, +- `bin/lib/ucp-http.sh` — curl wrappers (`curl_required`, `ucp_status`, `ucp_expect_status`, + `ucp_jsonrpc`), assertions, and `next_idempotency_key`. `ucp_http_init` builds the `UCP-Agent` + header and the wrappers auto-inject it, so a runtime request can never silently omit it (the SDK + rejects a missing header with `422`). +- `bin/lib/lane.sh` — container helpers (`web`, `db_query`, …) operating on the sourcing script's + `compose` array and `container_runtime`. +- `bin/lib/smoke/*.sh` — `bin/ci-smoke.sh` is a thin orchestrator that, after bootstrap, sources and + runs named stage modules (`discovery`, `identity`, `checkout`). Each prints a `>>> smoke: ` + banner, so a failure names the area. Stages share the orchestrator's shell scope (they are + sourced, not subprocesses); add a new check by adding a `smoke_` module and calling it from + the orchestrator. Before adding a smoke check, confirm it cannot be a `functional` test (see _Test + layering_ above) — smoke is for deployed-stack concerns only. With `CI_SMOKE_RUN_FUNCTIONAL=1` + (set on every `shopware-matrix` lane) the orchestrator installs Shopware's dev deps and runs the + functional suite on the lane's own phpunit after the HTTP smoke. + +Lint every shell script with `shellcheck -x bin/*.sh bin/lib/*.sh` (the CI `shell-lint` job; +`.shellcheckrc` disables `SC2016` for jq filters). `-x` follows the `# shellcheck source=` +directives so the sourced modules are validated in context. + +Signed / strict-signature request verification is **not** covered by the smoke (it sends unsigned +requests under log policy); use the conformance suite, `bin/validate-ucp-store.sh '' conformance`. ### Composer advisory reporting -Compatibility lanes may need to resolve historical Shopware dependencies with -known advisories. Keep Composer's security blocking disabled for these disposable -CI containers, but preserve visibility through the centralized reporting flow: - -- The `php-quality` PHP 8.2 lane captures the plugin lock's direct dependency - report. The three `shopware-matrix` lanes (`6.5.x`, `6.6.x`, and `trunk`) - capture the dependencies resolved in each installed Shopware environment. -- Those four sources upload normalized JSON as uniquely named - `composer-audit-*` artifacts with short retention. Composer versions that emit - no JSON for a clean audit must still produce an empty report. -- The non-blocking `composer-security-report` job downloads those artifacts, - deduplicates advisory IDs, and writes exactly one workflow warning and one job - summary for the run. -- Do not add Composer advisory annotations or summaries to `php-quality`, - `admin-matrix`, `storefront-matrix`, MySQL, or individual smoke jobs. A - nonzero `composer audit` status can also represent abandoned packages; inspect - the JSON `advisories` data instead of treating the exit code as proof of a - security advisory. -- Keep `composer-security-report` outside `validation-gate`. Missing, malformed, - or known-vulnerable compatibility reports must remain visible without blocking - functional validation. - -Do not reuse a complete installed Shopware tree across these jobs. Lanes use -different Shopware versions and administration build modes, and the tests mutate -dependencies, assets, databases, and caches. Composer's download cache can be -optimized separately without coupling otherwise isolated compatibility jobs. +Compatibility lanes may need to resolve historical Shopware dependencies with known advisories. Keep +Composer's security blocking disabled for these disposable CI containers, but preserve visibility +through the centralized reporting flow: + +- The `php-quality` PHP 8.2 lane captures the plugin lock's direct dependency report. The three + `shopware-matrix` lanes (`6.5.x`, `6.6.x`, and `trunk`) capture the dependencies resolved in each + installed Shopware environment. +- Those four sources upload normalized JSON as uniquely named `composer-audit-*` artifacts with + short retention. Composer versions that emit no JSON for a clean audit must still produce an empty + report. +- The non-blocking `composer-security-report` job downloads those artifacts, deduplicates advisory + IDs, and writes exactly one workflow warning and one job summary for the run. +- Do not add Composer advisory annotations or summaries to `php-quality`, `admin-matrix`, + `storefront-matrix`, MySQL, or individual smoke jobs. A nonzero `composer audit` status can also + represent abandoned packages; inspect the JSON `advisories` data instead of treating the exit code + as proof of a security advisory. +- Keep `composer-security-report` outside `validation-gate`. Missing, malformed, or known-vulnerable + compatibility reports must remain visible without blocking functional validation. + +Do not reuse a complete installed Shopware tree across these jobs. Lanes use different Shopware +versions and administration build modes, and the tests mutate dependencies, assets, databases, and +caches. Composer's download cache can be optimized separately without coupling otherwise isolated +compatibility jobs. ## Administration Build Matrix The administration build system differs by lane: -| Lane | Required admin build validation | -| --- | --- | -| `6.5.x` | webpack only | -| `6.6.x` | webpack and Vite | -| `trunk` / current `6.7+` | Vite only | +| Lane | Required admin build validation | +| ------------------------ | ------------------------------- | +| `6.5.x` | webpack only | +| `6.6.x` | webpack and Vite | +| `trunk` / current `6.7+` | Vite only | Use `bin/ci-admin-smoke.sh ` for build validation: @@ -223,150 +199,134 @@ bin/ci-admin-smoke.sh /path/to/shopware-6-6-branch vite bin/ci-admin-smoke.sh /path/to/shopware-trunk vite ``` -A green lane build does **not** mean the released ZIP works. Lane builds compile the -administration with that lane's own toolchain (webpack or Vite) from source. The ZIP -instead ships a single pre-compiled bundle produced by shopware-cli's **esbuild** +A green lane build does **not** mean the released ZIP works. Lane builds compile the administration +with that lane's own toolchain (webpack or Vite) from source. The ZIP instead ships a single +pre-compiled bundle produced by shopware-cli's **esbuild** (`build.zip.assets.enable_es_build_for_admin` in `.shopware-extension.yml`), written to `Resources/public/administration/js/swag-agentic-commerce.js` with a `.vite/entrypoints.json` pointing at it — the one layout both discovery paths accept: -| Lane | Reads | Injected as | -| --- | --- | --- | -| `6.5.x`, `6.6.x` | `administration/js/.js` | classic script | -| `6.7+` | `administration/.vite/entrypoints.json` | `type="module"` | +| Lane | Reads | Injected as | +| ---------------- | --------------------------------------- | --------------- | +| `6.5.x`, `6.6.x` | `administration/js/.js` | classic script | +| `6.7+` | `administration/.vite/entrypoints.json` | `type="module"` | -That is why the bundle must stay IIFE: a webpack bundle dies in module scope -(`this` is `undefined`). Packaging is therefore validated separately, by the `zip-artifact` -CI job running `bin/ci-assert-zip-admin-bundle.sh`. Run it locally against any zip before -shipping. Changes under `src/Resources/app/administration/` must stay esbuild-compatible: -no bare (`node_modules`) imports, no `.vue` SFCs, and no Vite-only import suffixes such as -`?raw`. +That is why the bundle must stay IIFE: a webpack bundle dies in module scope (`this` is +`undefined`). Packaging is therefore validated separately, by the `zip-artifact` CI job running +`bin/ci-assert-zip-admin-bundle.sh`. Run it locally against any zip before shipping. Changes under +`src/Resources/app/administration/` must stay esbuild-compatible: no bare (`node_modules`) imports, +no `.vue` SFCs, and no Vite-only import suffixes such as `?raw`. The script handles the important differences: -- `6.5.x` rejects Vite and relaxes local Node engine checks for webpack - validation. -- `6.6.x` can run both paths; the script toggles `ADMIN_VITE` in - `var/config_js_features.json` and restores it afterward. +- `6.5.x` rejects Vite and relaxes local Node engine checks for webpack validation. +- `6.6.x` can run both paths; the script toggles `ADMIN_VITE` in `var/config_js_features.json` and + restores it afterward. - `trunk` rejects webpack and uses Vite. -- Every build must be followed by an admin shell/browser check. A successful - JavaScript build alone is not enough. +- Every build must be followed by an admin shell/browser check. A successful JavaScript build alone + is not enough. ## Administration Compatibility Rules -- Keep UCP under the `shop` settings group on `6.5.x`/`6.6.x` unless the - `commerce` group exists. `trunk` uses `commerce`. -- Legacy `sw-card` and newer Meteor `mt-card` wrappers differ. Layout fixes must - work for both. -- `6.5.x` can still consume legacy static administration assets. If the browser - shows stale labels or layout, check installed assets under - `public/bundles/swagagenticcommerce/` before changing source code. -- Do not copy or sync `var/plugins.json` manually. It is generated per Shopware - lane by `bundle:dump`; if UCP is missing from the admin shell, rerun the lane - admin smoke instead of reusing metadata from another lane. -- Treat `src/Resources/public/` and - `public/bundles/swagagenticcommerce/` as generated admin output. The lane sync - helper ignores these paths and the admin smoke script cleans them before each - build to avoid webpack/Vite cross-lane pollution. +- Keep UCP under the `shop` settings group on `6.5.x`/`6.6.x` unless the `commerce` group exists. + `trunk` uses `commerce`. +- Legacy `sw-card` and newer Meteor `mt-card` wrappers differ. Layout fixes must work for both. +- `6.5.x` can still consume legacy static administration assets. If the browser shows stale labels + or layout, check installed assets under `public/bundles/swagagenticcommerce/` before changing + source code. +- Do not copy or sync `var/plugins.json` manually. It is generated per Shopware lane by + `bundle:dump`; if UCP is missing from the admin shell, rerun the lane admin smoke instead of + reusing metadata from another lane. +- Treat `src/Resources/public/` and `public/bundles/swagagenticcommerce/` as generated admin output. + The lane sync helper ignores these paths and the admin smoke script cleans them before each build + to avoid webpack/Vite cross-lane pollution. - Browser validation is mandatory on each lane after admin UI changes. ## Runtime Compatibility Rules -- Sales-channel UCP config lives in the plugin table - `swag_agentic_commerce_ucp_config`. `SystemConfig` is only a legacy fallback - and read-through backfill path; do not add new UCP settings there. -- Gate sales-channel features through `AbstractSalesChannelTypeResolver`, never - on a denylist of feed types and never on `SalesChannelTypeClassification::forTypeId()` - directly: the enum's built-in map is the resolver's default and an unresolved - type stays `Other`. Unknown types are excluded by construction, so neither - version probing nor another vendor's type id belongs here. Both classes are - `@internal` while the extension is in beta; opening the seam is a deliberate +- Sales-channel UCP config lives in the plugin table `swag_agentic_commerce_ucp_config`. + `SystemConfig` is only a legacy fallback and read-through backfill path; do not add new UCP + settings there. +- Gate sales-channel features through `AbstractSalesChannelTypeResolver`, never on a denylist of + feed types and never on `SalesChannelTypeClassification::forTypeId()` directly: the enum's + built-in map is the resolver's default and an unresolved type stays `Other`. Unknown types are + excluded by construction, so neither version probing nor another vendor's type id belongs here. + Both classes are `@internal` while the extension is in beta; opening the seam is a deliberate later step. -- Keep REST, A2A, embedded, and MCP on the shared SDK operation/capability - layer. Shopware-specific MCP code is limited to the `/ucp/mcp` proxy and Store - API MCP tool registrations. -- Customer-facing runtime flows must use Store API route boundaries wherever - they exist. This is a hard rule for UCP adapters/gateways and especially - catalog, cart, checkout, customer, identity, and order flows. Inject the - relevant Store API route abstraction instead of using DAL repositories, - manually creating customers, or mutating sales-channel context state by hand. - Direct repositories are only acceptable for plugin-owned configuration, - admin/internal metadata, compatibility discovery, or a documented exception - where no Store API route exists. -- MCP write tools must expose object payload schemas (`payload` plus `id` where - needed), not JSON-string payload arguments. -- Embedded pages require configured `embeddedAllowedOrigins`: an unconfigured sales - channel is refused with a controlled `403`, and so is a request whose `Origin` is - present but not allowlisted. An absent `Origin` is not a denial signal -- browsers - omit the header on the iframe and top-level GET navigations the embedded surface is - loaded by -- so such a request proceeds and its preflight receives no - `Access-Control-Allow-Origin` grant. CSP frame ancestors come from +- Keep REST, A2A, embedded, and MCP on the shared SDK operation/capability layer. Shopware-specific + MCP code is limited to the `/ucp/mcp` proxy and Store API MCP tool registrations. +- Customer-facing runtime flows must use Store API route boundaries wherever they exist. This is a + hard rule for UCP adapters/gateways and especially catalog, cart, checkout, customer, identity, + and order flows. Inject the relevant Store API route abstraction instead of using DAL + repositories, manually creating customers, or mutating sales-channel context state by hand. Direct + repositories are only acceptable for plugin-owned configuration, admin/internal metadata, + compatibility discovery, or a documented exception where no Store API route exists. +- MCP write tools must expose object payload schemas (`payload` plus `id` where needed), not + JSON-string payload arguments. +- Embedded pages require configured `embeddedAllowedOrigins`: an unconfigured sales channel is + refused with a controlled `403`, and so is a request whose `Origin` is present but not + allowlisted. An absent `Origin` is not a denial signal -- browsers omit the header on the iframe + and top-level GET navigations the embedded surface is loaded by -- so such a request proceeds and + its preflight receives no `Access-Control-Allow-Origin` grant. CSP frame ancestors come from `embeddedFrameAncestors`. -- Feature-detect Shopware capabilities instead of comparing versions unless a - version check is the only stable signal. -- Keep migrations safe across all supported lanes. Do not assume newer core - tables, constants, entity definitions, services, or administration APIs exist. +- Feature-detect Shopware capabilities instead of comparing versions unless a version check is the + only stable signal. +- Keep migrations safe across all supported lanes. Do not assume newer core tables, constants, + entity definitions, services, or administration APIs exist. ## Boyscouting Scope -- When asked to make a specific cleanup or behavioral change, look for safe - opportunities to apply the same improvement across the touched file. -- If the same pattern appears in nearby files or a broader low-risk scope, - mention that proactively and suggest extending the cleanup. -- When adding or touching unit tests, look for low-hanging missing coverage - paths in the same domain or command surface that can be covered cheaply and - locally. -- Keep the scope aligned with the request: avoid unrelated refactors, but do not - miss obvious consistency fixes that make the codebase simpler. +- When asked to make a specific cleanup or behavioral change, look for safe opportunities to apply + the same improvement across the touched file. +- If the same pattern appears in nearby files or a broader low-risk scope, mention that proactively + and suggest extending the cleanup. +- When adding or touching unit tests, look for low-hanging missing coverage paths in the same domain + or command surface that can be covered cheaply and locally. +- Keep the scope aligned with the request: avoid unrelated refactors, but do not miss obvious + consistency fixes that make the codebase simpler. ## Test Structure -- Write test methods as clear executable examples. Keep scenario-specific data, - action, and assertions visible in the test body. -- Move stable boilerplate such as mock services, the class under test, command - testers, and repeated collaborators into `setUp()`/`tearDown()` when that - lets tests focus on the scenario. -- Prefer PHPUnit mocks/stubs for interfaces. Avoid throwaway anonymous classes - inside test methods unless the concrete behavior is the subject of the test. -- Avoid reflection-uninitialized final services in tests. Construct real - collaborators with mocks or extract a narrower interface where that is already - part of the production design. -- Keep helpers smaller than the code they replace. Helpers may create entities, - files, or value objects, but should not hide meaningful scenario wiring or - assertions. -- Prefer one focused test method per distinct exception or behavior over broad - data providers when each case has its own meaning. -- Use named `yield` cases in data providers. Case names should describe the - behavior being proven, not restate raw input values. -- Do not add `#[CoversClass]`, `#[CoversFunction]`, or `#[CoversNothing]` to - integration tests. -- Do not mock DBAL persistence behavior for adapter confidence. SQL/database - adapters should have integration coverage when persistence behavior matters. +- Write test methods as clear executable examples. Keep scenario-specific data, action, and + assertions visible in the test body. +- Move stable boilerplate such as mock services, the class under test, command testers, and repeated + collaborators into `setUp()`/`tearDown()` when that lets tests focus on the scenario. +- Prefer PHPUnit mocks/stubs for interfaces. Avoid throwaway anonymous classes inside test methods + unless the concrete behavior is the subject of the test. +- Avoid reflection-uninitialized final services in tests. Construct real collaborators with mocks or + extract a narrower interface where that is already part of the production design. +- Keep helpers smaller than the code they replace. Helpers may create entities, files, or value + objects, but should not hide meaningful scenario wiring or assertions. +- Prefer one focused test method per distinct exception or behavior over broad data providers when + each case has its own meaning. +- Use named `yield` cases in data providers. Case names should describe the behavior being proven, + not restate raw input values. +- Do not add `#[CoversClass]`, `#[CoversFunction]`, or `#[CoversNothing]` to integration tests. +- Do not mock DBAL persistence behavior for adapter confidence. SQL/database adapters should have + integration coverage when persistence behavior matters. ## Bug Fix Root Cause And Scope -- Treat fix suggestions from issues as hypotheses, not instructions to follow - blindly. Reason from first principles about the actual failure mode before - choosing an implementation. +- Treat fix suggestions from issues as hypotheses, not instructions to follow blindly. Reason from + first principles about the actual failure mode before choosing an implementation. - Prefer the least invasive fix that correctly addresses the root cause. -- Fix issues at the boundary where the root cause actually lives instead of - spreading compensating changes across unrelated components. -- Match the fix location to the bug scope. A plugin-specific bug belongs in the - plugin, while a Shopware core bug should be fixed upstream instead of worked - around repeatedly here. -- Conversely, keep feature-specific bugs out of broad shared infrastructure when - a general change could negatively affect other plugin behavior. +- Fix issues at the boundary where the root cause actually lives instead of spreading compensating + changes across unrelated components. +- Match the fix location to the bug scope. A plugin-specific bug belongs in the plugin, while a + Shopware core bug should be fixed upstream instead of worked around repeatedly here. +- Conversely, keep feature-specific bugs out of broad shared infrastructure when a general change + could negatively affect other plugin behavior. - Always do a root cause analysis to identify where the real issue lives. ## Code Shape -These are the rules a review keeps rediscovering. They are measurable on purpose: -check the number, do not argue with the feeling. +These are the rules a review keeps rediscovering. They are measurable on purpose: check the number, +do not argue with the feeling. ### Comment budget -`src/` sits at roughly **0.24 comment lines per code line**. A diff well above -that is explaining in the wrong place. Measure before pushing: +`src/` sits at roughly **0.24 comment lines per code line**. A diff well above that is explaining in +the wrong place. Measure before pushing: ```bash git diff -- 'src/*.php' | grep '^+' | grep -v '^+++' | sed 's/^+//' | awk ' @@ -374,90 +334,153 @@ git diff -- 'src/*.php' | grep '^+' | grep -v '^+++' | sed 's/^+//' | awk END {printf "%.2f\n", c/code}' ``` -- A comment earns its place when it stops a reader from **undoing** something: a - constraint not visible locally, a rejected alternative, a rule from upstream. - "Shared on purpose, because the SDK class is final" is worth a line. -- Do not narrate history ("this used to…", "for as long as it has been here"), - restate the code, or re-explain the bug. The commit message is where the story - goes, and git keeps it. -- `@param` and `@return` descriptions are **one line**. A parameter that needs a - paragraph means the contract belongs in `docs/`, with `@see` pointing there. -- Class docblock on a small abstraction: about five lines, then `@see docs/…`. - This repo has a real `docs/` tree — use it instead of growing a header. +- A comment earns its place when it stops a reader from **undoing** something: a constraint not + visible locally, a rejected alternative, a rule from upstream. "Shared on purpose, because the SDK + class is final" is worth a line. +- Do not narrate history ("this used to…", "for as long as it has been here"), restate the code, or + re-explain the bug. The commit message is where the story goes, and git keeps it. +- `@param` and `@return` descriptions are **one line**. A parameter that needs a paragraph means the + contract belongs in `docs/`, with `@see` pointing there. +- Class docblock on a small abstraction: about five lines, then `@see docs/…`. This repo has a real + `docs/` tree — use it instead of growing a header. ### Constructor size -Current worst offenders, all service classes, none of them good: -`CheckoutCompleter` (13), `ShopwareCheckoutAdapter` (13), `ShopwareCartGateway` -(11). `UcpConfig` is a DTO and does not count. +Current worst offenders, all service classes, none of them good: `CheckoutCompleter` (13), +`ShopwareCheckoutAdapter` (13), `ShopwareCartGateway` (11). `UcpConfig` is a DTO and does not count. - **Six collaborators is the ceiling** for a service. At seven, say so in the PR. -- Do not add an argument to a class already over the ceiling without proposing - the split first. "Just one more logger" is how all three got there. -- A cross-cutting dependency (logger, clock, cache pool) landing on several - classes at once is a sign the behaviour wants its own collaborator rather than - a constructor parameter on each. -- Adding a dependency only to make one method testable usually means that method - wants to be its own class — `UcpCheckoutCompletionPayment` exists precisely - because the SDK executor is `final` and the tool could not be mocked. +- Do not add an argument to a class already over the ceiling without proposing the split first. + "Just one more logger" is how all three got there. +- A cross-cutting dependency (logger, clock, cache pool) landing on several classes at once is a + sign the behaviour wants its own collaborator rather than a constructor parameter on each. +- Adding a dependency only to make one method testable usually means that method wants to be its own + class — `UcpCheckoutCompletionPayment` exists precisely because the SDK executor is `final` and + the tool could not be mocked. ### Answering reviews -- A review comment is a hypothesis about the code, not an instruction. Verify it - against the source first, and say so when the premise is wrong. -- Fixing the reported instance is half the job: check whether the same mistake - sits in the sibling nobody reviewed. The per-parent variant bound and the - stale-allowlist bug each had to be fixed twice because the first pass only - touched the copy that was pointed at. -- Answering a reviewer in prose inside the source file is this plugin's main - source of comment bloat. Answer in the PR thread; leave a line in the code only - if a future reader would otherwise revert the change. +- A review comment is a hypothesis about the code, not an instruction. Verify it against the source + first, and say so when the premise is wrong. +- Fixing the reported instance is half the job: check whether the same mistake sits in the sibling + nobody reviewed. The per-parent variant bound and the stale-allowlist bug each had to be fixed + twice because the first pass only touched the copy that was pointed at. +- Answering a reviewer in prose inside the source file is this plugin's main source of comment + bloat. Answer in the PR thread; leave a line in the code only if a future reader would otherwise + revert the change. ## Pull Requests -- Keep PRs focused. Test-only refactors, compatibility fixes, runtime behavior, - and administration UI work should be separate unless the user asks otherwise. -- Use conventional commit style for commit messages and keep PR titles/messages - short. -- Preserve review history when updating an existing PR after feedback: add a - follow-up commit unless the user explicitly asks for an amend or force-push. -- PR descriptions should summarize what changed and why. Do not add validation - sections; CI owns validation reporting. -- Need an install-ready package for a reviewer? Add the `build:zip` label to the - PR. `.github/workflows/package-zip.yml` then builds, validates, and uploads a - `SwagAgenticCommerce.zip` run artifact, and rebuilds it on every push while the - label stays on. It is opt-in on purpose, so do not wire it into the default CI - matrix or the `validation-gate`. See the README `Release` section for details. +- Keep PRs focused. Test-only refactors, compatibility fixes, runtime behavior, and administration + UI work should be separate unless the user asks otherwise. +- Use conventional commit style for commit messages and keep PR titles/messages short. +- Preserve review history when updating an existing PR after feedback: add a follow-up commit unless + the user explicitly asks for an amend or force-push. +- PR descriptions should summarize what changed and why. Do not add validation sections; CI owns + validation reporting. +- Need an install-ready package for a reviewer? Add the `build:zip` label to the PR. + `.github/workflows/package-zip.yml` then builds, validates, installs it on 6.5.x/6.6.x/trunk shops + without the SDK, and uploads a `SwagAgenticCommerce.zip` run artifact, rebuilding on every push + while the label stays on. It is opt-in on purpose, so do not wire it into the default CI matrix or + the `validation-gate`. See [docs/releasing.md](docs/releasing.md) for details. + +## Installation And Update + +How the extension reaches a shop, and what must stay true for it to keep arriving. This cost a +release and a support round once; do not rediscover it. + +**Two routes, one dependency story.** A Composer shop resolves the extension and the UCP SDK through +its own `composer.json`. A store shop uploads a zip, and Shopware then runs +`composer require shopware/agentic-commerce:` against the project itself, because +`executeComposerCommands()` returns true. Every Shopware project declares `custom/plugins/*` as a +path repository, so that require resolves the extracted archive locally and pulls the pinned SDK +from Packagist into the shop's own `vendor/`. The archive therefore ships **no** dependencies — +`bin/ci-assert-zip-no-vendor.sh` fails the build if any appear. + +**Do not bundle the SDK back into the archive.** Shopware never loads a plugin's +`vendor/autoload.php`, so a bundled copy only works if the plugin loads it — which registers a +second Composer `ClassLoader` in the shop, reported by FroshTools as `2 autoloaders registered` and +able to answer version lookups the shop's own registry should own +(FriendsOfShopware/FroshTools#469). 1.3.0 shipped that way. + +**The update window is the hard part.** An update extracts the new files **one request before** +Shopware runs Composer. In that request the extension is already active, and its dependency is +either missing or still the previously pinned version. So: + +- `SdkAvailability` decides whether the shop has the SDK this release names. It compares the + _installed version_ against the constraint in `composer.json`; `class_exists` is not enough, + because a shop holding the previous SDK passes that and then fatals on the first class the new + code needs. +- When the answer is no, the extension contributes **nothing** to the container: `services.php` and + `routes.php` return without registering anything, and `getAdditionalBundles()` returns none. Half + a container is not an option — `services.php` configures the `ucp_sdk` extension and 13 plugin + classes implement SDK interfaces, so compilation dies either way. +- `build()` writes the reason and the command that fixes it to the shop's + `var/log/swag-agentic-commerce.log`. A shop that lands there recovers by itself once the SDK + arrives; `activate()` bootstraps the SDK schema too, so a late recovery is complete. + +Never let a boot path throw when the SDK is absent, and never add SDK-dependent service definitions, +routes, or bundles outside that guard. Both turn a degraded extension back into a shop answering +`500` on every page, storefront included. + +**Symptoms and what they mean.** + +| what you see | what happened | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `Unable to load the UCP SDK Symfony bundle from Composer dependencies` in `registerBundles()` | an active extension booted without the SDK, and something threw instead of degrading | +| `There is no extension able to load the configuration for "ucp_sdk"` from `services.php` | same, one layer deeper: the container was compiled with SDK-dependent config | +| FroshTools reporting `2 autoloaders registered` | a bundled `vendor/` came back into the archive | +| `cannot unmarshal array into Go struct field .autoload.psr-4` | `shopware-cli` reads psr-4 values as a string; a JSON array fails every `extension` command | +| install fails with `MissingRequirementException` for `ucp-php-sdk/*` | `executeComposerCommands()` returned false without the archive carrying the SDK | + +**The platform intends to remove this window.** shopware/shopware#13630 proposes separating +`composer require`/`remove` from the plugin lifecycle and running it into a separate vendor +directory that is swapped in at the end — and names our exact case, "the flag can be set or unset +from one version to the other", as needing special handling. shopware/shopware#13631 goes further: +require store plugins from the SBP registry instead of downloading zips. Neither lets the guard here +go: this extension supports 6.5.8 upwards, so shops without those changes stay in scope for years. +Read them before redesigning any of this, not after. + +**Before changing any of this, prove it on a lane.** `bin/test-zip-install.sh` installs a built +archive through the admin upload endpoint on a shop stripped of the extension and the SDK, and +refuses to run if the shop can already resolve the SDK. The `zip-install` job in `package-zip.yml` +runs it on 6.5.x, 6.6.x and trunk, and it also takes the SDK away from the installed extension again +to prove the shop stays up. + +Two scenarios are deliberately **not** in CI: an update from a version that bundled the SDK, and an +update where the pinned SDK version moves. Both need a second archive in the job, and the +bundled-SDK upgrade happens exactly once, on a small installed base. Both were verified by hand for +1.4.0, and the README _Troubleshooting_ section tells a merchant what to expect. Do not reopen this +without a reason that has changed. ## Releases -Store releases run from `main` HEAD via `.github/workflows/store-release.yml` after -that commit has a green `validation-gate`. Bump `composer.json` `version` and both -changelogs (`# `) in the release PR. See the -README `Release` section for the full flow. Two recurring pitfalls have their own -subsections there — read them before the change, not after CI is green: - -- **SDK version pin.** `ucp-php-sdk/symfony-bundle` is required at the exact version it - was tested against, currently `0.0.7` — **not** a caret (a caret on `0.0.x` already means - that exact patch; the plugin's original `^0.0.2` never resolved `0.0.3`), **not** a tilde - (`~0.0.6` is the open `>=0.0.6 <0.1.0` range) and **not** a `>=a :` that no release tag contains it. +Store releases run from `main` HEAD via `.github/workflows/store-release.yml` after that commit has +a green `validation-gate`. Bump `composer.json` `version` and both changelogs (`# `) in the +release PR. See [docs/releasing.md](docs/releasing.md) for the full flow. Two recurring pitfalls +have their own subsections there — read them before the change, not after CI is green: + +- **SDK version pin.** `ucp-php-sdk/symfony-bundle` is required at the exact version it was tested + against, currently `0.0.7` — **not** a caret (a caret on `0.0.x` already means that exact patch; + the plugin's original `^0.0.2` never resolved `0.0.3`), **not** a tilde (`~0.0.6` is the open + `>=0.0.6 <0.1.0` range) and **not** a `>=a :` that no release tag + contains it. ## Further References diff --git a/CHANGELOG.md b/CHANGELOG.md index a047126d..954da88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # next version - Require Shopware `6.5.8` or newer. `6.5.0.0` through `6.5.7.4` were listed as compatible but could never install: those versions ship Symfony 6.3, while both the extension's own routes and the UCP SDK need Symfony 6.4. Installation therefore ended in a Composer error about `symfony/config` that a merchant cannot act on. Such a shop now sees the extension as incompatible; updating to `6.5.8.x` fixes that and stays inside the same minor. +- Let Shopware install the UCP SDK instead of shipping it inside the archive. 1.3.0 put the SDK in the plugin's own `vendor/` and loaded the autoloader Composer had generated for it, because Shopware does not load a plugin's `vendor/autoload.php` by itself. Every shop that installed it then carried two separate package registries: FroshTools reported `2 autoloaders registered`, and a question as ordinary as which version of a package is installed could be answered from the plugin's copy instead of the shop's. The extension now carries no dependencies at all. It has Shopware run `composer require` on install and update instead, which is what that mechanism is there for on a zip-installed extension: the extension itself resolves from the `custom/plugins/*` path repository every Shopware project declares, and the SDK version it names comes from Packagist into the shop's own `vendor/`. Nothing changes for a shop that installs through Composer. What is new is that installing has to reach Packagist -- without it the install stops with a Composer error, rather than leaving an extension that cannot run. +- Switch the extension off instead of taking the shop down when the SDK is missing. An update extracts the new files one request before Shopware runs Composer, so an active extension boots at least once without the dependency it needs. On 1.3.0 every page answered `500` from that moment on, the storefront included, until someone installed the SDK by hand. The extension now registers no services, routes or feeds in that state, and writes to `var/log/swag-agentic-commerce.log` what is missing and the command that fixes it. Once the SDK is there it picks up again on its own. The check asks not only whether an SDK is present but whether it is the version this release names, so a shop still holding the previous one waits instead of running new code against an old SDK. The README has a _Troubleshooting_ section covering what that state looks like, how to leave it, and what to expect when updating from 1.3.0 or older. A cluster setup is the exception: Shopware never runs Composer for a plugin there, so nothing would ever install the requirements, and the extension refuses the install rather than reporting success and then doing nothing. # 1.3.0 diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index e3bfe855..9b033316 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,6 +1,8 @@ # next version - Die Erweiterung setzt jetzt Shopware `6.5.8` oder neuer voraus. `6.5.0.0` bis `6.5.7.4` wurden als kompatibel angezeigt, ließen sich aber nie installieren: Diese Versionen enthalten Symfony 6.3, während sowohl die Routen der Erweiterung als auch das UCP-SDK Symfony 6.4 benötigen. Die Installation brach deshalb mit einer Composer-Fehlermeldung zu `symfony/config` ab, mit der ein Händler nichts anfangen kann. In betroffenen Shops wird die Erweiterung jetzt als nicht kompatibel angezeigt; ein Update auf `6.5.8.x` behebt das und bleibt innerhalb derselben Minor-Version. +- Das UCP SDK wird nicht mehr mitgeliefert, sondern von Shopware installiert. 1.3.0 legte es in das plugin-eigene `vendor/` und lud den Autoloader, den Composer dafür erzeugt hatte -- denn das `vendor/autoload.php` einer Erweiterung lädt Shopware nicht von sich aus. Damit führte jeder Shop zwei getrennte Paketverzeichnisse: FroshTools meldete `2 autoloaders registered`, und die Frage, welche Version eines Pakets installiert ist, konnte aus der Kopie der Erweiterung beantwortet werden statt aus der des Shops. Die Erweiterung bringt jetzt keine Abhängigkeiten mehr mit, sondern lässt Shopware bei Installation und Update `composer require` ausführen -- dafür ist dieser Weg bei Erweiterungen aus einer ZIP-Datei gedacht. Die Erweiterung selbst wird dabei über das Pfad-Repository `custom/plugins/*` aufgelöst, das jedes Shopware-Projekt mitbringt, das festgelegte SDK kommt von Packagist in das `vendor/` des Shops. Für Shops, die über Composer installieren, ändert sich nichts. Neu ist, dass die Installation Packagist erreichen muss: Ohne Internetzugang bricht sie mit einem Composer-Fehler ab, statt eine unvollständige Erweiterung zu hinterlassen. +- Fehlt das SDK, schaltet sich die Erweiterung ab, statt den Shop lahmzulegen. Ein Update entpackt die neuen Dateien, bevor Shopware Composer ausführt -- eine aktive Erweiterung startet also mindestens einmal ohne die Abhängigkeit, die sie braucht. In 1.3.0 antwortete daraufhin jede Seite mit `500`, die Storefront eingeschlossen, bis jemand das SDK von Hand nachinstallierte. Jetzt registriert die Erweiterung in diesem Zustand weder Services noch Routen oder Feeds und schreibt nach `var/log/swag-agentic-commerce.log`, was fehlt und mit welchem Befehl es sich beheben lässt. Sobald das SDK da ist, arbeitet sie ohne weiteres Zutun normal weiter. Geprüft wird dabei nicht nur, ob überhaupt ein SDK vorhanden ist, sondern ob es die Version ist, die diese Erweiterung vorgibt: Ein Shop, der noch die vorherige Version hat, wartet lieber ab, als neuen Code mit einem alten SDK auszuführen. Die README beschreibt unter _Troubleshooting_, woran man diesen Zustand erkennt, wie man ihn verlässt und was beim Update von 1.3.0 oder älter zu erwarten ist. Cluster-Setups sind die Ausnahme: Dort führt Shopware für ein Plugin grundsätzlich kein Composer aus, die Anforderungen würden also nie installiert. Deshalb verweigert die Erweiterung dort die Installation, statt Erfolg zu melden und dann nichts zu tun. # 1.3.0 diff --git a/README.md b/README.md index 32e3626c..1b325bce 100644 --- a/README.md +++ b/README.md @@ -2,43 +2,68 @@ Shopware plugin repository for Agentic Commerce features. -This repository is intentionally scoped to commerce-facing agent integrations. It is not a generic AI playground, chat assistant, or experimentation bucket. Code added here must help external agents discover, understand, or transact with a Shopware storefront in a controlled merchant-owned way. +This repository is intentionally scoped to commerce-facing agent integrations. It is not a generic +AI playground, chat assistant, or experimentation bucket. Code added here must help external agents +discover, understand, or transact with a Shopware storefront in a controlled merchant-owned way. ## Feature Areas The plugin groups three related but separate Agentic Commerce surfaces: -| Feature | Purpose | Primary audience | Status | -| --- | --- | --- | --- | -| Universal Commerce Protocol (UCP) | Transactional protocol surface for catalog, cart, checkout, order, identity, and payment-capability flows. | Agent platforms, protocol clients, merchants configuring sales-channel exposure. | Implemented in this plugin with SDK integration and lane-aware capability exposure. | -| Native agentic discovery | Storefront discovery documents that explain how agents should interact with a shop before transactional calls happen. | Crawlers, LLM shopping agents, custom agent clients, merchants defining operating rules. | Implemented through the core bridge on lanes with sales-channel file support and through plugin fallback routes on older lanes. | -| Product feed | Outbound product feed surface for agentic/catalog consumers. | Feed consumers, marketplaces, AI catalog ingestion, merchants managing feed availability. | Implemented with Shopware product exports for OpenAI JSONL and Google Shopping XML feeds. | +| Feature | Purpose | Primary audience | Status | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Universal Commerce Protocol (UCP) | Transactional protocol surface for catalog, cart, checkout, order, identity, and payment-capability flows. | Agent platforms, protocol clients, merchants configuring sales-channel exposure. | Implemented in this plugin with SDK integration and lane-aware capability exposure. | +| Native agentic discovery | Storefront discovery documents that explain how agents should interact with a shop before transactional calls happen. | Crawlers, LLM shopping agents, custom agent clients, merchants defining operating rules. | Implemented through the core bridge on lanes with sales-channel file support and through plugin fallback routes on older lanes. | +| Product feed | Outbound product feed surface for agentic/catalog consumers. | Feed consumers, marketplaces, AI catalog ingestion, merchants managing feed availability. | Implemented with Shopware product exports for OpenAI JSONL and Google Shopping XML feeds. | -These features should share sales-channel awareness, admin UX patterns, compatibility handling, and test infrastructure where possible. They should not duplicate Store API or UCP gateway logic just because they expose different agent-facing entry points. +These features should share sales-channel awareness, admin UX patterns, compatibility handling, and +test infrastructure where possible. They should not duplicate Store API or UCP gateway logic just +because they expose different agent-facing entry points. ## Where Changes Belong -Keep protocol and Shopware responsibilities separated. This plugin directly requires the `ucp-php-sdk/symfony-bundle` Composer package, which in turn requires SDK core. The plugin should not copy SDK protocol behavior or push Shopware-specific decisions into the SDK. - -| Layer | Owns | Should not own | -| --- | --- | --- | -| `ucp-php-sdk` | Protocol models, transport controllers, profile building, capability contracts, payment handler contracts, request/response envelopes, shared exception mapping, signing/idempotency/replay/profile-cache abstractions, A2A/MCP/embedded transport shaping. | Shopware repositories, Store API calls, storefront rendering details, sales-channel admin UX, Shopware version detection. | -| `SwagAgenticCommerce` plugin | Shopware adapters/gateways, sales-channel scoped config, Administration UX, lane-aware feature exposure, storefront embedded rendering, native discovery contributions, product-feed integration, demo/QA scripts. | New protocol semantics, duplicated transport controllers, generic SDK storage contracts, protocol error formats. | -| `shopware/shopware` core | Shared platform primitives that benefit more than this plugin, for example a generic Store API MCP endpoint or native agentic discovery infrastructure. | Plugin-only UCP admin behavior, PSP-specific tokenization handlers, local QA/demo shortcuts. | - -Default rule: if multiple merchants/frameworks could reuse it, start in the SDK. If it depends on Shopware runtime state, sales channels, Store API, Administration, or storefront rendering, finish it in the plugin. Touch core only when the primitive is generally useful outside this plugin line. - -Customer-facing runtime flows must enter Shopware through Store API boundaries wherever such a boundary exists. This is a hard architecture rule for UCP adapters, gateways, and shopping flows such as catalog, cart, checkout, customer, identity, and order reads. Prefer injecting the relevant Store API route abstraction, for example `Abstract*Route`, so Shopware decorators, sales-channel visibility, validation, customer ownership checks, context-token handling, and route events stay in effect. Do not implement buyer-facing behavior with direct DAL repository reads/writes, manual customer creation, or hand-rolled context mutation. Repository access is acceptable for plugin-owned configuration, admin/runtime metadata, compatibility discovery, or a documented exception where no Store API route exists. - -The SDK is a required runtime dependency for this plugin line. Shopware installs the public Packagist packages through plugin Composer commands, so `SwagAgenticCommerce::executeComposerCommands()` stays enabled. If a future release should boot with UCP disabled when the SDK is missing, implement that as an explicit conditional service-loading mode in the plugin. Do not only suppress `getAdditionalBundles()` errors; the plugin service graph contains SDK interfaces and transport contracts. +Keep protocol and Shopware responsibilities separated. This plugin directly requires the +`ucp-php-sdk/symfony-bundle` Composer package, which in turn requires SDK core. The plugin should +not copy SDK protocol behavior or push Shopware-specific decisions into the SDK. + +| Layer | Owns | Should not own | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `ucp-php-sdk` | Protocol models, transport controllers, profile building, capability contracts, payment handler contracts, request/response envelopes, shared exception mapping, signing/idempotency/replay/profile-cache abstractions, A2A/MCP/embedded transport shaping. | Shopware repositories, Store API calls, storefront rendering details, sales-channel admin UX, Shopware version detection. | +| `SwagAgenticCommerce` plugin | Shopware adapters/gateways, sales-channel scoped config, Administration UX, lane-aware feature exposure, storefront embedded rendering, native discovery contributions, product-feed integration, demo/QA scripts. | New protocol semantics, duplicated transport controllers, generic SDK storage contracts, protocol error formats. | +| `shopware/shopware` core | Shared platform primitives that benefit more than this plugin, for example a generic Store API MCP endpoint or native agentic discovery infrastructure. | Plugin-only UCP admin behavior, PSP-specific tokenization handlers, local QA/demo shortcuts. | + +Default rule: if multiple merchants/frameworks could reuse it, start in the SDK. If it depends on +Shopware runtime state, sales channels, Store API, Administration, or storefront rendering, finish +it in the plugin. Touch core only when the primitive is generally useful outside this plugin line. + +Customer-facing runtime flows must enter Shopware through Store API boundaries wherever such a +boundary exists. This is a hard architecture rule for UCP adapters, gateways, and shopping flows +such as catalog, cart, checkout, customer, identity, and order reads. Prefer injecting the relevant +Store API route abstraction, for example `Abstract*Route`, so Shopware decorators, sales-channel +visibility, validation, customer ownership checks, context-token handling, and route events stay in +effect. Do not implement buyer-facing behavior with direct DAL repository reads/writes, manual +customer creation, or hand-rolled context mutation. Repository access is acceptable for plugin-owned +configuration, admin/runtime metadata, compatibility discovery, or a documented exception where no +Store API route exists. + +The SDK is a required runtime dependency for this plugin line. Shopware installs the public +Packagist packages through plugin Composer commands, so +`SwagAgenticCommerce::executeComposerCommands()` stays enabled. If a future release should boot with +UCP disabled when the SDK is missing, implement that as an explicit conditional service-loading mode +in the plugin. Do not only suppress `getAdditionalBundles()` errors; the plugin service graph +contains SDK interfaces and transport contracts. ## UCP -UCP provides the transaction contract for agentic shopping. The plugin exposes lane-aware UCP configuration in the Administration and wires Shopware catalog/cart/checkout/order behavior through the `ucp-php-sdk`. +UCP provides the transaction contract for agentic shopping. The plugin exposes lane-aware UCP +configuration in the Administration and wires Shopware catalog/cart/checkout/order behavior through +the `ucp-php-sdk`. ### Set up UCP on a sales channel -The whole path from "plugin activated" to "first UCP request answered", for one sales channel. You need a Storefront or Headless sales channel with at least one domain; every other channel type is refused. +The whole path from "plugin activated" to "first UCP request answered", for one sales channel. You +need a Storefront or Headless sales channel with at least one domain; every other channel type is +refused. 1. **Configure the channel in one step.** @@ -50,9 +75,17 @@ The whole path from "plugin activated" to "first UCP request answered", for one bin/console ucp:setup --sales-channel=Storefront --agent-host=agent.example.com ``` - `ucp:setup` switches UCP on for the channel, writes the security defaults, generates a signing key if the channel has none, runs the readiness checks and prints the profile URL. `--dev` accepts unsigned requests (policy `log`) and puts the channel's own domain hosts and `localhost` on the allowlists, so the shop can act as its own agent. Without `--dev` the policy is `strict` and nothing is allowed until you name a platform host. `--dry-run` shows the resulting config without writing it. + `ucp:setup` switches UCP on for the channel, writes the security defaults, generates a signing + key if the channel has none, runs the readiness checks and prints the profile URL. `--dev` + accepts unsigned requests (policy `log`) and puts the channel's own domain hosts and `localhost` + on the allowlists, so the shop can act as its own agent. Without `--dev` the policy is `strict` + and nothing is allowed until you name a platform host. `--dry-run` shows the resulting config + without writing it. -2. **Locally only: turn on the SDK's development mode.** Every UCP request names the *calling agent's* profile URL, and the SDK's URL-safety rules refuse everything a laptop can offer (`*.localhost` names and container hostnames resolve to loopback). In development mode the shop accepts its own `/.well-known/ucp` as that profile instead, so no second server is needed. +2. **Locally only: turn on the SDK's development mode.** Every UCP request names the _calling + agent's_ profile URL, and the SDK's URL-safety rules refuse everything a laptop can offer + (`*.localhost` names and container hostnames resolve to loopback). In development mode the shop + accepts its own `/.well-known/ucp` as that profile instead, so no second server is needed. ```bash export SWAG_AGENTIC_COMMERCE_UCP_PROFILE_FETCHING_DEVELOPMENT_MODE=1 @@ -61,52 +94,77 @@ The whole path from "plugin activated" to "first UCP request answered", for one Never set this in production: it also admits plain-http and loopback profile hosts. -3. **Make the first request.** The SDK bundle prints it for you, headers and a minimal body included: +3. **Make the first request.** The SDK bundle prints it for you, headers and a minimal body + included: ```bash bin/console ucp:dev:request catalog.search --base-uri=http://shop.localhost:8088 ``` - Paste the printed `curl`. Expect `200` with `"status": "success"` in the `ucp` envelope. Without an argument the command lists every operation; `--id` fills in product and resource ids. + Paste the printed `curl`. Expect `200` with `"status": "success"` in the `ucp` envelope. Without + an argument the command lists every operation; `--id` fills in product and resource ids. -4. **Change and re-check.** Exposure (active, profile domain, capabilities, transports) lives in the Administration under the sales channel; allowlists and policy in `ucp:config:set`; `bin/console ucp:config:validate --sales-channel=Storefront` re-runs the readiness checks any time. +4. **Change and re-check.** Exposure (active, profile domain, capabilities, transports) lives in the + Administration under the sales channel; allowlists and policy in `ucp:config:set`; + `bin/console ucp:config:validate --sales-channel=Storefront` re-runs the readiness checks any + time. -What the shortcut in step 2 does and does not prove, and what to do when you need a real second profile (strict signatures, the conformance agent, a staging platform), is in the SDK's `docs/local-testing.md`. Which UCP version the plugin serves and what an SDK bump means for a shop is in [docs/ucp-version-support.md](docs/ucp-version-support.md). +What the shortcut in step 2 does and does not prove, and what to do when you need a real second +profile (strict signatures, the conformance agent, a staging platform), is in the SDK's +`docs/local-testing.md`. Which UCP version the plugin serves and what an SDK bump means for a shop +is in [docs/ucp-version-support.md](docs/ucp-version-support.md). Current responsibilities: -- Configure UCP per sales channel — Storefront and Headless only. Every other type, product feed channels included, is refused: the card is hidden, the Admin API and `ucp:config:set` return an error, and a stale `active` flag reads as off. -- Store sales-channel UCP config in the plugin-owned `swag_agentic_commerce_ucp_config` table. Legacy `SystemConfig` values are read only as a compatibility fallback and backfilled into the table when found. -- Publish `/.well-known/ucp` with only capabilities and transports that are usable on the current Shopware line. -- Expose REST, A2A (`/.well-known/agent-card.json`, `/ucp/a2a`), embedded (`/ucp/embedded/*`), and trunk/6.7 MCP (`/ucp/mcp`) flows through shared capability adapters. -- Implement customer-facing adapter/gateway behavior through Store API routes, not direct repositories, so UCP follows the same sales-channel and customer-context rules as storefront clients. -- Keep signing keys, OAuth identity linking, payment tokenization, platform-profile cache, and allowlists scoped to sales-channel behavior. -- Require explicit embedded origin and frame-ancestor configuration before embedded pages render cross-origin; disallowed or missing origins return controlled UCP errors. +- Configure UCP per sales channel — Storefront and Headless only. Every other type, product feed + channels included, is refused: the card is hidden, the Admin API and `ucp:config:set` return an + error, and a stale `active` flag reads as off. +- Store sales-channel UCP config in the plugin-owned `swag_agentic_commerce_ucp_config` table. + Legacy `SystemConfig` values are read only as a compatibility fallback and backfilled into the + table when found. +- Publish `/.well-known/ucp` with only capabilities and transports that are usable on the current + Shopware line. +- Expose REST, A2A (`/.well-known/agent-card.json`, `/ucp/a2a`), embedded (`/ucp/embedded/*`), and + trunk/6.7 MCP (`/ucp/mcp`) flows through shared capability adapters. +- Implement customer-facing adapter/gateway behavior through Store API routes, not direct + repositories, so UCP follows the same sales-channel and customer-context rules as storefront + clients. +- Keep signing keys, OAuth identity linking, payment tokenization, platform-profile cache, and + allowlists scoped to sales-channel behavior. +- Require explicit embedded origin and frame-ancestor configuration before embedded pages render + cross-origin; disallowed or missing origins return controlled UCP errors. - Hide unsupported capabilities instead of advertising placeholders. ### Console commands -UCP is administered from the CLI for everything except the per-channel Exposure settings (active, profile domain, capabilities, transports), which live in the Administration. Every command takes `--sales-channel` (id **or** name; omit it to pick interactively). Run `bin/console ucp:channels` first to see channel ids and which channels currently expose UCP. +UCP is administered from the CLI for everything except the per-channel Exposure settings (active, +profile domain, capabilities, transports), which live in the Administration. Every command takes +`--sales-channel` (id **or** name; omit it to pick interactively). Run `bin/console ucp:channels` +first to see channel ids and which channels currently expose UCP. -| Command | Purpose | -| --- | --- | -| `ucp:setup --sales-channel=… [--dev] [--agent-host=…] [--dry-run]` | Configure a channel for UCP in one step: exposure, security defaults, signing key, readiness check, first request. See *Set up UCP on a sales channel* above. | -| `ucp:channels` | List the UCP-capable sales channels, their ids and UCP exposure (`exposed` / `off`). | -| `ucp:config:show --sales-channel=…` | Print the resolved UCP config for a channel. | -| `ucp:config:set --sales-channel=… …` | Set the non-UI config fields (below). Only the options you pass change; the rest is preserved by a merge, so admin-managed Exposure fields are never reset. | -| `ucp:dev:request [operation] [--id=…] --base-uri=…` | SDK command. Print a ready-to-run `curl` for a UCP operation with the shop's own profile as the agent (needs the development mode from step 2 above). | -| `ucp:signing-keys:{generate,list,show-public,retire,delete} --sales-channel=…` | Manage a channel's signing keys — thin subclasses of the SDK commands that map `--sales-channel` to the SDK tenant. | +| Command | Purpose | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ucp:setup --sales-channel=… [--dev] [--agent-host=…] [--dry-run]` | Configure a channel for UCP in one step: exposure, security defaults, signing key, readiness check, first request. See _Set up UCP on a sales channel_ above. | +| `ucp:channels` | List the UCP-capable sales channels, their ids and UCP exposure (`exposed` / `off`). | +| `ucp:config:show --sales-channel=…` | Print the resolved UCP config for a channel. | +| `ucp:config:set --sales-channel=… …` | Set the non-UI config fields (below). Only the options you pass change; the rest is preserved by a merge, so admin-managed Exposure fields are never reset. | +| `ucp:dev:request [operation] [--id=…] --base-uri=…` | SDK command. Print a ready-to-run `curl` for a UCP operation with the shop's own profile as the agent (needs the development mode from step 2 above). | +| `ucp:signing-keys:{generate,list,show-public,retire,delete} --sales-channel=…` | Manage a channel's signing keys — thin subclasses of the SDK commands that map `--sales-channel` to the SDK tenant. | `ucp:config:set` fields (run it with `--help` for per-option examples): - `--signature-policy=strict|log|off` - `--idempotency=true|false` -- `--agent-allowlist`, `--remote-profile-allowlist`, `--platform-allowlist` — bare hosts, no scheme (repeatable) +- `--agent-allowlist`, `--remote-profile-allowlist`, `--platform-allowlist` — bare hosts, no scheme + (repeatable) - `--embedded-allowed-origins`, `--embedded-frame-ancestors` — origins, scheme + host (repeatable) -- `--webhook-url-override` — absolute https URL whose host is in an allowlist (pass an empty value to clear) -- `--continue-url-template` — absolute URL supporting `{checkoutId}`, `{cartId}`, `{salesChannelId}` (pass an empty value to clear) +- `--webhook-url-override` — absolute https URL whose host is in an allowlist (pass an empty value + to clear) +- `--continue-url-template` — absolute URL supporting `{checkoutId}`, `{cartId}`, `{salesChannelId}` + (pass an empty value to clear) -Options are omit-to-leave-unchanged; passing none is an error rather than a silent no-op. Example — allow an embedded checkout to be framed by ChatGPT and require idempotency: +Options are omit-to-leave-unchanged; passing none is an error rather than a silent no-op. Example — +allow an embedded checkout to be framed by ChatGPT and require idempotency: ```bash bin/console ucp:config:set --sales-channel=Storefront \ @@ -115,250 +173,197 @@ bin/console ucp:config:set --sales-channel=Storefront \ --idempotency=true ``` -The SDK bundle also ships storage-maintenance commands (not sales-channel scoped): `ucp:storage:cleanup` purges all expired SDK records (OAuth state, idempotency, negotiation sessions, profile cache, replay nonces, retired keys) using the configured retention windows, while `ucp:storage:cleanup-signature-nonces` purges only replay nonces with a tunable `--older-than-seconds`. Schedule the former periodically; reach for the latter only to prune nonces on a tighter cadence. +The SDK bundle also ships storage-maintenance commands (not sales-channel scoped): +`ucp:storage:cleanup` purges all expired SDK records (OAuth state, idempotency, negotiation +sessions, profile cache, replay nonces, retired keys) using the configured retention windows, while +`ucp:storage:cleanup-signature-nonces` purges only replay nonces with a tunable +`--older-than-seconds`. Schedule the former periodically; reach for the latter only to prune nonces +on a tighter cadence. ### Compatibility and transport security -The plugin supports Shopware `6.5.8` and newer, `6.6.x`, and trunk/current `6.7+` from one codebase. `6.5.0.0` through `6.5.7.4` are not supported: they ship Symfony 6.3, while both the plugin's own controllers and the UCP SDK need the routing attribute and the `symfony/config` version that arrived in Symfony 6.4. A shop on one of those takes the in-line update to `6.5.8.x`, which stays inside the same minor. Capability exposure is feature-detected at runtime: unsupported transports are removed from the UCP profile instead of returning dead links. MCP is only advertised when the current lane has the required Store API MCP infrastructure; REST, A2A, and embedded routes stay on the shared SDK capability layer. - -Every UCP sales channel has its own tenant configuration. The default setup is intentionally closed: profile exposure must be enabled for the channel, remote platform/profile hosts must be allowlisted where configured, and embedded pages require both `embeddedAllowedOrigins` and `embeddedFrameAncestors`. Until both are configured, every embedded request returns a controlled `403` UCP response. - -Once configured, the embedded surface is protected by three distinct mechanisms — do not mistake any one of them for the others: - -- **Framing** is restricted by `embeddedFrameAncestors`, emitted as `Content-Security-Policy: frame-ancestors ...` (and `X-Frame-Options` is removed so the CSP is authoritative). This is what stops a non-allowlisted page from embedding the surface, and it is enforced by the browser. -- **Cross-origin reads** are restricted by `embeddedAllowedOrigins`, emitted as `Access-Control-Allow-Origin`. A request carrying an `Origin` header that is not on the allowlist is rejected with `403`. Note that browsers omit `Origin` on iframe and top-level `GET` navigations, so an *absent* `Origin` is deliberately not treated as a denial signal — rejecting it would break every real browser load of the embedded page. `embeddedAllowedOrigins` is a CORS control, not a server-side authorization gate, and cannot constrain a non-browser client. -- **Authorization for the payload** is possession of the cart or checkout token in the URL. That path segment is the Shopware sales-channel context token; anyone holding it can already read and write that cart through the REST and A2A transports. **Treat the embedded URL as a secret.** Embedded responses are sent with `Cache-Control: no-store, private`, `Referrer-Policy: no-referrer`, and `X-Robots-Tag: noindex, nofollow` so the token does not leak into shared caches, search indexes, or `Referer` headers, and they vary by `Origin`. - -Signed-request handling, idempotency, replay nonce storage, profile cache storage, OAuth state, and retired signing keys are owned by the SDK bundle. The plugin maps those contracts to Shopware sales channels and keeps buyer-facing work inside Store API route boundaries. Run `bin/validate-ucp-store.sh conformance` against a live lane for signed-request conformance when transport behavior changes. +The plugin supports Shopware `6.5.8` and newer, `6.6.x`, and trunk/current `6.7+` from one codebase. +`6.5.0.0` through `6.5.7.4` are not supported: they ship Symfony 6.3, while both the plugin's own +controllers and the UCP SDK need the routing attribute and the `symfony/config` version that arrived +in Symfony 6.4. A shop on one of those takes the in-line update to `6.5.8.x`, which stays inside the +same minor. Capability exposure is feature-detected at runtime: unsupported transports are removed +from the UCP profile instead of returning dead links. MCP is only advertised when the current lane +has the required Store API MCP infrastructure; REST, A2A, and embedded routes stay on the shared SDK +capability layer. + +Every UCP sales channel has its own tenant configuration. The default setup is intentionally closed: +profile exposure must be enabled for the channel, remote platform/profile hosts must be allowlisted +where configured, and embedded pages require both `embeddedAllowedOrigins` and +`embeddedFrameAncestors`. Until both are configured, every embedded request returns a controlled +`403` UCP response. + +Once configured, the embedded surface is protected by three distinct mechanisms — do not mistake any +one of them for the others: + +- **Framing** is restricted by `embeddedFrameAncestors`, emitted as + `Content-Security-Policy: frame-ancestors ...` (and `X-Frame-Options` is removed so the CSP is + authoritative). This is what stops a non-allowlisted page from embedding the surface, and it is + enforced by the browser. +- **Cross-origin reads** are restricted by `embeddedAllowedOrigins`, emitted as + `Access-Control-Allow-Origin`. A request carrying an `Origin` header that is not on the allowlist + is rejected with `403`. Note that browsers omit `Origin` on iframe and top-level `GET` + navigations, so an _absent_ `Origin` is deliberately not treated as a denial signal — rejecting it + would break every real browser load of the embedded page. `embeddedAllowedOrigins` is a CORS + control, not a server-side authorization gate, and cannot constrain a non-browser client. +- **Authorization for the payload** is possession of the cart or checkout token in the URL. That + path segment is the Shopware sales-channel context token; anyone holding it can already read and + write that cart through the REST and A2A transports. **Treat the embedded URL as a secret.** + Embedded responses are sent with `Cache-Control: no-store, private`, + `Referrer-Policy: no-referrer`, and `X-Robots-Tag: noindex, nofollow` so the token does not leak + into shared caches, search indexes, or `Referer` headers, and they vary by `Origin`. + +Signed-request handling, idempotency, replay nonce storage, profile cache storage, OAuth state, and +retired signing keys are owned by the SDK bundle. The plugin maps those contracts to Shopware sales +channels and keeps buyer-facing work inside Store API route boundaries. Run +`bin/validate-ucp-store.sh conformance` against a live lane for signed-request +conformance when transport behavior changes. ## Native Agentic Discovery -Native agentic discovery is the storefront-facing operating manual for agents. It complements UCP: discovery tells an agent how the merchant wants the shop to be used, while UCP tells the agent which transactional capabilities are technically available. +Native agentic discovery is the storefront-facing operating manual for agents. It complements UCP: +discovery tells an agent how the merchant wants the shop to be used, while UCP tells the agent which +transactional capabilities are technically available. -The core reference in [shopware/shopware#17033](https://github.com/shopware/shopware/pull/17033) introduces the sales-channel file primitives used by trunk/current `6.7+`. +The core reference in [shopware/shopware#17033](https://github.com/shopware/shopware/pull/17033) +introduces the sales-channel file primitives used by trunk/current `6.7+`. - `/agents.md` - `/llms.txt` - `/.well-known/ai-catalog.json` -On lanes with `SalesChannelFileDiscovery`, `SalesChannelFileRenderer`, and the `sales_channel_file` table, the plugin enables the `agentic` file family for active UCP sales channels and lets core render the files. On older lanes, the fallback bundle provides storefront routes for the same files. Fallback discovery is only rendered when UCP is active for the current sales channel; inactive channels receive `404`. +On lanes with `SalesChannelFileDiscovery`, `SalesChannelFileRenderer`, and the `sales_channel_file` +table, the plugin enables the `agentic` file family for active UCP sales channels and lets core +render the files. On older lanes, the fallback bundle provides storefront routes for the same files. +Fallback discovery is only rendered when UCP is active for the current sales channel; inactive +channels receive `404`. -The generated documents are sales-channel scoped. They point agents at the UCP profile when UCP is active and describe safe shopping behavior for public storefront resources. Validate discovery after admin or route changes with a storefront browser check and direct `GET` requests for `/llms.txt`, `/agents.md`, and `/.well-known/ai-catalog.json` on each supported lane. +The generated documents are sales-channel scoped. They point agents at the UCP profile when UCP is +active and describe safe shopping behavior for public storefront resources. Validate discovery after +admin or route changes with a storefront browser check and direct `GET` requests for `/llms.txt`, +`/agents.md`, and `/.well-known/ai-catalog.json` on each supported lane. ## Product Feed -The product-feed feature is the outbound catalog surface for agentic commerce consumers. It is separate from UCP catalog operations: UCP handles transactional runtime calls, while product feeds support catalog ingestion, indexing, and marketplace-style discovery. - -Product feeds reuse Shopware's product export infrastructure on Agentic Commerce sales channels. The Administration registers two templates: - -| Provider | Format | File format | Template source | -| --- | --- | --- | --- | -| `open-ai` | OpenAI product feed | JSONL, one JSON object per valid product row | `agentic-product-export-templates/open-ai/body.json.twig.js` | -| `google` | Google Merchant Center feed | XML RSS item feed | `agentic-product-export-templates/google/*.xml.twig.js` | - -The templates are Twig, but they live in `.js` modules that export the template as a -string. They must reach Shopware byte-exact — the OpenAI feed is JSONL, so newlines are -significant — and every admin bundler treats a plain JS module identically, whereas an -imported `.twig` file gets its whitespace collapsed by Shopware's Twig loader. +The product-feed feature is the outbound catalog surface for agentic commerce consumers. It is +separate from UCP catalog operations: UCP handles transactional runtime calls, while product feeds +support catalog ingestion, indexing, and marketplace-style discovery. -The feed URL is the Shopware product export URL shown in the Agentic Commerce sales-channel detail. It must be publicly reachable by the feed consumer and is not signed by UCP. Product links in both feeds include `referringSalesChannel` and preserve configured affiliate/campaign codes so downstream orders and customers can be attributed to the Agentic Commerce channel. +Product feeds reuse Shopware's product export infrastructure on Agentic Commerce sales channels. The +Administration registers two templates: -OpenAI JSONL rows include search eligibility, checkout eligibility, item and offer identifiers, title, description, URL, image URLs, price, availability, brand/seller data, return policy URL, store/target countries, GTIN/MPN, digital-product signal, and variant data when variants are included. The JSONL renderer trims blank rows, skips empty product renders, re-encodes each row as one JSON object per line, and URL-encodes spaces in media URLs. +| Provider | Format | File format | Template source | +| --------- | --------------------------- | -------------------------------------------- | ------------------------------------------------------------ | +| `open-ai` | OpenAI product feed | JSONL, one JSON object per valid product row | `agentic-product-export-templates/open-ai/body.json.twig.js` | +| `google` | Google Merchant Center feed | XML RSS item feed | `agentic-product-export-templates/google/*.xml.twig.js` | -Google XML rows include the required Merchant Center fields, canonical and tracked product links, image URLs, availability, price/sale price, condition, brand, GTIN/MPN or `identifier_exists`, category path, item group, variant attributes, custom labels, and shipping data derived from the sales-channel context. +The templates are Twig, but they live in `.js` modules that export the template as a string. They +must reach Shopware byte-exact — the OpenAI feed is JSONL, so newlines are significant — and every +admin bundler treats a plain JS module identically, whereas an imported `.twig` file gets its +whitespace collapsed by Shopware's Twig loader. -Feed generation, scheduling, caching, and invalidation are owned by Shopware's product export subsystem. The plugin supplies provider-specific templates, provider context, JSONL normalization, and validation. Template defaults set `generateByCronjob: false` and `interval: 86400`; merchants can adjust export behavior through the normal Shopware product export configuration. +The feed URL is the Shopware product export URL shown in the Agentic Commerce sales-channel detail. +It must be publicly reachable by the feed consumer and is not signed by UCP. Product links in both +feeds include `referringSalesChannel` and preserve configured affiliate/campaign codes so downstream +orders and customers can be attributed to the Agentic Commerce channel. -## Local Development (plugin maintainers) +OpenAI JSONL rows include search eligibility, checkout eligibility, item and offer identifiers, +title, description, URL, image URLs, price, availability, brand/seller data, return policy URL, +store/target countries, GTIN/MPN, digital-product signal, and variant data when variants are +included. The JSONL renderer trims blank rows, skips empty product renders, re-encodes each row as +one JSON object per line, and URL-encodes spaces in media URLs. -This section is about developing the plugin itself against three Shopware lanes. To run UCP on a shop you already have, see [Set up UCP on a sales channel](#set-up-ucp-on-a-sales-channel) above; the full lane workflow is in [docs/manual-testing.md](docs/manual-testing.md). +Google XML rows include the required Merchant Center fields, canonical and tracked product links, +image URLs, availability, price/sale price, condition, brand, GTIN/MPN or `identifier_exists`, +category path, item group, variant attributes, custom labels, and shipping data derived from the +sales-channel context. -This repository keeps plugin source, QA tooling, and CI helpers only. Local Podman/Mutagen lane orchestration is intentionally not versioned here, because it is workstation setup, not plugin code. +Feed generation, scheduling, caching, and invalidation are owned by Shopware's product export +subsystem. The plugin supplies provider-specific templates, provider context, JSONL normalization, +and validation. Template defaults set `generateByCronjob: false` and `interval: 86400`; merchants +can adjust export behavior through the normal Shopware product export configuration. -If you use the three-lane setup (`trunk`, `6.6.x`, `6.5.x`), keep the bootstrap helpers outside the repository, for example under `~/scripts/agentic-commerce/`. The local helpers support these environment variables instead of hard-coded personal paths: +## Troubleshooting -- `AGENTIC_COMMERCE_PROJECTS_ROOT` -- `AGENTIC_COMMERCE_PLUGIN_ROOT` -- `AGENTIC_COMMERCE_SDK_ROOT` -- `AGENTIC_COMMERCE_SHOPWARE_TRUNK_ROOT` -- `AGENTIC_COMMERCE_SHOPWARE_66_ROOT` -- `AGENTIC_COMMERCE_SHOPWARE_65_ROOT` -- `AGENTIC_COMMERCE_BASE_URL` +### The extension is installed, but UCP and the product feeds do nothing -Add them to your shell profile (`~/.zshrc`, `~/.bashrc`, etc.): +The extension says so in two places. It writes to PHP's error log — wherever the host sends it, +often the container output or `php_errors.log` — and, when that directory is writable, to +`var/log/swag-agentic-commerce.log` in the shop. Both carry the same line, which names the reason +and the command that fixes it: -```bash -export AGENTIC_COMMERCE_PLUGIN_ROOT=~/Documents/Projects/SwagAgenticCommerce -export AGENTIC_COMMERCE_SDK_ROOT=~/Documents/Projects/ucp-php-sdk -export AGENTIC_COMMERCE_SHOPWARE_TRUNK_ROOT=~/Documents/Projects/shopware-trunk -export AGENTIC_COMMERCE_SHOPWARE_66_ROOT=~/Documents/Projects/shopware-6-6-branch -export AGENTIC_COMMERCE_SHOPWARE_65_ROOT=~/Documents/Projects/shopware-6-5-branch -export AGENTIC_COMMERCE_PROJECTS_ROOT=~/Documents/Projects -export AGENTIC_COMMERCE_BASE_URL=http://trunk.localhost:8088 ``` - -Adjust the paths to match your local checkout layout. - -## QA - -```bash -composer ci -composer test # unit suite (mocks, no kernel) -composer test:integration # DB-backed integration suite (real connection, e.g. migrations) -composer test:functional # functional suite (boots a real test kernel + Symfony browser) -bin/ci-smoke.sh /path/to/shopware-checkout -bin/ci-admin-smoke.sh /path/to/shopware-checkout auto -bin/ci-storefront-smoke.sh /path/to/shopware-checkout +[SwagAgenticCommerce] The extension is inactive: ucp-php-sdk/core 0.0.7 is required but not +installed. Shopware installs this itself when the plugin is installed or updated; if that did +not happen -- an offline shop, or an interrupted update -- run `composer require +ucp-php-sdk/core:0.0.7 ucp-php-sdk/symfony-bundle:0.0.7` in the shop root and clear the cache. +The extension registers no services, routes or feeds until then. ``` -`bin/ci-smoke.sh` resolves the SDK from Packagist at the versions `composer.json` pins. To smoke -a local SDK checkout instead, set `UCP_SDK_SOURCE=path` (and `SDK_ROOT`, which defaults to a -sibling `../ucp-php-sdk`); it is then staged into the shop and relabelled as the pinned version. - -### Prefer functional tests over shell smoke - -Cover behavior at the lowest layer that can express it, and reach for a PHP test -before a shell smoke check. The `functional` suite (`tests/Functional`, -`composer test:functional`) boots a real Shopware test kernel and drives UCP runtime -routes end-to-end through a real Symfony browser (against `APP_URL`, as Shopware's -own functional tests do), so it is the preferred home for route, request-context, and -capability behavior — it is readable and debuggable without a deployed HTTP -stack. It covers the request-context guards and the **catalog/cart/checkout -capability flows**, including completing a checkout into a **real Shopware order** -and reading it back via its context token (via the shared `UcpFlowTestBehaviour`). -It requires the booting bootstrap (`SHOPWARE_PROJECT_DIR` unset + `APP_ENV=test`) and, -like core, assumes a booted kernel — run it against a configured lane with -`composer test:functional`. In CI it gates on **every** `shopware-matrix` lane -(`CI_SMOKE_RUN_FUNCTIONAL=1`). - -It runs on the **lane's own** phpunit: Shopware core's test base classes are -coupled to each lane's phpunit major (6.5→9.x, 6.6→10.x, trunk→11.x), so a single -pinned phpunit can't span lanes. `bin/run.php` prefers the platform phpunit inside -a lane and `tests/bootstrap.php` registers the plugin on the platform autoloader; -ci-smoke installs Shopware's dev deps so that binary is present. The unit/mock -suites stay on the plugin's pinned `.tools` phpunit (fast, lane-independent). - -Shell smoke (`bin/ci-smoke.sh`) is reserved for what the functional suite is the wrong -layer for — genuine deployed-stack / on-the-wire concerns. After the capability flows -moved to the functional suite, what stays in smoke and why: - -- the **outbound signed order webhook** — actually delivered to an external - endpoint with `signature`/`signature-input`/`content-digest` headers (on-the-wire); -- **tokenize 501** — the payment endpoint needs a *signed* request; -- **profile/discovery** — lane-aware MCP transport detection + storefront-rendered - `/llms.txt` and `/agents.md`; -- **admin/storefront** builds + UI shells (`bin/ci-admin-smoke.sh`, - `bin/ci-storefront-smoke.sh`) — closer to a browser-e2e concern; -- **signed-request conformance** (`bin/validate-ucp-store.sh … conformance`). - -The former `catalog` and `cart` smoke stages have been removed — that capability coverage now -lives entirely in the functional suite. The `checkout` stage resolves the seeded product itself -(one `catalog.lookup` as data setup) and stays in smoke only to drive the signed order webhook. -Almost any smoke assertion can become a functional test — when one can, move it and drop the -redundant smoke check once the functional suite gates in CI. See [AGENTS.md](AGENTS.md) for the -full layering rationale. - -Manual human test steps are documented in [docs/manual-testing.md](docs/manual-testing.md). - -Lane-specific administration, build, and local-runtime differences are documented in [docs/shopware-version-differences.md](docs/shopware-version-differences.md). Short-form guidance for future coding agents is kept in [AGENTS.md](AGENTS.md). - -## Release - -Store releases use `.github/workflows/store-release.yml`. Manual dispatch is safe by default: with `publish` disabled, the workflow builds and validates the same Shopware CLI package without uploading it. With `publish` enabled, it only releases the current `main` HEAD after that exact commit has a successful `validation-gate` check. - -Prepare a release in a pull request by updating: +The extension needs the UCP SDK, and Shopware installs it through Composer when the extension is +installed or updated. When that has not happened — a shop that cannot reach Packagist, or an update +that stopped halfway — the extension registers nothing instead of taking the shop down with it. The +storefront keeps serving, `/.well-known/ucp` answers `404` rather than `500`, and the product feeds +stay quiet. -- `composer.json` (`version`), which is the Store release source of truth; -- `CHANGELOG.md` and `CHANGELOG_de-DE.md` with a matching `# ` section. +To recover, run the command from the log entry in the shop root (the exact versions are in the +extension's `composer.json`), then clear the cache. Deactivating and reactivating the extension in +the administration has the same effect, and is the more reliable one if a worker is still serving +the old state. -After merging and waiting for the `main` CI run, dispatch a packaging-only run first. Enable `publish` only after that succeeds. Publishing uploads the ZIP to the Shopware Store and creates the version tag and GitHub release. It does not update the Store listing metadata or remove the Beta label. +It is not in Shopware's own `var/log/prod-*.log`, and cannot be: the extension decides this while +the container is being compiled, which is before any logger service exists. -Repository administrators must configure `SHOPWARE_CLI_ACCOUNT_CLIENT_ID` and `SHOPWARE_CLI_ACCOUNT_CLIENT_SECRET` as GitHub Actions secrets before publishing. +### Cluster setups -### The SDK version pin +A shop running with `shopware.deployment.cluster_setup: true` never lets Shopware run Composer for a +plugin — `PluginLifecycleService::executeComposerRequireWhenNeeded()` returns early there, by +design, because the filesystem is shared and built elsewhere. Nothing would install this extension's +requirements, so installing it from a zip would report success and leave an extension that does +nothing. -The plugin requires `ucp-php-sdk/symfony-bundle` at the exact version it was tested against — currently `0.0.7`, though `composer.json` is the authority and this page is not. Not a caret, not a tilde, and not a `>=a =0.0.2 <0.0.3`, which is why the plugin's original constraint never picked up `0.0.3`), `~0.0.6` expands to the open `>=0.0.6 <0.1.0`, and even `>=0.0.6 <0.0.7` still admits a four-component `0.0.6.1`. An exact version is the only constraint that cannot widen. +The extension refuses that install instead, naming what is missing: -The pin is deliberate. A plugin has no `composer.lock` and the SDK resolves at merchant install time, so a range let any matching release — which carries no compatibility promise — reach production without a plugin change. The SDK serves exactly one UCP version per release and switches it outright (see the SDK's `docs/ucp-version-support-policy.md`), so an SDK release that moves the spec date changes what every shop advertises and has to arrive together with the plugin review that goes with it. The pin turns that into a release decision instead of an accident. [docs/ucp-version-support.md](docs/ucp-version-support.md) is the integrator-facing summary. - -Moving the pin is a plugin release: - -1. **Wait for the SDK tag to be published on Packagist.** `ucp-php-sdk/core` and `ucp-php-sdk/symfony-bundle` are public Packagist packages; the Store build and merchant installs resolve them from there. Do not merge plugin code that references symbols which only exist on the SDK `main` branch or an unmerged SDK PR — anyone who resolved before that tag existed gets the older release that lacks them, and the plugin fatals with `Class "…" not found`. -2. **Move the pin, and the forced versions with it.** - - `composer.json` — the exact `ucp-php-sdk/core` and `ucp-php-sdk/symfony-bundle` versions. Both, not only the bundle: the bundle accepts a *range* of `core`, so pinning the bundle alone would let a later `core` release pair with it on a source install. - - `.github/workflows/ci.yml` — the two forced `versions` in the *Configure private SDK path repositories* step (`ucp-php-sdk/core` and `ucp-php-sdk/symfony-bundle`). A forced version outside the pin no longer satisfies the constraint and resolution breaks. - - `bin/ci-smoke.sh` — the same two forced `versions` in the `composer config repositories.ucp-sdk-*` lines, which apply only under `UCP_SDK_SOURCE=path`. - - `src/Ucp/UcpProtocol.php` — only if the SDK release moved the spec date. `UcpProtocolVersionGuardTest` fails until `UcpProtocol::VERSION` follows, and it must follow only after `ShopwareDataMapper` and `UcpCapabilityCatalog` have been reviewed against the new schemas. Do not make the constant read the SDK's enum; the failing test is the point. - - `CHANGELOG.md` and `CHANGELOG_de-DE.md`. -3. **Leave `UCP_SDK_REF` on `main`.** One job, `sdk-main-compatibility`, still builds the plugin against the moving SDK `main` branch so upcoming SDK breakage is caught early. Do not pin `UCP_SDK_REF` to a tag to "make CI match production" — that trades away the early-warning signal. - -### Which SDK a CI job resolves - -Every job that blocks a merge resolves both SDK packages **from Packagist at the versions `composer.json` pins** — the pair a merchant installs. `sdk-main-compatibility` is the single exception and the only job that stages an SDK checkout: it points a path repository at `UCP_SDK_REF` and relabels that source as the pinned version. - -That relabelling is why the exception is `continue-on-error` and absent from both `expected_checks` and `validation-gate`. A branch wearing a release's version number is not what anyone installs, and when SDK `main` moved to require a newer `core`, having it on the merge path turned this repository's `main` red for five days — and would have done the same to every open pull request at once. Read the job, open an SDK issue; do not let it stop a merge. - -`bin/ci-smoke.sh` follows the same rule through `UCP_SDK_SOURCE`, which defaults to `packagist`; only `sdk-main-compatibility` and local manual testing set `path`. - -> **Why green CI is not enough on its own:** a change that compiles against SDK `main` in `sdk-main-compatibility` can still be broken against whichever published tag an install resolves. Before merging SDK-coupled code for a release, confirm the required symbols exist in a **published** SDK tag and that `composer.json` pins that tag. - -### Migrations and releases - -Shopware's migration runner tracks each migration by class + creation timestamp and **never re-executes one it has already marked applied**. This has a hard consequence for edits: - -- **Never change the effect of a migration that has shipped in a tagged release.** Existing installs will not re-run it, so editing the DDL only affects fresh installs and silently drifts the schema of upgraded shops (e.g. a column added to a `CREATE TABLE IF NOT EXISTS` is a no-op where the table already exists). Add a **new** forward migration instead, made idempotent (guard column/index/FK changes with `information_schema` checks) so it is safe on both drifted and already-correct schemas. -- **Editing a migration that only exists in the current unreleased development cycle is fine.** No released install ever ran the old version, and fresh installs get the corrected DDL. Only internal dev/QA/CI shops that ran the intermediate version drift — reset or manually reconcile those databases rather than shipping a migration for them. Check with `git show :`: if the migration is absent from every release tag, editing it is safe. - -### Test packages on pull requests - -Reviewers can get an install-ready ZIP for a pull request without a local build. `.github/workflows/package-zip.yml` builds and validates the extension with the same `shopware/github-actions/build-zip` action the release uses, then uploads it as a `SwagAgenticCommerce.zip` run artifact. - -The build is opt-in per PR to keep it off the default CI path: - -1. Add the `build:zip` label to the pull request. The label triggers a build right away, and every later push rebuilds the ZIP while the label stays on. Remove the label to stop rebuilding. -2. Open the workflow run from the PR checks (or the Actions tab) and download `SwagAgenticCommerce.zip` from the run's **Artifacts**. -3. Install it in a Shopware shop via **Extensions → My extensions → Upload extension**, or with `bin/console plugin:install --activate` after unzipping into `custom/plugins`. - -Create the `build:zip` label once under **Issues → Labels** (any color/description) if it does not exist yet; the workflow matches it by name. - -Administration build compatibility is intentionally validated as a matrix: - -- `6.5.x`: webpack only -- `6.6.x`: webpack and Vite -- `trunk` / current `6.7`: Vite only - -The plugin handles this with one administration implementation and lane-aware build/test scripts, not by copying admin modules per Shopware line. - -GitHub Actions checks out public `shopware/shopware` and public `agentic-commerce-alliance/ucp-php-sdk` directly. Both repositories are public, so no repository secret or access token is required for the SDK checkout. - -The plugin stores tooling dependencies in `.tools/vendor`, not `vendor`, so lane-local Composer installs do not collide with the Shopware runtime dependency graph. - -Runtime dependencies are installed through the active Shopware lane's root `composer.json`. The plugin's source `composer.json` is the public metadata source of truth for plugin-owned dependencies: it requires the public `ucp-php-sdk/symfony-bundle` Packagist package, and SDK core is resolved transitively by that bundle. Shopware packages are provided by the active lane. Release packages therefore do not embed a plugin-local vendor tree. - -Local lanes and CI still configure path repositories for the public SDK checkout so compatibility can be tested against `UCP_SDK_REF` before an SDK release. Those path repositories force the SDK to a version that must satisfy the `composer.json` range — at or above its lower bound (see *Bumping the SDK version floor* above), and the plugin path repository exposes the release version from `composer.json` so Shopware's plugin lifecycle resolves the same package version during `plugin:install`. - -`bin/ci-smoke.sh` supports two execution modes: +``` +This shop runs with shopware.deployment.cluster_setup enabled, where Shopware never runs composer +for a plugin, so nothing will install this extension's requirements: ucp-php-sdk/core 0.0.7 is +required but not installed. Add them to the project's composer.json and deploy, then install the +extension. +``` -- Local default: `warm` - Reuses an already prepared Shopware web volume and only refreshes `custom/plugins/SwagAgenticCommerce` and `custom/ucp-php-sdk` before running the smoke flow. -- CI and full validation: `cold` - Rebuilds the Shopware web volume from the checkout before running the smoke flow. +Add `ucp-php-sdk/core` and `ucp-php-sdk/symfony-bundle` at the versions the extension's +`composer.json` pins to the project, deploy, and install it again. -Examples: +### Updating from 1.3.0 or older -```bash -# Fast local rerun against an already bootstrapped lane. -bin/ci-smoke.sh /path/to/shopware-checkout +Up to and including 1.3.0 the archive carried the UCP SDK inside itself. Those versions cannot be +updated in place without a short gap: Shopware extracts the new files one request before it runs +Composer, so between those two steps the old bundled SDK is gone and the new one is not installed +yet. From 1.4.0 on the extension handles that gap by switching itself off, so the shop stays up and +the update completes on its own. -# Force a full rebuild locally. -CI_SMOKE_MODE=cold bin/ci-smoke.sh /path/to/shopware-checkout -``` +Two things to expect on that one upgrade: -You can also override stack cleanup explicitly with `CI_SMOKE_KEEP_STACK=0|1`. By default, warm mode keeps the stack running and cold mode tears it down at the end. +- Uploading the archive onto a 1.3.0 install can answer `500` for that single request. The files are + extracted regardless; continue with the update and the shop recovers. +- If the update is interrupted before Composer ran, the shop is in the state described above. The + log entry tells you what to run. -Administration and storefront validation should always prove both halves: +Later updates do not have this gap, because the SDK then lives in the shop's own `vendor/` and does +not disappear when the extension's files are replaced. -- build succeeds -- the rendered UI shell actually loads afterward +## Further reading -`bin/ci-admin-smoke.sh` checks the administration login shell after the build. `bin/ci-storefront-smoke.sh` builds the storefront, compiles the theme, and checks the live homepage and cart shell. For local frontend work, still follow up with a real browser pass on the active lane. +| Document | What it covers | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| [docs/local-development.md](docs/local-development.md) | Developing the plugin against the three Shopware lanes. | +| [docs/qa.md](docs/qa.md) | Test suites, what belongs in which layer, smoke modes, build matrix. | +| [docs/releasing.md](docs/releasing.md) | Store releases, the SDK version pin, migrations, test packages on pull requests. | +| [docs/manual-testing.md](docs/manual-testing.md) | Manual human test steps on a lane. | +| [docs/shopware-version-differences.md](docs/shopware-version-differences.md) | Lane-specific administration, build, and runtime differences. | +| [docs/ucp-version-support.md](docs/ucp-version-support.md) | Which UCP version the plugin serves, and what an SDK bump means. | +| [AGENTS.md](AGENTS.md) | Short-form guidance for coding agents working in this repository. | diff --git a/bin/ci-assert-markdown-links.sh b/bin/ci-assert-markdown-links.sh new file mode 100755 index 00000000..114d2596 --- /dev/null +++ b/bin/ci-assert-markdown-links.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Assert that every relative link in the repository's markdown resolves. +# +# Documentation moves -- sections get split into their own files, files get renamed -- and a link +# written from the repository root keeps pointing at `docs/x.md` from inside `docs/`, where it +# means `docs/docs/x.md`. Nothing renders an error for that; the link is simply dead. Three of +# them were already in the tree when this check was written. +# +# External links are not fetched: this is a structural check, not a network one. + +set -euo pipefail + +python3 - "$@" <<'PYTHON' +import re +import sys +from pathlib import Path + +SKIP = {'node_modules', '.tools', 'vendor', 'var', 'dist'} + +broken = [] +checked = 0 + +for markdown in sorted(Path('.').rglob('*.md')): + if any(part in SKIP for part in markdown.parts): + continue + + for match in re.finditer(r'\[[^\]]*\]\(([^)]+)\)', markdown.read_text()): + target = match.group(1).split('#')[0].strip() + + if not target or target.startswith(('http://', 'https://', 'mailto:')): + continue + + checked += 1 + + if not (markdown.parent / target).resolve().exists(): + broken.append(f'{markdown}: {target}') + +for entry in broken: + print(f'FAIL: dead relative link -> {entry}', file=sys.stderr) + +if broken: + sys.exit(1) + +print(f'ok: {checked} relative markdown links resolve.') +PYTHON diff --git a/bin/ci-assert-zip-no-vendor.sh b/bin/ci-assert-zip-no-vendor.sh new file mode 100755 index 00000000..d0927502 --- /dev/null +++ b/bin/ci-assert-zip-no-vendor.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Assert that a packaged extension zip carries no dependencies of its own. +# +# The plugin returns true from executeComposerCommands(), so Shopware runs +# `composer require shopware/agentic-commerce:` in the shop when the archive is +# installed. `shopware/production` declares `custom/plugins/*` as a path repository, so that +# resolves the extracted archive locally and pulls the pinned UCP SDK from Packagist into the +# project -- which is why the archive must ship none of it itself. +# +# 1.3.0 shipped the other way round: the SDK vendored into the plugin's own vendor/ with the +# autoloader Composer generates for it, which the plugin then required. That registers a second +# Composer ClassLoader in every shop, reported by FroshTools as "2 autoloaders registered" +# (FriendsOfShopware/FroshTools#469) and able to answer version lookups the shop's own registry +# should own. +# +# Guards four things: +# - no vendor/ tree and no marker file: nothing is bundled +# - the two SDK packages are still required, so the install has something to resolve +# - composer.json carries a concrete version -- the path repository derives the package version +# from it, and a require of `:` that cannot match fails the install +# - no build-only trees (.sdk, .tools, node_modules) + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: bin/ci-assert-zip-no-vendor.sh " >&2 + exit 1 +fi + +zip_file="$1" + +if ! command -v unzip >/dev/null 2>&1; then + echo "Missing required dependency: unzip" >&2 + exit 1 +fi + +if [[ ! -f "${zip_file}" ]]; then + echo "No such zip: ${zip_file}" >&2 + exit 1 +fi + +readonly PLUGIN="SwagAgenticCommerce" + +# Consumers must read the whole listing. grep -q can close early on a large archive, +# making printf fail with SIGPIPE under pipefail even when the expected file exists. +listing="$(unzip -Z1 "${zip_file}")" + +status=0 + +bundled="$(printf '%s\n' "${listing}" | grep -E "^${PLUGIN}/vendor/" || true)" + +if [[ -n "${bundled}" ]]; then + echo "FAIL: the archive ships a vendor/ tree ($(printf '%s\n' "${bundled}" | wc -l | tr -d ' ') entries)." >&2 + echo " Shopware resolves this plugin's requirements through Composer at install time;" >&2 + echo " a bundled copy registers a second autoloader in the shop." >&2 + status=1 +else + echo "ok: the archive bundles no dependencies." +fi + +for build_only in .sdk .tools node_modules; do + if printf '%s\n' "${listing}" | grep "^${PLUGIN}/${build_only}/" >/dev/null; then + echo "FAIL: the archive ships the build-only ${build_only}/ tree." >&2 + echo " Add ${build_only} to zip.pack.excludes.paths in .shopware-extension.yml." >&2 + status=1 + fi +done + +if printf '%s\n' "${listing}" | grep -Fx "${PLUGIN}/.swag-agentic-commerce-bundled-sdk" >/dev/null; then + echo "FAIL: the archive still carries the bundled-SDK marker file." >&2 + status=1 +fi + +if ! python3 - "${zip_file}" <<'PYTHON' +import json +import sys +import zipfile + +PLUGIN = 'SwagAgenticCommerce' +failures = [] + +with zipfile.ZipFile(sys.argv[1]) as archive: + manifest = json.loads(archive.read(f'{PLUGIN}/composer.json')) + +# A path repository takes the package version from this field, and Shopware requires +# `:`. Without it the install fails on a constraint nothing can satisfy. +version = manifest.get('version') +if not isinstance(version, str) or version == '': + failures.append(f'composer.json declares no version (got {version!r})') + +for package in ('ucp-php-sdk/core', 'ucp-php-sdk/symfony-bundle'): + constraint = manifest.get('require', {}).get(package) + if not constraint: + failures.append(f'composer.json no longer requires {package}, so nothing installs it') + +psr4 = manifest.get('autoload', {}).get('psr-4', {}) +if psr4.get('Swag\\AgenticCommerce\\') != 'src/': + failures.append(f"autoload.psr-4 must map Swag\\AgenticCommerce\\ to src/, got {psr4!r}") + +for failure in failures: + print(f'FAIL: {failure}', file=sys.stderr) + +if failures: + sys.exit(1) + +print(f'ok: version {version}, SDK required, own namespace autoloaded.') +PYTHON +then + status=1 +fi + +if [[ "${status}" -ne 0 ]]; then + exit 1 +fi + +echo "ok: the archive leaves its dependencies to Composer." diff --git a/bin/ci-assert-zip-vendors-sdk.sh b/bin/ci-assert-zip-vendors-sdk.sh deleted file mode 100755 index 4066a6f4..00000000 --- a/bin/ci-assert-zip-vendors-sdk.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# Assert that a packaged extension zip actually carries the UCP SDK it autoloads. -# -# The failure this exists for is silent in every other check. `composer` records a path -# repository's package in vendor/composer/installed.json and writes an autoload entry -# pointing at $vendorDir/ucp-php-sdk//src -- and if the package directory was never -# copied in, nothing complains. `shopware-cli extension validate` does not read vendor/, -# and bin/ci-assert-zip-admin-bundle.sh only reads the administration bundle. So the -# archive installs, the plugin activates, and the first UCP request dies with -# `Class "Ucp\Sdk\..." not found`. -# -# That is not hypothetical: shopware-cli copies the extension into a temp directory before -# running composer, so a path repository aimed at a sibling checkout (`../ucp-php-sdk`) -# resolves during planning and materialises nothing. -# -# Guards three things: -# - the two SDK packages have real PHP files under vendor/ -# - the autoloader points at those same paths -# - the archive does not also ship the build-only .sdk/ source copy - -set -euo pipefail - -if [[ $# -ne 1 ]]; then - echo "Usage: bin/ci-assert-zip-vendors-sdk.sh " >&2 - exit 1 -fi - -zip_file="$1" - -if ! command -v unzip >/dev/null 2>&1; then - echo "Missing required dependency: unzip" >&2 - exit 1 -fi - -if [[ ! -f "${zip_file}" ]]; then - echo "No such zip: ${zip_file}" >&2 - exit 1 -fi - -readonly PLUGIN="SwagAgenticCommerce" -# Deliberately a low floor. An earlier revision set this to 50 and failed a correct -# archive, because ucp-php-sdk/symfony-bundle genuinely has 49 source files -- a count -# threshold has to be re-tuned every time the package legitimately changes shape, and -# tuning it against the current tree is how it ends up asserting nothing. The real -# invariant is the named classes below; this only catches "essentially nothing arrived". -readonly MIN_PHP_FILES=10 - -# Load-bearing classes. If the copy is partial in a way a count would miss, one of these -# is what actually breaks: the bundle Shopware registers, the extension that reads the -# configuration, and a core class the plugin constructs directly. -readonly REQUIRED_CLASSES=( - "vendor/ucp-php-sdk/symfony-bundle/src/UcpSdkBundle.php" - "vendor/ucp-php-sdk/symfony-bundle/src/DependencyInjection/UcpSdkExtension.php" - "vendor/ucp-php-sdk/symfony-bundle/src/Bridge/DoctrineDbal/SchemaBootstrapper.php" - "vendor/ucp-php-sdk/core/src/Model/Profile/PlatformProfile.php" - "vendor/ucp-php-sdk/core/src/Enum/UcpProtocolVersion.php" -) - -# Consumers must read the whole listing. grep -q can close early on a large archive, -# making printf fail with SIGPIPE under pipefail even when the expected file exists. -listing="$(unzip -Z1 "${zip_file}")" - -status=0 - -# A full Composer resolve also installs Shopware and Symfony. Those belong to the -# host shop; carrying them here overrides its versions and raises the PHP minimum. -python3 - "${zip_file}" <<'PYTHON' -import json -import sys -import zipfile - -with zipfile.ZipFile(sys.argv[1]) as archive: - metadata = json.loads(archive.read('SwagAgenticCommerce/vendor/composer/installed.json')) -names = {package['name'] for package in metadata['packages']} -expected = {'ucp-php-sdk/core', 'ucp-php-sdk/symfony-bundle'} -if names != expected: - sys.exit(f'FAIL: bundled runtime must contain only the two SDK packages; found {sorted(names)}') -print('ok: bundled runtime contains only the two SDK packages.') -PYTHON - -for package in core symfony-bundle; do - count="$(printf '%s\n' "${listing}" \ - | grep -c "^${PLUGIN}/vendor/ucp-php-sdk/${package}/.*\.php$" || true)" - - if [[ "${count}" -eq 0 ]]; then - echo "FAIL: vendor/ucp-php-sdk/${package} ships no PHP files." >&2 - echo " The autoloader will point at a directory that is not in the archive." >&2 - status=1 - elif [[ "${count}" -lt "${MIN_PHP_FILES}" ]]; then - echo "FAIL: vendor/ucp-php-sdk/${package} ships only ${count} PHP files." >&2 - echo " That is a partial copy, not the package." >&2 - status=1 - else - echo "ok: vendor/ucp-php-sdk/${package} ships ${count} PHP files." - fi -done - -for class_path in "${REQUIRED_CLASSES[@]}"; do - if ! printf '%s\n' "${listing}" | grep -Fx "${PLUGIN}/${class_path}" >/dev/null; then - echo "FAIL: ${class_path} is not in the archive." >&2 - status=1 - fi -done - -# The SDK ships the generated JSON Schemas it validates every request and response -# against, and they are resources rather than PHP -- so a copy that took only *.php would -# pass every check above and then refuse traffic at runtime. -schema_count="$(printf '%s\n' "${listing}" \ - | grep -c "^${PLUGIN}/vendor/ucp-php-sdk/core/resources/schema/.*\.json$" || true)" - -if [[ "${schema_count}" -lt 30 ]]; then - echo "FAIL: only ${schema_count} generated schema files shipped." >&2 - echo " The SDK validates every request and response against these." >&2 - status=1 -else - echo "ok: ${schema_count} schema files ship." -fi - -# The autoloader has to agree with what is on disk. If composer recorded the package under -# a different install path than the one packed, the file count above can pass while nothing -# is reachable. -autoload_psr4="$(unzip -p "${zip_file}" "${PLUGIN}/vendor/composer/autoload_psr4.php" 2>/dev/null || true)" - -if [[ -z "${autoload_psr4}" ]]; then - echo "FAIL: ${PLUGIN}/vendor/composer/autoload_psr4.php is missing from the archive." >&2 - echo " Without it Shopware cannot autoload the SDK at all." >&2 - status=1 -else - for expected in 'ucp-php-sdk/core/src' 'ucp-php-sdk/symfony-bundle/src'; do - if ! printf '%s\n' "${autoload_psr4}" | grep -F "${expected}" >/dev/null; then - echo "FAIL: autoload_psr4.php does not map anything to ${expected}." >&2 - status=1 - fi - done -fi - -# .sdk/ is the build-only source the path repositories resolve from. Shipping it too would -# put a second copy of the SDK in the archive, which is how a plugin ends up with two -# versions of the same class on disk. -if printf '%s\n' "${listing}" | grep "^${PLUGIN}/\.sdk/" >/dev/null; then - echo "FAIL: the archive ships the build-only .sdk/ source copy." >&2 - echo " Add .sdk to zip.pack.excludes.paths in .shopware-extension.yml." >&2 - status=1 -fi - -if [[ "${status}" -ne 0 ]]; then - exit 1 -fi - -echo "ok: the archive carries the SDK its autoloader points at." diff --git a/bin/ci-bundle-sdk-only.sh b/bin/ci-bundle-sdk-only.sh deleted file mode 100755 index f6799a78..00000000 --- a/bin/ci-bundle-sdk-only.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# Rebuild the prepared archive's vendor tree with only the SDK. Framework packages -# are provided by the host shop; shipping another Shopware/Symfony tree breaks older lanes. -set -euo pipefail - -python3 - <<'PYTHON' -import json -from pathlib import Path - -manifest = json.loads(Path('composer.json').read_text()) -installed = json.loads(Path('vendor/composer/installed.json').read_text()) -versions = {package['name']: package['version'] for package in installed['packages']} -bundle = json.loads(Path('vendor/ucp-php-sdk/symfony-bundle/composer.json').read_text()) -platform = { - name: versions[name] for name in bundle['require'] - if name != 'php' and not name.startswith(('ext-', 'ucp-php-sdk/')) -} -build = dict(manifest) -build['require'] = { - 'php': manifest['require']['php'], - 'ucp-php-sdk/symfony-bundle': versions['ucp-php-sdk/symfony-bundle'], - 'ucp-php-sdk/core': versions['ucp-php-sdk/core'], -} -build['replace'] = platform -build['config'] = {'vendor-dir': 'vendor'} -for key in ('require-dev', 'autoload-dev', 'scripts'): - build.pop(key, None) -Path('.composer-bundled-sdk.json').write_text(json.dumps(build, indent=4) + '\n') - -# Keep the published manifest's real requirements, without build-only source paths. -repositories = manifest.get('repositories', []) -def is_build_repository(repository): - return isinstance(repository, dict) and repository.get('type') == 'path' and repository.get('url') in ('.sdk/core', './.sdk/core', '.sdk/symfony-bundle', './.sdk/symfony-bundle') -if isinstance(repositories, dict): - manifest['repositories'] = {name: repository for name, repository in repositories.items() if not is_build_repository(repository)} -else: - manifest['repositories'] = [repository for repository in repositories if not is_build_repository(repository)] -if not manifest['repositories']: - del manifest['repositories'] -Path('composer.json').write_text(json.dumps(manifest, indent=4) + '\n') -PYTHON - -# This tree was generated by the preceding Composer step in the disposable build checkout. -rm -rf vendor -COMPOSER=.composer-bundled-sdk.json composer update --no-dev --no-scripts --no-interaction --prefer-dist - -# Composer records replacement-only packages without a version. Remove those records so -# InstalledVersions defers to the host's registry instead of hiding its platform versions. -php <<'PHP' - $package) { - if (isset($package['replaced']) && !isset($package['version'])) { - unset($data['versions'][$name]); - } -} -file_put_contents($path, '/dev/null || true)" + + if [[ ! "${status}" =~ ^[0-9]{3}$ ]]; then + status="000" + fi + + printf '%s' "${status}" +} + # Authenticate before temporarily removing a plugin that may already be active. token=$(curl -sS -X POST "${shop_url}/api/oauth/token" -H 'Content-Type: application/json' \ -d "{\"client_id\":\"administration\",\"grant_type\":\"password\",\"scopes\":\"write\",\"username\":\"${admin_user}\",\"password\":\"${admin_pass}\"}" \ @@ -78,6 +98,8 @@ composer_backed_up=0 registry_backed_up=0 plugin_moved=0 sdk_moved=0 +sdk_hidden=0 +archive_present=0 # Invoked from the EXIT trap below, which shellcheck cannot see. # shellcheck disable=SC2317,SC2329 restore() { @@ -94,6 +116,18 @@ restore() { fi if [[ "${plugin_moved}" -eq 1 ]]; then in_shop "rm -rf custom/plugins/${PLUGIN} && mv /tmp/zit-plugin custom/plugins/${PLUGIN}" || true + elif [[ "${archive_present}" -eq 1 ]]; then + # The lane had no plugin when this started, so putting it back means taking the archive out + # again -- files and plugin record both. Every step is best-effort: the archive may have been + # extracted by the upload and never installed, which is the case this whole script exists for. + api PUT /dev/null -X PUT "${shop_url}/api/_action/extension/deactivate/plugin/${PLUGIN}" >/dev/null || true + api POST /dev/null -X POST "${shop_url}/api/_action/extension/uninstall/plugin/${PLUGIN}" >/dev/null || true + in_shop "rm -rf custom/plugins/${PLUGIN}" || true + api POST /dev/null -X POST "${shop_url}/api/_action/extension/refresh" >/dev/null || true + in_shop "php bin/console cache:clear --no-warmup" >/dev/null 2>&1 || true + fi + if [[ "${sdk_hidden}" -eq 1 ]]; then + in_shop "rm -rf vendor/ucp-php-sdk && mv /tmp/zit-sdk-hidden vendor/ucp-php-sdk" || true fi if [[ "${sdk_moved}" -eq 1 ]]; then in_shop "rm -rf vendor/ucp-php-sdk && mv /tmp/zit-sdk vendor/ucp-php-sdk" || true @@ -101,7 +135,13 @@ restore() { if [[ -n "${sync_session}" ]]; then mutagen sync resume "${sync_session}" >/dev/null 2>&1 || true fi - say "lane restored; re-run your bootstrap if the plugin version looks off" + if [[ "${plugin_moved}" -eq 1 ]]; then + say "lane restored; re-run your bootstrap if the plugin version looks off" + elif [[ "${archive_present}" -eq 1 ]]; then + say "archive taken back out; the lane has no plugin again, as it did before this ran" + else + say "nothing was moved, so there is nothing to restore" + fi } trap restore EXIT @@ -144,13 +184,18 @@ foreach ($names as $name) { file_put_contents('vendor/composer/installed.php', '/dev/null # Some lanes answer 500 here on a PHP upload_tmp_dir quirk while still extracting the archive, # so the upload status is reported rather than enforced; install is the check that matters. +# Set before the upload, not after a successful install: the upload extracts the archive into +# custom/plugins/ and refreshes the plugin record, so from here on the lane holds a copy whether +# or not anything below succeeds -- and an install that does not is the failure this exists for. +archive_present=1 upload=$(api POST /tmp/zit-upload.json -X POST "${shop_url}/api/_action/extension/upload" -F "file=@${zip_file};type=application/zip") say "upload: HTTP ${upload}" api POST /dev/null -X POST "${shop_url}/api/_action/extension/refresh" >/dev/null @@ -225,20 +274,142 @@ else status=1 fi -# The point of the whole exercise: the SDK must come from the plugin's own vendor directory. -origin=$(in_shop "php -r ' -require \"vendor/autoload.php\"; -require \"custom/plugins/${PLUGIN}/vendor/autoload.php\"; -echo (new ReflectionClass(\"${SDK_PROBE_CLASS//\\/\\\\}\"))->getFileName(); -'" 2>/dev/null || echo "") -case "${origin}" in - */custom/plugins/${PLUGIN}/vendor/*) say "SDK resolved from the archive: ${origin}" ;; - "") echo "FAIL: could not resolve ${SDK_PROBE_CLASS} at all after install." >&2; status=1 ;; - *) echo "FAIL: SDK resolved from ${origin}, not from the plugin's bundled vendor." >&2; status=1 ;; +# The point of the whole exercise: installing the archive must be what brings the SDK into the +# shop. executeComposerCommands() makes Shopware run `composer require` against the project, and +# `custom/plugins/*` is a path repository in shopware/production, so the plugin resolves from the +# extracted directory and its pinned SDK comes from Packagist -- into the project's vendor, on the +# one autoloader the shop already has. The refusal above proved none of it was there beforehand. +probe_script=$(cat <<'PHP' +getFileName() : 'unresolved'; +printf( + "%s|%s|%s\n", + $origin, + is_dir($plugin.'/vendor') ? 'plugin-vendor-present' : 'no-plugin-vendor', + file_exists('vendor/shopware/agentic-commerce') ? 'required-by-composer' : 'not-required' +); +PHP +) + +probe=$(printf '%s' "${probe_script}" | docker exec -i -u www-data \ + -e "ZIT_PLUGIN=${PLUGIN}" -e "ZIT_CLASS=${SDK_PROBE_CLASS}" "${container}" php 2>/dev/null \ + || echo "error|error|error") + +IFS='|' read -r sdk_origin plugin_vendor composer_required <<< "${probe}" + +case "${sdk_origin}" in + */vendor/ucp-php-sdk/*) say "SDK resolved from the shop's own vendor: ${sdk_origin}" ;; + */custom/plugins/${PLUGIN}/*) + echo "FAIL: the SDK resolved from inside the plugin (${sdk_origin})." >&2 + echo " The archive is supposed to ship none, and Composer to install it." >&2 + status=1 + ;; + *) + echo "FAIL: ${SDK_PROBE_CLASS} is not resolvable after install (${sdk_origin})." >&2 + status=1 + ;; +esac + +if [[ "${plugin_vendor}" != "no-plugin-vendor" ]]; then + echo "FAIL: the installed plugin has a vendor/ directory." >&2 + echo " Its autoloader would register a second Composer ClassLoader in the shop." >&2 + status=1 +else + say "the installed plugin carries no vendor/ of its own" +fi + +if [[ "${composer_required}" != "required-by-composer" ]]; then + echo "FAIL: vendor/shopware/agentic-commerce is missing, so the install never ran composer." >&2 + echo " Something resolved the SDK by another route and this run proves nothing." >&2 + status=1 +else + say "Shopware required the plugin through Composer" +fi + +datasets=$(in_shop "php -r 'require \"vendor/autoload.php\"; echo count(Composer\\InstalledVersions::getAllRawData());'" 2>/dev/null || echo "error") +if [[ "${datasets}" == "1" ]]; then + say "Composer reports a single registered autoloader dataset" +else + echo "FAIL: Composer reports ${datasets} autoloader datasets, expected 1." >&2 + status=1 +fi + +# --------------------------------------------------------------------------------------------- +echo "== checking the shop survives its SDK going missing" + +# The regression this exists for: an update extracts the new files one request before Shopware +# runs composer, so an active extension boots with its dependency missing. 1.3.0 answered 500 on +# every page in that state, storefront included, until someone installed the SDK by hand. +# +# Taking the SDK away from an installed, active extension reproduces exactly that, and it is what +# catches SDK-dependent wiring added outside the SdkAvailability guard -- a service, a route, a +# bundle, or a class implementing an SDK interface that the service glob then reflects on. Any of +# those stop the container compiling, and the shop goes down with it. +in_shop "cp vendor/composer/installed.json /tmp/zit-sdk-registry.json && cp vendor/composer/installed.php /tmp/zit-sdk-registry.php" +in_shop "mv vendor/ucp-php-sdk /tmp/zit-sdk-hidden" +sdk_hidden=1 + +docker exec -i -u www-data "${container}" php <<'PHP' >/dev/null + !in_array($package['name'], $names, true), +)); +file_put_contents('vendor/composer/installed.json', json_encode($installed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); +$versions = require 'vendor/composer/installed.php'; +foreach ($names as $name) { + unset($versions['versions'][$name]); +} +file_put_contents('vendor/composer/installed.php', '&2 + echo " An active extension must not take the shop down while composer has not run yet;" >&2 + echo " something is wired to the SDK outside the SdkAvailability guard." >&2 + status=1 +fi + +degraded="$(http_status "${shop_url}/.well-known/ucp")" +case "${degraded}" in + 404) say "UCP is switched off rather than failing (HTTP 404)" ;; + 500) + echo "FAIL: /.well-known/ucp answered 500 without the SDK; the routes are still registered." >&2 + status=1 + ;; + *) echo "FAIL: /.well-known/ucp answered HTTP ${degraded}, expected 404 while degraded." >&2; status=1 ;; esac +if in_shop "grep -q 'composer require ucp-php-sdk' var/log/swag-agentic-commerce.log" 2>/dev/null; then + say "var/log/swag-agentic-commerce.log names the command that fixes it" +else + echo "FAIL: nothing in var/log/swag-agentic-commerce.log tells the merchant how to recover." >&2 + status=1 +fi + +# Put it back, so the lane is left as it was found. Recovery is deliberately not asserted here: +# the install above already proves the same transition -- a shop with no SDK ends with UCP +# answering -- and doing it a second time by hand only measures when a PHP worker lets go of the +# container it already built, which is the platform's business and not this extension's. +in_shop "rm -rf vendor/ucp-php-sdk && mv /tmp/zit-sdk-hidden vendor/ucp-php-sdk" +in_shop "cp /tmp/zit-sdk-registry.json vendor/composer/installed.json && cp /tmp/zit-sdk-registry.php vendor/composer/installed.php" +sdk_hidden=0 +in_shop "rm -rf var/cache/*" + if [[ "${status}" -eq 0 ]]; then - echo "== PASS: the archive installs and runs on a shop without the SDK" + echo "== PASS: the archive installs, runs, and the shop survives losing the SDK" fi exit "${status}" diff --git a/docs/automated-store-release-plan.md b/docs/automated-store-release-plan.md index d060f700..08b70292 100644 --- a/docs/automated-store-release-plan.md +++ b/docs/automated-store-release-plan.md @@ -4,72 +4,69 @@ Replace the custom release-candidate ZIP pipeline with Shopware's standard [`store-release` action](https://github.com/shopware/github-actions/tree/main/store-release). -Releases will be manually dispatched from `main`, require green CI for the exact -commit, upload `SwagAgenticCommerce` to Store product `21761`, and create the -GitHub tag and release. +Releases will be manually dispatched from `main`, require green CI for the exact commit, upload +`SwagAgenticCommerce` to Store product `21761`, and create the GitHub tag and release. ## Implementation Changes - Add `store-release.yml` with: - `workflow_dispatch`, single-release concurrency, and `contents: write`. - - Guards requiring the selected ref to equal the current `main` HEAD and its - CI workflow to have completed successfully. - - Preflight validation for a new semantic Composer version and matching - English and German changelog entries. + - Guards requiring the selected ref to equal the current `main` HEAD and its CI workflow to have + completed successfully. + - Preflight validation for a new semantic Composer version and matching English and German + changelog entries. - `shopware/github-actions/store-release@main` using hardcoded - `extensionName: SwagAgenticCommerce`, `GITHUB_TOKEN`, - `SHOPWARE_CLI_ACCOUNT_CLIENT_ID`, and + `extensionName: SwagAgenticCommerce`, `GITHUB_TOKEN`, `SHOPWARE_CLI_ACCOUNT_CLIENT_ID`, and `SHOPWARE_CLI_ACCOUNT_CLIENT_SECRET`. - - Default action behavior retained: Store upload, unprefixed version tag, - GitHub release, and release ZIP. Store-page metadata is not overwritten. + - Default action behavior retained: Store upload, unprefixed version tag, GitHub release, and + release ZIP. Store-page metadata is not overwritten. - Make the repository compatible with standard packaging: - - Add Composer `version: 1.1.0` as the next backward-compatible feature release; - every later release PR must bump it. - - Add `CHANGELOG.md` and `CHANGELOG_de-DE.md`, initially documenting 1.1.0; - each release must add the new version to both. - - Enable Shopware CLI administration asset building against the 6.7/Vite - line and add an asset hook that installs the existing legacy bootstrap - beside the Vite entrypoints, preserving 6.5/6.6 compatibility. - - Rely on the public Packagist SDK packages instead of embedding a private - vendor tree. - - Remove bundled-SDK marker handling, custom autoloading, and marker-specific - route resolution; normal Shopware Composer installation becomes the only - runtime path. + - Add Composer `version: 1.1.0` as the next backward-compatible feature release; every later + release PR must bump it. + - Add `CHANGELOG.md` and `CHANGELOG_de-DE.md`, initially documenting 1.1.0; each release must add + the new version to both. + - Enable Shopware CLI administration asset building against the 6.7/Vite line and add an asset + hook that installs the existing legacy bootstrap beside the Vite entrypoints, preserving 6.5/6.6 + compatibility. + - Rely on the public Packagist SDK packages instead of embedding a private vendor tree. + - Remove bundled-SDK marker handling, custom autoloading, and marker-specific route resolution; + normal Shopware Composer installation becomes the only runtime path. + + **Status.** Done in #112, undone by #219 (which vendored the SDK into the archive to carry an + untagged QA build), and restored in #248 -- this time with the plugin able to boot while the SDK + is not installed yet, which is what made the bundle look necessary. See the _Installation And + Update_ section in `AGENTS.md` before touching it again. - Simplify CI and obsolete packaging support: - - Delete `package-test-zip`, `zip-install-smoke`, and `publish-test-zip`, plus - the packaging script and release-candidate documentation. - - Remove the ZIP-install mode from the smoke orchestrator while retaining - normal source-based lane smoke tests. - - Add a stable `validation-gate` job covering shell lint, PHP quality, - Shopware lanes, functional tests, administration builds/browser checks, - and storefront builds/browser checks. - - Keep the existing optimization that trusts complete PR checks for a merge - commit; direct or unverifiable pushes rerun the full matrix. - - Update README release instructions and remove statements that the SDK is - unavailable through Packagist. + - Delete `package-test-zip`, `zip-install-smoke`, and `publish-test-zip`, plus the packaging + script and release-candidate documentation. + - Remove the ZIP-install mode from the smoke orchestrator while retaining normal source-based lane + smoke tests. + - Add a stable `validation-gate` job covering shell lint, PHP quality, Shopware lanes, functional + tests, administration builds/browser checks, and storefront builds/browser checks. + - Keep the existing optimization that trusts complete PR checks for a merge commit; direct or + unverifiable pushes rerun the full matrix. + - Update README release instructions and remove statements that the SDK is unavailable through + Packagist. ## Validation - Run the normal full CI matrix and require `validation-gate` to pass. -- Build locally with the same `shopware-cli extension zip --release` path and - run `shopware-cli extension validate`. -- Inspect the package for Composer metadata, Vite entrypoints, the legacy - administration loader, and absence of development files or bundled-SDK - markers/vendor workarounds. -- Confirm release preflight rejects a feature branch, stale main commit, - missing or failed CI, an existing `v` or `` tag, or a - missing changelog entry. -- The first live dispatch after a version bump must create the Store version, - GitHub tag, GitHub release, and release ZIP. +- Build locally with the same `shopware-cli extension zip --release` path and run + `shopware-cli extension validate`. +- Inspect the package for Composer metadata, Vite entrypoints, the legacy administration loader, and + absence of development files or bundled-SDK markers/vendor workarounds. +- Confirm release preflight rejects a feature branch, stale main commit, missing or failed CI, an + existing `v` or `` tag, or a missing changelog entry. +- The first live dispatch after a version bump must create the Store version, GitHub tag, GitHub + release, and release ZIP. ## Assumptions -- Accept the exact main commit's already-green CI instead of rerunning the full - matrix during release. -- Repository administrators will add the currently missing - `SHOPWARE_CLI_ACCOUNT_CLIENT_ID` and +- Accept the exact main commit's already-green CI instead of rerunning the full matrix during + release. +- Repository administrators will add the currently missing `SHOPWARE_CLI_ACCOUNT_CLIENT_ID` and `SHOPWARE_CLI_ACCOUNT_CLIENT_SECRET` secrets before the first dispatch. -- The Store listing remains Beta. Publishing is explicit; the default workflow - dispatch only builds and validates the package. +- The Store listing remains Beta. Publishing is explicit; the default workflow dispatch only builds + and validates the package. diff --git a/docs/completion-payment.md b/docs/completion-payment.md index 1c452628..83feef5d 100644 --- a/docs/completion-payment.md +++ b/docs/completion-payment.md @@ -2,32 +2,30 @@ ## What happens today -An agent completing a UCP checkout sends a payment instrument. The specification marks it -required — `checkout.json` annotates `payment` as `ucp_request: {complete: "required"}` — and -this plugin validates it against the schema and then **ignores it**. Every UCP order is -placed with whatever payment method the sales channel defaults to, usually invoice or another -offline method. +An agent completing a UCP checkout sends a payment instrument. The specification marks it required — +`checkout.json` annotates `payment` as `ucp_request: {complete: "required"}` — and this plugin +validates it against the schema and then **ignores it**. Every UCP order is placed with whatever +payment method the sales channel defaults to, usually invoice or another offline method. -That is not a rounding error. It is the difference between a protocol that can carry a -payment and one that only appears to. +That is not a rounding error. It is the difference between a protocol that can carry a payment and +one that only appears to. -Since the release that added this document, the instrument reaches a seam instead of being -dropped, and the default implementation logs a warning naming the handler the agent asked -for. **Order behaviour is unchanged** — the default still charges the channel default — so -installing the release changes nothing about how money moves. What changed is that the gap is -now audible and has one obvious place to close it. +Since the release that added this document, the instrument reaches a seam instead of being dropped, +and the default implementation logs a warning naming the handler the agent asked for. **Order +behaviour is unchanged** — the default still charges the channel default — so installing the release +changes nothing about how money moves. What changed is that the gap is now audible and has one +obvious place to close it. ## Why it is not closed here Two decisions belong to people who are not this plugin: - **What a buyer is charged with** is checkout's call, not an integration layer's. -- **How an instrument maps onto a concrete payment method** is a payment provider's, and - differs per provider. +- **How an instrument maps onto a concrete payment method** is a payment provider's, and differs per + provider. -A plausible-looking implementation that guesses either one is worse than no implementation, -because it moves money. So this plugin supplies the instrument, the context and the timing, -and stops. +A plausible-looking implementation that guesses either one is worse than no implementation, because +it moves money. So this plugin supplies the instrument, the context and the timing, and stops. ## The seam @@ -98,16 +96,16 @@ The **first** instrument on the completion — not necessarily the one the buyer UCP models payment as `{"instruments": [...]}` and marks the buyer's choice with `selected` at the instrument top level. The SDK's `PaymentInstrument` has no such property and completion maps the -whole list through it, so nothing reaches this plugin that could tell two instruments apart. The -SDK does honour `selected` on create and update, where it reads the raw payload before mapping; -the asymmetry is tracked in +whole list through it, so nothing reaches this plugin that could tell two instruments apart. The SDK +does honour `selected` on create and update, where it reads the raw payload before mapping; the +asymmetry is tracked in [ucp-php-sdk#190](https://github.com/agentic-commerce-alliance/ucp-php-sdk/issues/190). -The plugin does not refuse a completion carrying several instruments — that is a spec-valid -request, and refusing it here would be this plugin deciding for every deployment. If charging the -wrong one is unacceptable to your business, **your applier is the place to refuse**: throw -`ValidationException` when the instrument you are handed is not one you can confidently settle. -Once the SDK reports `selected`, this section goes away and the parameter becomes the chosen one. +The plugin does not refuse a completion carrying several instruments — that is a spec-valid request, +and refusing it here would be this plugin deciding for every deployment. If charging the wrong one +is unacceptable to your business, **your applier is the place to refuse**: throw +`ValidationException` when the instrument you are handed is not one you can confidently settle. Once +the SDK reports `selected`, this section goes away and the parameter becomes the chosen one. ### When it is called @@ -118,19 +116,19 @@ Immediately before the order is placed, and after everything the order needs alr 3. **`apply()` runs**, 4. `placeOrder()` is called with the context `apply()` returned. -So an implementation may switch the payment method, recalculate, and return the new context — -those are one decision and one call. +So an implementation may switch the payment method, recalculate, and return the new context — those +are one decision and one call. -It runs inside the completion lock, so it will not race a second completion of the same -checkout. It runs **before** `completionStore->complete()`, so throwing leaves the checkout -un-completed and the agent may retry. +It runs inside the completion lock, so it will not race a second completion of the same checkout. It +runs **before** `completionStore->complete()`, so throwing leaves the checkout un-completed and the +agent may retry. ### What it receives `$instrument` is the instrument the agent selected. `payment.json` models the field as -`{"instruments": [...]}`, so a request carries a list; the plugin picks the one flagged -`selected` and otherwise the first, matching what the SDK does when reading the same shape on -create and update. It is `null` when the agent sent none — return the context unchanged. +`{"instruments": [...]}`, so a request carries a list; the plugin picks the one flagged `selected` +and otherwise the first, matching what the SDK does when reading the same shape on create and +update. It is `null` when the agent sent none — return the context unchanged. `$instrument->handlerId` names a payment handler this business published in its profile. `$instrument->credential` is an open map whose shape the handler defines. @@ -141,15 +139,15 @@ create and update. It is `null` when the agent sent none — return the context The plugin already has half of this. `PaymentHandlerInterface::prepareInstrument()` exists and `ShopwareInvoicePaymentHandler` implements it, returning a `paymentMethodId` read from -`$instrument->credential['payment_method_id']` — and **nothing has ever called it**. Resolving -the handler through `PaymentHandlerRegistryInterface::find($instrument->handlerId)` and calling +`$instrument->credential['payment_method_id']` — and **nothing has ever called it**. Resolving the +handler through `PaymentHandlerRegistryInterface::find($instrument->handlerId)` and calling `prepareInstrument()` is the intended path. ### 2. Switch the sales channel context Switching the payment method on a `SalesChannelContext` and recalculating the cart is ordinary -Shopware work, and it is the part that needs checkout's agreement rather than an opinion from -here. Whatever it does must leave a context `placeOrder()` can use. +Shopware work, and it is the part that needs checkout's agreement rather than an opinion from here. +Whatever it does must leave a context `placeOrder()` can use. ### 3. Refuse what cannot be honoured — do not fall back @@ -162,30 +160,28 @@ Throw `Ucp\Sdk\Exception\ValidationException` when: The SDK maps that to a UCP error descriptor the agent can read and act on. -**Falling back to the channel default is the behaviour that made this gap invisible for as -long as it has been here.** An agent that presents a card and receives a successful order has -been told its payment was accepted. Silence is the wrong answer to "I cannot charge this". +**Falling back to the channel default is the behaviour that made this gap invisible for as long as +it has been here.** An agent that presents a card and receives a successful order has been told its +payment was accepted. Silence is the wrong answer to "I cannot charge this". -The one exception is the default implementation shipped with the plugin, which deliberately -does fall back and warns instead — because making refusal the default would break every -business already completing checkouts through the channel default, for a capability they have -not been offered yet. +The one exception is the default implementation shipped with the plugin, which deliberately does +fall back and warns instead — because making refusal the default would break every business already +completing checkouts through the channel default, for a capability they have not been offered yet. ### 4. Decide what tokenization means for you -`ShopwareInvoicePaymentHandler` reports `tokenization: false` and returns `null` from -`tokenize()`. A provider handling real credentials will want the opposite, and the tokenization -capability this plugin publishes (`dev.ucp.shopping.payment_tokenization`) is an id no UCP -release defines — at `2026-08-25` tokenization is a payment *handler* concern -(`handlers/tokenization/openapi.json`), not a shopping capability. Worth settling before -building against it. +`ShopwareInvoicePaymentHandler` reports `tokenization: false` and returns `null` from `tokenize()`. +A provider handling real credentials will want the opposite, and the tokenization capability this +plugin publishes (`dev.ucp.shopping.payment_tokenization`) is an id no UCP release defines — at +`2026-08-25` tokenization is a payment _handler_ concern (`handlers/tokenization/openapi.json`), not +a shopping capability. Worth settling before building against it. ## Testing an implementation -The acceptance test is an order placed against a **non-default** payment method, driven end to -end over HTTP: create a checkout, complete it with an instrument naming that method, and assert -the resulting order carries it. A unit test asserting `apply()` returns a switched context -proves the seam and not the outcome. +The acceptance test is an order placed against a **non-default** payment method, driven end to end +over HTTP: create a checkout, complete it with an instrument naming that method, and assert the +resulting order carries it. A unit test asserting `apply()` returns a switched context proves the +seam and not the outcome. `UnappliedCompletionPayment` is a usable stand-in for tests that need the seam to do nothing. diff --git a/docs/full-ucp-parity-plan.md b/docs/full-ucp-parity-plan.md index 15945651..2ccde1f0 100644 --- a/docs/full-ucp-parity-plan.md +++ b/docs/full-ucp-parity-plan.md @@ -1,123 +1,167 @@ # Full UCP Parity Plan > **Not the SDK's file of the same name.** `ucp-php-sdk` also carries a -> `docs/full-ucp-parity-plan.md`, with different content — that one records the SDK's -> MCP-proxy architectural decision. This one is the plugin's parity status. Check which -> repository you are in before editing either. +> `docs/full-ucp-parity-plan.md`, with different content — that one records the SDK's MCP-proxy +> architectural decision. This one is the plugin's parity status. Check which repository you are in +> before editing either. ## Summary -The plugin targets UCP parity with the Shopware 6.7 UCP work while keeping the admin UI simpler than the original PR. The implementation is split across three codebases: +The plugin targets UCP parity with the Shopware 6.7 UCP work while keeping the admin UI simpler than +the original PR. The implementation is split across three codebases: -- Shopware 6.7/trunk core adds the Store API MCP foundation on branch `codex/store-api-mcp-endpoint`. +- Shopware 6.7/trunk core adds the Store API MCP foundation on branch + `codex/store-api-mcp-endpoint`. - `../ucp-php-sdk` advertises multiple transports and supports endpoint overrides. -- `SwagAgenticCommerce` exposes only the transports and capabilities that work on the current Shopware line. +- `SwagAgenticCommerce` exposes only the transports and capabilities that work on the current + Shopware line. ## Support Matrix -| Transport | 6.5 | 6.6 | 6.7/trunk | -| --- | --- | --- | --- | -| REST | enabled | enabled | enabled | -| A2A | configurable | configurable | configurable | -| Embedded | configurable | configurable | configurable | -| MCP | hidden from runtime, disabled in admin | hidden from runtime, disabled in admin | enabled only when core Store API MCP exists | - -UCP advertises the buyer-facing MCP endpoint as `/ucp/mcp`. On trunk, the -plugin proxies that route to the core Store API MCP endpoint `/store-api/_mcp` -and injects the sales-channel access key server-side. The access key must never -be exposed in the UCP profile or required from the MCP client. The admin MCP -endpoint `/api/_mcp` remains for merchant/admin automation and is not used for +| Transport | 6.5 | 6.6 | 6.7/trunk | +| --------- | -------------------------------------- | -------------------------------------- | ------------------------------------------- | +| REST | enabled | enabled | enabled | +| A2A | configurable | configurable | configurable | +| Embedded | configurable | configurable | configurable | +| MCP | hidden from runtime, disabled in admin | hidden from runtime, disabled in admin | enabled only when core Store API MCP exists | + +UCP advertises the buyer-facing MCP endpoint as `/ucp/mcp`. On trunk, the plugin proxies that route +to the core Store API MCP endpoint `/store-api/_mcp` and injects the sales-channel access key +server-side. The access key must never be exposed in the UCP profile or required from the MCP +client. The admin MCP endpoint `/api/_mcp` remains for merchant/admin automation and is not used for buyer-facing UCP. OAuth identity linking is implemented as an optional plugin-backed capability: -- The SDK routes stay registered so clients receive explicit UCP responses instead of framework `404`/`500` failures. -- The plugin registers a Shopware-backed identity-linking adapter and advertises it only when `identity_linking` is enabled for the sales channel. -- Authorization Code + PKCE S256 is supported. Authorization requires a logged-in Shopware customer context token so anonymous requests cannot mint identity-linked tokens. +- The SDK routes stay registered so clients receive explicit UCP responses instead of framework + `404`/`500` failures. +- The plugin registers a Shopware-backed identity-linking adapter and advertises it only when + `identity_linking` is enabled for the sales channel. +- Authorization Code + PKCE S256 is supported. Authorization requires a logged-in Shopware customer + context token so anonymous requests cannot mint identity-linked tokens. - Access and refresh tokens are stored sales-channel scoped in plugin tables. -- Token retention is bounded by a daily scheduled task (`swag_agentic_commerce.ucp_oauth_token.cleanup`, handled by `CleanupExpiredOAuthTokensTaskHandler`). It purges expired and consumed authorization codes (TTL 10 min), expired access tokens (TTL 1 h), and expired refresh tokens (TTL 30 days) from the three `swag_agentic_commerce_ucp_oauth_*` tables. Revoked-but-unexpired refresh tokens are intentionally retained until their natural expiry so refresh-token reuse detection (family revocation) keeps working within the token lifetime; they are removed once expired. There is no separate audit-grace window — rows are purged as soon as they can no longer be used. -- Checkout completion currently uses an existing Shopware customer only when the resolved sales-channel context already contains one; otherwise it creates a guest customer from `buyer.email`. It must not attach orders to an existing account by email match alone. Follow-up work should hydrate cart/checkout contexts from the OAuth-linked customer subject before falling back to guest checkout (see [#72](https://github.com/shopware/agentic-commerce/issues/72)). -- Checkout completion uses a Symfony Lock (keyed `ucp.checkout.completion.{checkoutId}.{salesChannelId}`, TTL 300 s) as the in-flight mutex, and a DB-backed idempotency record in `swag_agentic_commerce_ucp_checkout_completion` keyed by `(checkout_id, sales_channel_id)` for completed-order replay. The lock auto-expires after TTL if the holding process crashes, so there are no permanently stuck locks. Concurrent requests that cannot acquire the lock receive a 422 and are told to retry. Idempotent replay is checked before lock acquisition (fast path) and again after acquisition (double-check against a race). Because `placeOrder()` is not idempotent, a TTL expiry while order placement is in flight can still cause a second request to place a duplicate order; the idempotency INSERT will fail with a unique-constraint error for the second `complete()` call, leaving the duplicate order orphaned in Shopware. Making `placeOrder()` idempotent is the correct long-term fix. +- Token retention is bounded by a daily scheduled task + (`swag_agentic_commerce.ucp_oauth_token.cleanup`, handled by + `CleanupExpiredOAuthTokensTaskHandler`). It purges expired and consumed authorization codes (TTL + 10 min), expired access tokens (TTL 1 h), and expired refresh tokens (TTL 30 days) from the three + `swag_agentic_commerce_ucp_oauth_*` tables. Revoked-but-unexpired refresh tokens are intentionally + retained until their natural expiry so refresh-token reuse detection (family revocation) keeps + working within the token lifetime; they are removed once expired. There is no separate audit-grace + window — rows are purged as soon as they can no longer be used. +- Checkout completion currently uses an existing Shopware customer only when the resolved + sales-channel context already contains one; otherwise it creates a guest customer from + `buyer.email`. It must not attach orders to an existing account by email match alone. Follow-up + work should hydrate cart/checkout contexts from the OAuth-linked customer subject before falling + back to guest checkout (see [#72](https://github.com/shopware/agentic-commerce/issues/72)). +- Checkout completion uses a Symfony Lock (keyed + `ucp.checkout.completion.{checkoutId}.{salesChannelId}`, TTL 300 s) as the in-flight mutex, and a + DB-backed idempotency record in `swag_agentic_commerce_ucp_checkout_completion` keyed by + `(checkout_id, sales_channel_id)` for completed-order replay. The lock auto-expires after TTL if + the holding process crashes, so there are no permanently stuck locks. Concurrent requests that + cannot acquire the lock receive a 422 and are told to retry. Idempotent replay is checked before + lock acquisition (fast path) and again after acquisition (double-check against a race). Because + `placeOrder()` is not idempotent, a TTL expiry while order placement is in flight can still cause + a second request to place a duplicate order; the idempotency INSERT will fail with a + unique-constraint error for the second `complete()` call, leaving the duplicate order orphaned in + Shopware. Making `placeOrder()` idempotent is the correct long-term fix. Sales-channel configuration is also plugin-table backed: - The canonical UCP config table is `swag_agentic_commerce_ucp_config`. -- Legacy `SystemConfig` keys are read only as a compatibility fallback for older local installs and are backfilled into the plugin table when found. -- New admin fields and runtime policy should be added to `UcpConfig`, `UcpConfigService`, and the plugin table payload, not as new `SystemConfig` state. +- Legacy `SystemConfig` keys are read only as a compatibility fallback for older local installs and + are backfilled into the plugin table when found. +- New admin fields and runtime policy should be added to `UcpConfig`, `UcpConfigService`, and the + plugin table payload, not as new `SystemConfig` state. Payment tokenization remains extension-ready but not bundled as a shipped tokenizer: -- The plugin registers the tokenization capability wrapper and a non-tokenizing Shopware invoice payment-handler descriptor. -- `/ucp/v1/tokenize` must return `501` until at least one real payment handler supports UCP tokenization and the capability is enabled. -- `payment_handlers` must stay an empty object in `/.well-known/ucp` unless tokenization is enabled and a real tokenizing handler is registered. -- Store API customer login/context-token APIs and Shopware checkout payment tokens are not sufficient substitutes. They do not provide the UCP identity-linking consent model or a reusable payment-tokenization contract. +- The plugin registers the tokenization capability wrapper and a non-tokenizing Shopware invoice + payment-handler descriptor. +- `/ucp/v1/tokenize` must return `501` until at least one real payment handler supports UCP + tokenization and the capability is enabled. +- `payment_handlers` must stay an empty object in `/.well-known/ucp` unless tokenization is enabled + and a real tokenizing handler is registered. +- Store API customer login/context-token APIs and Shopware checkout payment tokens are not + sufficient substitutes. They do not provide the UCP identity-linking consent model or a reusable + payment-tokenization contract. - Implementation TODO and example PSP handler shape live in [docs/payment-tokenization-handler.md](payment-tokenization-handler.md). ## Implementation Decisions - Keep REST/A2A/embedded in the plugin/SDK transport surface. -- Ship a plugin-owned `/ucp/mcp` discovery endpoint that delegates to the 6.7 - core Store API MCP endpoint without leaking the sales-channel access key. +- Ship a plugin-owned `/ucp/mcp` discovery endpoint that delegates to the 6.7 core Store API MCP + endpoint without leaking the sales-channel access key. - Register UCP MCP tools into the Store API MCP registry through the shared - `shopware.store_api_mcp.tool` tag. Write tools expose structured object - payload schemas instead of JSON-string payload arguments. -- Keep all transports behind the same capability layer so catalog/cart/checkout/order behavior does not fork per protocol. -- Keep customer-facing runtime behavior behind Store API route boundaries. UCP - adapters/gateways must delegate catalog, cart, checkout, customer, identity, - and order operations to the relevant Store API routes instead of direct DAL - repositories or hand-built customer/context mutations. Exceptions must be - limited to plugin-owned config/admin/internal metadata or explicitly - documented gaps where no Store API route exists. + `shopware.store_api_mcp.tool` tag. Write tools expose structured object payload schemas instead of + JSON-string payload arguments. +- Keep all transports behind the same capability layer so catalog/cart/checkout/order behavior does + not fork per protocol. +- Keep customer-facing runtime behavior behind Store API route boundaries. UCP adapters/gateways + must delegate catalog, cart, checkout, customer, identity, and order operations to the relevant + Store API routes instead of direct DAL repositories or hand-built customer/context mutations. + Exceptions must be limited to plugin-owned config/admin/internal metadata or explicitly documented + gaps where no Store API route exists. - Show unsupported transports in admin as disabled with concrete reasons. -- Do not implement placeholder tokenization adapters. The identity adapter is real and customer-context backed; tokenization still requires a PSP-backed handler. +- Do not implement placeholder tokenization adapters. The identity adapter is real and + customer-context backed; tokenization still requires a PSP-backed handler. ## Version Strategy -- Keep one administration implementation and drive lane differences from admin API metadata such as `supportsStoreApiMcp`. -- Keep one PHP configuration/runtime model and filter capabilities/transports at runtime through compatibility services. -- Keep Shopware-version conditionals in small compatibility seams, scripts, or gateway branches only when the platform API really differs. -- Do not copy complete admin pages, controllers, or transport handlers per Shopware version. A duplicated lane implementation is a regression risk and must be replaced by shared code plus explicit feature detection. -- Test the same browser validator against every admin lane/build mode so version drift is caught by coverage, not by duplicated code. +- Keep one administration implementation and drive lane differences from admin API metadata such as + `supportsStoreApiMcp`. +- Keep one PHP configuration/runtime model and filter capabilities/transports at runtime through + compatibility services. +- Keep Shopware-version conditionals in small compatibility seams, scripts, or gateway branches only + when the platform API really differs. +- Do not copy complete admin pages, controllers, or transport handlers per Shopware version. A + duplicated lane implementation is a regression risk and must be replaced by shared code plus + explicit feature detection. +- Test the same browser validator against every admin lane/build mode so version drift is caught by + coverage, not by duplicated code. ## Demo Data And Validation Import demo data before implementation validation: -- 6.5: `bun run generate --instance=65 --name=music --domain=music-65`, storefront `http://music-65.localhost:8102` -- 6.6: `bun run generate --instance=66 --name=music --domain=music-66`, storefront `http://music-66.localhost:8101` -- 6.7/trunk: `bun run generate --instance=trunk --name=music --domain=music-trunk`, storefront `http://music-trunk.localhost:8100` +- 6.5: `bun run generate --instance=65 --name=music --domain=music-65`, storefront + `http://music-65.localhost:8102` +- 6.6: `bun run generate --instance=66 --name=music --domain=music-66`, storefront + `http://music-66.localhost:8101` +- 6.7/trunk: `bun run generate --instance=trunk --name=music --domain=music-trunk`, storefront + `http://music-trunk.localhost:8100` -`--name=music` must stay stable so the catalog generator reuses the pre-generated `music` template. `--domain` is the lane-specific storefront subdomain override and prevents all three local shops from competing for `music.localhost`. +`--name=music` must stay stable so the catalog generator reuses the pre-generated `music` template. +`--domain` is the lane-specific storefront subdomain override and prevents all three local shops +from competing for `music.localhost`. -Use `bin/validate-ucp-store.sh ` for basic profile validation. -Use `bin/validate-ucp-store.sh extended` to also -exercise A2A `catalog.search`/`cart.create`, embedded cart headers and bridge -markup, and trunk MCP `tools/list` when `UCP_STORE_API_ACCESS_KEY` is provided. -Use `bin/validate-ucp-store.sh conformance` with -`UCP_CONFORMANCE_DIR` when the official UCP conformance suite is checked out. +Use `bin/validate-ucp-store.sh ` for basic profile validation. Use +`bin/validate-ucp-store.sh extended` to also exercise A2A +`catalog.search`/`cart.create`, embedded cart headers and bridge markup, and trunk MCP `tools/list` +when `UCP_STORE_API_ACCESS_KEY` is provided. Use +`bin/validate-ucp-store.sh conformance` with `UCP_CONFORMANCE_DIR` when the +official UCP conformance suite is checked out. The validation script also asserts the default shipped optional capability decision: - `payment_handlers` is an empty object in the UCP profile. -- `/.well-known/oauth-authorization-server` returns `501` until - `identity_linking` is enabled for the sales channel. -- With `identity_linking` enabled, OAuth metadata returns `200` and authorize - requires a logged-in Shopware customer context token. +- `/.well-known/oauth-authorization-server` returns `501` until `identity_linking` is enabled for + the sales channel. +- With `identity_linking` enabled, OAuth metadata returns `200` and authorize requires a logged-in + Shopware customer context token. - `/ucp/v1/tokenize` returns `501`. Validated local profile matrix: - 6.5: `bin/validate-ucp-store.sh http://music-65.localhost:8102` returns REST/A2A/embedded. - 6.6: `bin/validate-ucp-store.sh http://music-66.localhost:8101` returns REST/A2A/embedded. -- 6.7/trunk: `bin/validate-ucp-store.sh http://music-trunk.localhost:8100` returns REST/MCP/A2A/embedded when the Store API MCP core branch is present. +- 6.7/trunk: `bin/validate-ucp-store.sh http://music-trunk.localhost:8100` returns + REST/MCP/A2A/embedded when the Store API MCP core branch is present. -For trunk MCP validation, initialize `/ucp/mcp`, then call `tools/list`. The -plugin resolves the current sales-channel access key internally before -delegating to `/store-api/_mcp`. The expected UCP tool names cover the shopping -operation matrix: `search_catalog`, -`lookup_catalog`, cart create/get/update/cancel, discount apply, -checkout create/get/update/complete/cancel, and order get. +For trunk MCP validation, initialize `/ucp/mcp`, then call `tools/list`. The plugin resolves the +current sales-channel access key internally before delegating to `/store-api/_mcp`. The expected UCP +tool names cover the shopping operation matrix: `search_catalog`, `lookup_catalog`, cart +create/get/update/cancel, discount apply, checkout create/get/update/complete/cancel, and order get. ## Admin QA @@ -128,23 +172,22 @@ Run the reusable browser validator for each supported lane/build mode: - 6.6 vite: `BASE_URL=http://sw66.localhost:8088 npm run qa:admin -- --lane 6.6-vite` - 6.7/trunk vite: `BASE_URL=http://trunk.localhost:8088 npm run qa:admin -- --lane trunk-vite` -The CI admin matrix runs this validator with `CI_ADMIN_BROWSER_VALIDATE=1`. -It logs in, opens the UCP overview/detail screens, validates authenticated admin -API save/profile/key operations, checks lane-aware profile-preview transports, -fails on UCP console errors, writes screenshots, and restores the original -sales-channel config. +The CI admin matrix runs this validator with `CI_ADMIN_BROWSER_VALIDATE=1`. It logs in, opens the +UCP overview/detail screens, validates authenticated admin API save/profile/key operations, checks +lane-aware profile-preview transports, fails on UCP console errors, writes screenshots, and restores +the original sales-channel config. Required browser assertions: - 6.5 admin: MCP disabled, REST/A2A/embedded visible. - 6.6 admin: MCP disabled, REST/A2A/embedded visible. -- 6.7/trunk admin: MCP enabled only when the core Store API MCP route exists; - the profile advertises `/ucp/mcp`. +- 6.7/trunk admin: MCP enabled only when the core Store API MCP route exists; the profile advertises + `/ucp/mcp`. - Profile preview: effective transports match the runtime matrix. - Sales-channel shortcut: opens the UCP detail page for the selected sales channel. -Store screenshots under `var/qa/admin-screenshots/{lane}/{build-mode}/` for CI -and under `var/qa-screenshots/` for ad-hoc local captures. +Store screenshots under `var/qa/admin-screenshots/{lane}/{build-mode}/` for CI and under +`var/qa-screenshots/` for ad-hoc local captures. Current local screenshot artifacts are stored in `var/qa-screenshots/`: @@ -152,62 +195,60 @@ Current local screenshot artifacts are stored in `var/qa-screenshots/`: - `admin-ucp-66.png` and `admin-ucp-66-security.png` - `admin-ucp-65.png` and `admin-ucp-65-security.png` -The top screenshots verify the lane transport summary. The security screenshots verify the split allowlist UX: remote profile hosts, agent/webhook hosts, embedded allowed origins, and embedded frame ancestors. +The top screenshots verify the lane transport summary. The security screenshots verify the split +allowlist UX: remote profile hosts, agent/webhook hosts, embedded allowed origins, and embedded +frame ancestors. ## Remaining Runtime Gaps -For the UCP SDK upgrade to protocol version `2026-08-25` and the plugin-side -work it implies, see [ucp-sdk-integration-backlog.md](ucp-sdk-integration-backlog.md). -Note that the SDK repository also has a `docs/full-ucp-parity-plan.md` with -different content: that one covers the SDK transport model, this one covers the -Shopware support matrix. - -- Payment tokenization stays hidden/unsupported by default until a real - PSP-backed tokenizing payment handler is registered and enabled per sales - channel. See - [docs/payment-tokenization-handler.md](payment-tokenization-handler.md) - for the required service contract and validation checklist. Identity linking - is implemented but remains opt-in per sales channel. -- Embedded now renders plugin-owned cart/checkout bridge pages with CSP, - explicit allowed-origin validation, and `postMessage` ready/state messages. - Missing `embeddedAllowedOrigins`, or an `Origin` header that is present but not - allowlisted, return a controlled `403`; an absent `Origin` is intentionally - allowed through because browsers omit it on iframe and top-level `GET` - navigations, with framing enforced by `frame-ancestors` and the payload - authorized by the cart/checkout token in the URL. Follow-up UX work should - focus on visual polish and deeper storefront theme integration, not transport - correctness. -- MCP and A2A now route the shopping operation matrix through the shared - capability layer. Follow-up validation should target lane builds and real demo - storefront data rather than adding protocol-specific business logic. -- `checkout.complete` requires a `payment` object per spec (`checkout.json` - annotates it `ucp_request: {complete: "required"}`), and the MCP tool sends - one. An earlier revision of this document recorded acting on the instrument as - blocked on an upstream SDK change. **It never was**: the SDK has exposed - `PaymentAwareCheckoutCapabilityInterface` and - `PaymentAwareCheckoutAdapterInterface` since 0.0.3. The plugin now supplies the - instrument, the context and the timing through `AbstractCompletionPaymentApplier` - ([#213](https://github.com/shopware/agentic-commerce/pull/213)). The default - applier still charges the sales-channel default method and logs a warning naming - the handler the agent asked for, because which methods are reachable through UCP - and how a handler id maps onto a payment method are decisions for checkout and a - payment provider, not this plugin. See +For the UCP SDK upgrade to protocol version `2026-08-25` and the plugin-side work it implies, see +[ucp-sdk-integration-backlog.md](ucp-sdk-integration-backlog.md). Note that the SDK repository also +has a `docs/full-ucp-parity-plan.md` with different content: that one covers the SDK transport +model, this one covers the Shopware support matrix. + +- Payment tokenization stays hidden/unsupported by default until a real PSP-backed tokenizing + payment handler is registered and enabled per sales channel. See + [docs/payment-tokenization-handler.md](payment-tokenization-handler.md) for the required service + contract and validation checklist. Identity linking is implemented but remains opt-in per sales + channel. +- Embedded now renders plugin-owned cart/checkout bridge pages with CSP, explicit allowed-origin + validation, and `postMessage` ready/state messages. Missing `embeddedAllowedOrigins`, or an + `Origin` header that is present but not allowlisted, return a controlled `403`; an absent `Origin` + is intentionally allowed through because browsers omit it on iframe and top-level `GET` + navigations, with framing enforced by `frame-ancestors` and the payload authorized by the + cart/checkout token in the URL. Follow-up UX work should focus on visual polish and deeper + storefront theme integration, not transport correctness. +- MCP and A2A now route the shopping operation matrix through the shared capability layer. Follow-up + validation should target lane builds and real demo storefront data rather than adding + protocol-specific business logic. +- `checkout.complete` requires a `payment` object per spec (`checkout.json` annotates it + `ucp_request: {complete: "required"}`), and the MCP tool sends one. An earlier revision of this + document recorded acting on the instrument as blocked on an upstream SDK change. **It never was**: + the SDK has exposed `PaymentAwareCheckoutCapabilityInterface` and + `PaymentAwareCheckoutAdapterInterface` since 0.0.3. The plugin now supplies the instrument, the + context and the timing through `AbstractCompletionPaymentApplier` + ([#213](https://github.com/shopware/agentic-commerce/pull/213)). The default applier still charges + the sales-channel default method and logs a warning naming the handler the agent asked for, + because which methods are reachable through UCP and how a handler id maps onto a payment method + are decisions for checkout and a payment provider, not this plugin. See [docs/completion-payment.md](completion-payment.md) for the open questions. -- Applied discounts are reported as `discounts.applied[]` with per-target - allocations via `Cart.extra`, alongside the `items_discount` total - ([#212](https://github.com/shopware/agentic-commerce/pull/212)). An earlier - revision of this document said the SDK `Cart` model had no escape hatch for it. - **It never lacked one**: `Cart::$extra` has existed since SDK 0.0.3. -- `cart.update` no longer asks agents to repeat the cart id inside the payload. - UCP `2026-08-25` omits `cart.id` from update requests, and the MCP tool - description was corrected with the version switch - ([#214](https://github.com/shopware/agentic-commerce/pull/214)). +- Applied discounts are reported as `discounts.applied[]` with per-target allocations via + `Cart.extra`, alongside the `items_discount` total + ([#212](https://github.com/shopware/agentic-commerce/pull/212)). An earlier revision of this + document said the SDK `Cart` model had no escape hatch for it. **It never lacked one**: + `Cart::$extra` has existed since SDK 0.0.3. +- `cart.update` no longer asks agents to repeat the cart id inside the payload. UCP `2026-08-25` + omits `cart.id` from update requests, and the MCP tool description was corrected with the version + switch ([#214](https://github.com/shopware/agentic-commerce/pull/214)). ## QA-Only Surfaces Smoke-only runtime surfaces must stay out of the normal shipped contract: -- Test webhook capture is available only when `SWAG_AGENTIC_COMMERCE_TEST_CAPTURE=1` and the app is not running in `prod`. -- The smoke catalog seeder is hidden and available only when `SWAG_AGENTIC_COMMERCE_SMOKE_SEED=1` and the app is not running in `prod`. +- Test webhook capture is available only when `SWAG_AGENTIC_COMMERCE_TEST_CAPTURE=1` and the app is + not running in `prod`. +- The smoke catalog seeder is hidden and available only when `SWAG_AGENTIC_COMMERCE_SMOKE_SEED=1` + and the app is not running in `prod`. - `bin/ci-smoke.sh` sets both flags explicitly for local/CI QA. -- Package exports exclude tests, CI smoke scripts, local QA screenshots, and repository-only tooling via `.gitattributes`. +- Package exports exclude tests, CI smoke scripts, local QA screenshots, and repository-only tooling + via `.gitattributes`. diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 00000000..6ad21fe0 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,37 @@ +# Local Development (plugin maintainers) + +This section is about developing the plugin itself against three Shopware lanes. To run UCP on a +shop you already have, see [Set up UCP on a sales channel](#set-up-ucp-on-a-sales-channel) above; +the full lane workflow is in [docs/manual-testing.md](manual-testing.md). + +This repository keeps plugin source, QA tooling, and CI helpers only. Local Podman/Mutagen lane +orchestration is intentionally not versioned here, because it is workstation setup, not plugin code. + +If you use the three-lane setup (`trunk`, `6.6.x`, `6.5.x`), keep the bootstrap helpers outside the +repository, for example under `~/scripts/agentic-commerce/`. The local helpers support these +environment variables instead of hard-coded personal paths: + +- `AGENTIC_COMMERCE_PROJECTS_ROOT` +- `AGENTIC_COMMERCE_PLUGIN_ROOT` +- `AGENTIC_COMMERCE_SDK_ROOT` +- `AGENTIC_COMMERCE_SHOPWARE_TRUNK_ROOT` +- `AGENTIC_COMMERCE_SHOPWARE_66_ROOT` +- `AGENTIC_COMMERCE_SHOPWARE_65_ROOT` +- `AGENTIC_COMMERCE_BASE_URL` + +Add them to your shell profile (`~/.zshrc`, `~/.bashrc`, etc.): + +```bash +export AGENTIC_COMMERCE_PLUGIN_ROOT=~/Documents/Projects/SwagAgenticCommerce +export AGENTIC_COMMERCE_SDK_ROOT=~/Documents/Projects/ucp-php-sdk +export AGENTIC_COMMERCE_SHOPWARE_TRUNK_ROOT=~/Documents/Projects/shopware-trunk +export AGENTIC_COMMERCE_SHOPWARE_66_ROOT=~/Documents/Projects/shopware-6-6-branch +export AGENTIC_COMMERCE_SHOPWARE_65_ROOT=~/Documents/Projects/shopware-6-5-branch +export AGENTIC_COMMERCE_PROJECTS_ROOT=~/Documents/Projects +export AGENTIC_COMMERCE_BASE_URL=http://trunk.localhost:8088 +``` + +Adjust the paths to match your local checkout layout. + +The plugin stores tooling dependencies in `.tools/vendor`, not `vendor`, so lane-local Composer +installs do not collide with the Shopware runtime dependency graph. diff --git a/docs/manual-testing.md b/docs/manual-testing.md index c179735b..21f9ddad 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -1,32 +1,27 @@ # Manual Testing Guide -This document describes how a human tester validates `SwagAgenticCommerce` -across the supported Shopware lanes. - -Automated coverage (PHPUnit unit/integration/kernel suites, `bin/ci-smoke.sh`, -e2e Playwright) runs in CI; this document covers only what automation cannot. -Behavior is covered at the lowest layer that can express it — route, -request-context, and the **catalog/cart/checkout capability flows** (including -completing a checkout into a real order and reading it back) live in the `functional` -suite (`composer test:functional`, see [AGENTS.md](../AGENTS.md)). Shell -smoke is reserved for deployed-stack / on-the-wire concerns that a booted kernel -cannot observe: the outbound **signed order webhook** delivery, **tokenize** (needs -a signed request), lane-aware MCP detection + storefront-rendered `/llms.txt` and -`/agents.md`, the admin/storefront builds + UI shells, and signed-request -conformance. The REST happy-path, -profile/transport advertising, OAuth/tokenization `501` stubs, signed-webhook -header capture, admin module rendering, and storefront shell rendering are all -exercised automatically and need no manual repro — see the pointers throughout -this guide. - -Before testing, also read -[docs/shopware-version-differences.md](docs/shopware-version-differences.md). +This document describes how a human tester validates `SwagAgenticCommerce` across the supported +Shopware lanes. + +Automated coverage (PHPUnit unit/integration/kernel suites, `bin/ci-smoke.sh`, e2e Playwright) runs +in CI; this document covers only what automation cannot. Behavior is covered at the lowest layer +that can express it — route, request-context, and the **catalog/cart/checkout capability flows** +(including completing a checkout into a real order and reading it back) live in the `functional` +suite (`composer test:functional`, see [AGENTS.md](../AGENTS.md)). Shell smoke is reserved for +deployed-stack / on-the-wire concerns that a booted kernel cannot observe: the outbound **signed +order webhook** delivery, **tokenize** (needs a signed request), lane-aware MCP detection + +storefront-rendered `/llms.txt` and `/agents.md`, the admin/storefront builds + UI shells, and +signed-request conformance. The REST happy-path, profile/transport advertising, OAuth/tokenization +`501` stubs, signed-webhook header capture, admin module rendering, and storefront shell rendering +are all exercised automatically and need no manual repro — see the pointers throughout this guide. + +Before testing, also read [docs/shopware-version-differences.md](shopware-version-differences.md). That file is the memory for lane-specific traps. ## Scope -This guide is intentionally limited to scenarios that automation cannot -(or does not) cover. The core manual scenarios are: +This guide is intentionally limited to scenarios that automation cannot (or does not) cover. The +core manual scenarios are: - real-browser cross-origin iframe enforcement of the embedded cart - real MCP client connectivity against `/ucp/mcp` @@ -36,46 +31,43 @@ This guide is intentionally limited to scenarios that automation cannot - true concurrent checkout completion (the checkout lock) - signed / strict-signature request verification via the conformance suite -The build/runtime scaffolding (sync, install, administration and storefront -build + browser validation) also stays manual and is described below. +The build/runtime scaffolding (sync, install, administration and storefront build + browser +validation) also stays manual and is described below. -Still not covered as bundled shipped features: payment tokenization, -fulfillment, loyalty, and full agentic discovery/export. OAuth identity linking -is implemented but opt-in per sales channel. Payment tokenization is -extension-ready and must remain hidden/`501` unless a real tokenizing payment -handler is installed and enabled. The expected PSP implementation shape is -documented in -[docs/payment-tokenization-handler.md](docs/payment-tokenization-handler.md). +Still not covered as bundled shipped features: payment tokenization, fulfillment, loyalty, and full +agentic discovery/export. OAuth identity linking is implemented but opt-in per sales channel. +Payment tokenization is extension-ready and must remain hidden/`501` unless a real tokenizing +payment handler is installed and enabled. The expected PSP implementation shape is documented in +[docs/payment-tokenization-handler.md](payment-tokenization-handler.md). ## Supported Matrix -| Lane | Shopware ref | Local URL | Admin build modes | Discovery behavior | -| --- | --- | --- | --- | --- | -| `65` | `6.5.x` | `http://sw65.localhost:8088` | `webpack` | unavailable | -| `66` | `6.6.x` | `http://sw66.localhost:8088` | `webpack`, `vite` | unavailable | -| `trunk` | `trunk` | `http://trunk.localhost:8088` | `vite` | bridge may exist, export still out of scope | +| Lane | Shopware ref | Local URL | Admin build modes | Discovery behavior | +| ------- | ------------ | ----------------------------- | ----------------- | ------------------------------------------- | +| `65` | `6.5.x` | `http://sw65.localhost:8088` | `webpack` | unavailable | +| `66` | `6.6.x` | `http://sw66.localhost:8088` | `webpack`, `vite` | unavailable | +| `trunk` | `trunk` | `http://trunk.localhost:8088` | `vite` | bridge may exist, export still out of scope | ## Preconditions -- Local checkouts exist for the plugin, SDK, and each Shopware lane. Set the - following environment variables to point at them (see `README.md` § - *Local development* for the full list and suggested shell profile setup): +- Local checkouts exist for the plugin, SDK, and each Shopware lane. Set the following environment + variables to point at them (see [local-development.md](local-development.md) for the full list and + suggested shell profile setup): - | Variable | Points at | - | --- | --- | - | `AGENTIC_COMMERCE_PLUGIN_ROOT` | this repository | - | `AGENTIC_COMMERCE_SDK_ROOT` | `ucp-php-sdk` checkout | - | `AGENTIC_COMMERCE_SHOPWARE_65_ROOT` | `shopware` `6.5.x` checkout | - | `AGENTIC_COMMERCE_SHOPWARE_66_ROOT` | `shopware` `6.6.x` checkout | + | Variable | Points at | + | -------------------------------------- | --------------------------- | + | `AGENTIC_COMMERCE_PLUGIN_ROOT` | this repository | + | `AGENTIC_COMMERCE_SDK_ROOT` | `ucp-php-sdk` checkout | + | `AGENTIC_COMMERCE_SHOPWARE_65_ROOT` | `shopware` `6.5.x` checkout | + | `AGENTIC_COMMERCE_SHOPWARE_66_ROOT` | `shopware` `6.6.x` checkout | | `AGENTIC_COMMERCE_SHOPWARE_TRUNK_ROOT` | `shopware` `trunk` checkout | - Docker or Podman is available. - Mutagen is available. - `jq` and `curl` are available. -Do not use lane switching, `docker cp`, or manual rsync for this workflow. -Shopware, the plugin, and the SDK are synced per lane through persistent -two-way Mutagen sessions. +Do not use lane switching, `docker cp`, or manual rsync for this workflow. Shopware, the plugin, and +the SDK are synced per lane through persistent two-way Mutagen sessions. ## Sync And Startup @@ -99,18 +91,14 @@ Expected result: - every session is watching for changes - plugin and SDK files exist inside each lane container -Generated files are created inside the lane containers. Some Shopware checkout -changes can sync back and become dirty after builds, but UCP admin bundles are -lane-local/disposable: +Generated files are created inside the lane containers. Some Shopware checkout changes can sync back +and become dirty after builds, but UCP admin bundles are lane-local/disposable: -- `var/plugins.json` is generated per lane by Shopware and must not be copied - between lanes. -- `src/Resources/public/` and `public/bundles/swagagenticcommerce/` are ignored - by the lane sync helper and cleaned by `bin/ci-admin-smoke.sh` before each - admin build. -- UCP sales-channel config is stored in `swag_agentic_commerce_ucp_config`. - Legacy `SystemConfig` values are compatibility input only and should not be - copied or edited as the source of truth. +- `var/plugins.json` is generated per lane by Shopware and must not be copied between lanes. +- `src/Resources/public/` and `public/bundles/swagagenticcommerce/` are ignored by the lane sync + helper and cleaned by `bin/ci-admin-smoke.sh` before each admin build. +- UCP sales-channel config is stored in `swag_agentic_commerce_ucp_config`. Legacy `SystemConfig` + values are compatibility input only and should not be copied or edited as the source of truth. ## Running Commands In A Lane @@ -122,8 +110,8 @@ Use the lane helpers instead of raw Compose commands: ~/scripts/agentic-commerce/lane-shell trunk ``` -The Compose service is named `web` in every lane, but the helpers select the -correct project/container. +The Compose service is named `web` in every lane, but the helpers select the correct +project/container. ## Install The Plugin And SDK @@ -135,8 +123,8 @@ The normal local path repository setup is handled by: ~/scripts/agentic-commerce/bootstrap-lane trunk ``` -If you must install manually inside a lane container, configure Composer path -repositories against the synced container paths and require only the plugin: +If you must install manually inside a lane container, configure Composer path repositories against +the synced container paths and require only the plugin: ```bash composer config repositories.swag-agentic-commerce '{"type":"path","url":"custom/plugins/SwagAgenticCommerce","options":{"symlink":true}}' @@ -147,22 +135,19 @@ bin/console plugin:refresh bin/console plugin:install --activate SwagAgenticCommerce ``` -Use the matching lane version when requiring the plugin manually: -`6.5.9999999-dev` for 6.5, `6.6.9999999-dev` for 6.6, and -`6.7.9999999-dev` for trunk/current 6.7. +Use the matching lane version when requiring the plugin manually: `6.5.9999999-dev` for 6.5, +`6.6.9999999-dev` for 6.6, and `6.7.9999999-dev` for trunk/current 6.7. -The plugin directly requires `ucp-php-sdk/symfony-bundle`; SDK core is -resolved transitively by that bundle. Shopware packages are provided by the -active lane. The local SDK path repositories above are only needed while the -SDK packages are private/local. -Force a stable version that lies inside the window `composer.json` requires -(`0.0.7`; see the README section *The SDK version pin*). -Composer does not propagate alpha stability flags from the SDK bundle to the -root Shopware project, so alpha path aliases can make the transitive core -package unsatisfiable. +The plugin directly requires `ucp-php-sdk/symfony-bundle`; SDK core is resolved transitively by that +bundle. Shopware packages are provided by the active lane. The local SDK path repositories above are +only needed while the SDK packages are private/local. Force a stable version that lies inside the +window `composer.json` requires (`0.0.7`; see _The SDK version pin_ in +[releasing.md](releasing.md)). Composer does not propagate alpha stability flags from the SDK bundle +to the root Shopware project, so alpha path aliases can make the transitive core package +unsatisfiable. -The Composer `symlink` option is container-local package behavior. It is not -the old host-plugin-symlink workflow. +The Composer `symlink` option is container-local package behavior. It is not the old +host-plugin-symlink workflow. ## Recommended Test Order @@ -187,34 +172,31 @@ composer test:integration composer test:functional # requires a lane: SHOPWARE_PROJECT_DIR unset + APP_ENV=test ``` -Expected result: all commands pass. `composer test:functional` boots a real test -kernel and drives UCP routes through a real Symfony browser; like core's functional -tests it assumes a booted kernel, so run it against a configured lane (not the -fast-path bootstrap used by plain `composer ci`). +Expected result: all commands pass. `composer test:functional` boots a real test kernel and drives +UCP routes through a real Symfony browser; like core's functional tests it assumes a booted kernel, +so run it against a configured lane (not the fast-path bootstrap used by plain `composer ci`). ## Automated REST / Profile / Webhook Coverage -The UCP REST happy path is exercised automatically by `bin/ci-smoke.sh` in the -`shopware-matrix` CI jobs on every lane, so it needs no manual repro. That -script covers, per lane: +The UCP REST happy path is exercised automatically by `bin/ci-smoke.sh` in the `shopware-matrix` CI +jobs on every lane, so it needs no manual repro. That script covers, per lane: -- `GET /.well-known/ucp` profile with lane-aware transports - (REST/A2A/embedded everywhere, MCP only when the Store API MCP endpoint - exists), the enabled shopping capabilities, and an empty `payment_handlers` - object -- the "MCP supported ⇔ MCP advertised" invariant (server-side - `StoreApiMcpServerController` class check) +- `GET /.well-known/ucp` profile with lane-aware transports (REST/A2A/embedded everywhere, MCP only + when the Store API MCP endpoint exists), the enabled shopping capabilities, and an empty + `payment_handlers` object +- the "MCP supported ⇔ MCP advertised" invariant (server-side `StoreApiMcpServerController` + class check) - fallback `/llms.txt` and `/agents.md` when core agentic files are absent -- `tokenize` → `501` (the payment endpoint needs a *signed* request over real HTTP) -- checkout create/get/update/complete → a real Shopware order, and secured order - read (the checkout stage resolves the seeded product itself, then drives the webhook) -- signed outbound webhook capture (`signature`, `signature-input`, and - `content-digest` headers present) +- `tokenize` → `501` (the payment endpoint needs a _signed_ request over real HTTP) +- checkout create/get/update/complete → a real Shopware order, and secured order read (the + checkout stage resolves the seeded product itself, then drives the webhook) +- signed outbound webhook capture (`signature`, `signature-input`, and `content-digest` headers + present) -The OAuth-metadata `501`, the missing-`UCP-Agent` `422` guard, and the catalog/cart -capability flows are **no longer** in shell smoke — they are covered by the `functional` -PHPUnit suite (`UcpRequestContextGuardTest`, `UcpCatalogFlowTest`, `UcpCartFlowTest`), -which also gates on every `shopware-matrix` lane. +The OAuth-metadata `501`, the missing-`UCP-Agent` `422` guard, and the catalog/cart capability flows +are **no longer** in shell smoke — they are covered by the `functional` PHPUnit suite +(`UcpRequestContextGuardTest`, `UcpCatalogFlowTest`, `UcpCartFlowTest`), which also gates on every +`shopware-matrix` lane. You can still run the smoke runner locally if you want a fast confidence pass: @@ -224,26 +206,24 @@ bin/ci-smoke.sh "$AGENTIC_COMMERCE_SHOPWARE_66_ROOT" bin/ci-smoke.sh "$AGENTIC_COMMERCE_SHOPWARE_TRUNK_ROOT" ``` -These resolve the SDK from Packagist at the versions `composer.json` pins, which is what a -merchant installs. To smoke a local SDK checkout instead, prefix with +These resolve the SDK from Packagist at the versions `composer.json` pins, which is what a merchant +installs. To smoke a local SDK checkout instead, prefix with `UCP_SDK_SOURCE=path SDK_ROOT=/path/to/ucp-php-sdk`. -> **Runtime header note:** every `/ucp/...` runtime request must carry a -> `UCP-Agent` header (ucp-php-sdk request-time validation) or it returns `422` -> before reaching the capability. Any manual curl below that hits a runtime -> endpoint therefore includes +> **Runtime header note:** every `/ucp/...` runtime request must carry a `UCP-Agent` header +> (ucp-php-sdk request-time validation) or it returns `422` before reaching the capability. Any +> manual curl below that hits a runtime endpoint therefore includes > `-H 'UCP-Agent: