diff --git a/.config/shopware-extension.yml b/.config/shopware-extension.yml
new file mode 100644
index 0000000..8daab5d
--- /dev/null
+++ b/.config/shopware-extension.yml
@@ -0,0 +1,22 @@
+compatibility_date: 2026-02-11
+# shopware-cli extension configuration (build/package/validate)
+build:
+ zip:
+ pack:
+ excludes:
+ paths:
+ - tests
+ - docs
+ - scripts
+ - phpunit.xml
+ - docker-compose.yml
+ - skills-lock.json
+ - AGENTS.md
+ - CLAUDE.md
+ - QWEN.md
+ - .config
+ - .gitea
+ - .claude
+ - .scratch
+validation:
+ ignore: []
diff --git a/.gitea/actions/package/action.yml b/.gitea/actions/package/action.yml
new file mode 100644
index 0000000..131f957
--- /dev/null
+++ b/.gitea/actions/package/action.yml
@@ -0,0 +1,35 @@
+name: package-extension
+description: >-
+ Package the Shopware extension with shopware-cli (builds admin/storefront assets), validate the
+ zip and assert it contains the built JS. Shared by the PR checks and the release workflow.
+ Must run in the ghcr.io/shopware/shopware-cli image, after actions/checkout.
+
+inputs:
+ filename:
+ description: Name of the zip written to .build/
+ default: package.zip
+ build:
+ description: "'false' skips packaging (a prebuilt zip is already in .build/) and only validates it"
+ default: 'true'
+
+runs:
+ using: composite
+ steps:
+ - uses: actions/cache@v4
+ if: inputs.build == 'true'
+ with:
+ path: |
+ ~/.npm
+ ~/.cache/shopware-cli
+ key: swcli-${{ runner.os }}-${{ hashFiles('composer.json', 'src/Resources/app/**/package*.json') }}
+ restore-keys: swcli-${{ runner.os }}-
+ - name: Package extension
+ if: inputs.build == 'true'
+ shell: bash
+ run: shopware-cli extension package . --output-directory .build --filename "${{ inputs.filename }}" --git-commit "$GITHUB_SHA"
+ - name: Validate packaged zip
+ shell: bash
+ run: shopware-cli extension validate ".build/${{ inputs.filename }}"
+ - name: Assert built JS is in the zip
+ shell: bash
+ run: scripts/ci/assert-built-assets.sh ".build/${{ inputs.filename }}"
diff --git a/.gitea/workflows/plugin-checks.yml b/.gitea/workflows/plugin-checks.yml
new file mode 100644
index 0000000..779a0ce
--- /dev/null
+++ b/.gitea/workflows/plugin-checks.yml
@@ -0,0 +1,195 @@
+name: plugin-checks
+
+# Plugin-agnostic: nothing below names this plugin. Name, version and paths come from composer.json.
+# shopware-cli runs from its official Docker image, intentionally unpinned (latest); a red build
+# after a CLI release means fix the config.
+
+on:
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+# A new push to the PR cancels the superseded run.
+concurrency:
+ group: plugin-checks-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ # Every PR into main must bump the version and document it in CHANGELOG.md.
+ version-changelog:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ # The version must be higher than the latest *published release* (not just higher than main), so main can
+ # carry a version whose release failed or was withdrawn without blocking the next PR.
+ - name: Check version is unreleased and changelog entry exists
+ env:
+ TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ run: |
+ set -e
+ PR_VERSION=$(grep -m1 '"version"' composer.json | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
+ LATEST=$(curl -sf -H "Authorization: token ${TOKEN}" "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases?limit=100" \
+ | jq -r '.[].tag_name' | sed 's/^v//' | sort -V | tail -n1)
+ LATEST="${LATEST:-0.0.0}"
+
+ echo "latest release: $LATEST"
+ echo "PR version: $PR_VERSION"
+
+ if [ "$PR_VERSION" = "$LATEST" ] || [ "$(printf '%s\n%s\n' "$LATEST" "$PR_VERSION" | sort -V | tail -n1)" != "$PR_VERSION" ]; then
+ echo "::error::composer.json version ($PR_VERSION) is not greater than the latest release ($LATEST)"
+ exit 1
+ fi
+
+ if ! grep -qF "## [$PR_VERSION]" CHANGELOG.md; then
+ echo "::error::CHANGELOG.md is missing a '## [$PR_VERSION]' heading"
+ exit 1
+ fi
+
+ echo "OK: $PR_VERSION is unreleased and documented in CHANGELOG.md"
+
+ # Packages the extension (builds admin/storefront assets) and validates the resulting zip,
+ # which is the artifact that ships. A separate directory validate would only repeat these checks.
+ package:
+ runs-on: ubuntu-latest
+ container:
+ image: ghcr.io/shopware/shopware-cli:latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: ./.gitea/actions/package
+ # The release workflow reuses this tested zip when main ends up with the same tree
+ # (it falls back to packaging again otherwise).
+ - name: Tree hash of the packaged commit
+ id: tree
+ run: echo "hash=$(git rev-parse "${GITHUB_SHA}^{tree}")" >> "$GITHUB_OUTPUT"
+ - name: Save tested zip for the release
+ uses: actions/cache/save@v4
+ with:
+ path: .build/package.zip
+ key: plugin-zip-${{ steps.tree.outputs.hash }}
+
+ # One run per supported Shopware minor (keep in sync with require.shopware/core in composer.json).
+ phpunit:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ shopware: ['6.6', '6.7']
+ # Same image as above: it already ships PHP, Composer and Node, so no apt install.
+ container:
+ image: ghcr.io/shopware/shopware-cli:latest
+ services:
+ database:
+ # Shopware 6.6 and 6.7 both support MariaDB 10.11; 11.4 broke 6.6's own migrations.
+ image: mariadb:10.11
+ env:
+ MARIADB_ROOT_PASSWORD: root
+ env:
+ # Already the *_test name: Shopware's TestBootstrapper would otherwise append _test and run every migration a second time.
+ DATABASE_URL: mysql://root:root@database:3306/shopware_test
+ APP_ENV: test
+ APP_URL: http://localhost
+ APP_SECRET: ci-secret-not-for-production
+ INSTANCE_ID: ci
+ COMPOSER_MEMORY_LIMIT: '-1'
+ COMPOSER_ALLOW_SUPERUSER: '1'
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ path: plugin-src
+ # The cache key follows the Shopware version: a new release inside the plugin's supported range changes
+ # the key, so the old project + database are never reused. Bump -v1 to force a refresh by hand.
+ - name: Resolve the latest Shopware Version
+ id: sw
+ run: |
+ set -e
+ # The matrix minor (e.g. ~6.6.0) decides which versions are acceptable;
+ # Composer lists the matching ones newest first, the first stable one is the latest.
+ CONSTRAINT="~${{ matrix.shopware }}.0"
+ VERSION=$(composer show shopware/core "$CONSTRAINT" --all --format=json --no-interaction 2>/dev/null \
+ | php -r '$d = json_decode(stream_get_contents(STDIN), true); foreach ($d["versions"] as $v) { if (preg_match("/^v?\d+(\.\d+)+$/", $v)) { echo ltrim($v, "v"); exit; } }')
+ test -n "$VERSION" || { echo "::error::could not resolve a Shopware version for ${CONSTRAINT}"; exit 1; }
+ echo "Shopware ${VERSION} (constraint ${CONSTRAINT})"
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "constraint=${CONSTRAINT}" >> "$GITHUB_OUTPUT"
+ # Project (vendor/, phpunit, config) and the installed database as a SQL dump. Neither depends on the plugin.
+ - name: Restore Shopware project and database
+ id: shopware
+ uses: actions/cache/restore@v4
+ with:
+ path: |
+ shopware
+ !shopware/var/cache
+ !shopware/var/log
+ shopware-db.sql.gz
+ key: shopware-${{ steps.sw.outputs.version }}-installed-v1
+ - name: Create Shopware project
+ if: steps.shopware.outputs.cache-hit != 'true'
+ run: |
+ set -e
+ # shopware/production pins one exact core version and is not tagged for every core patch
+ # (e.g. core 6.6.10.31, template only up to 6.6.10.30): take the newest template inside the
+ # constraint, then require the resolved core version on top of it.
+ composer create-project "shopware/production:${{ steps.sw.outputs.constraint }}" shopware --no-interaction --no-scripts --no-install
+ composer require "shopware/core:${{ steps.sw.outputs.version }}" --no-update -d shopware
+ composer require --dev phpunit/phpunit:^10 --no-update -d shopware
+ composer update -W --no-interaction --no-scripts -d shopware
+ - name: Ensure the MariaDB client
+ run: |
+ command -v mariadb-dump >/dev/null || command -v mysqldump >/dev/null && exit 0
+ (apt-get update -q && apt-get install -y -q --no-install-recommends mariadb-client) || apk add --no-cache mariadb-client
+ - name: Wait for the database
+ run: |
+ for i in $(seq 60); do
+ (echo > /dev/tcp/database/3306) 2>/dev/null && exit 0
+ sleep 2
+ done
+ echo "::error::database service did not come up"; exit 1
+ - name: Relax database durability (throwaway CI database, speeds up the install and the restore)
+ run: |
+ php -r '$p = new PDO("mysql:host=database;port=3306", "root", "root"); $p->exec("SET GLOBAL innodb_flush_log_at_trx_commit=0"); $p->exec("SET GLOBAL sync_binlog=0");'
+ # One install into shopware_test; the bootstrapper then sees an installed DB, skips its own install and
+ # only activates the plugin (tests/TestBootstrap.php forces plugin installation).
+ - name: Install Shopware
+ if: steps.shopware.outputs.cache-hit != 'true'
+ working-directory: shopware
+ run: bin/console system:install --basic-setup --create-database --force --no-interaction
+ - name: Dump the installed database
+ if: steps.shopware.outputs.cache-hit != 'true'
+ run: |
+ set -e
+ DUMP=$(command -v mariadb-dump || command -v mysqldump)
+ "$DUMP" -h database -P 3306 -u root -proot --skip-ssl --single-transaction --routines --triggers \
+ --databases shopware_test | gzip > shopware-db.sql.gz
+ ls -l shopware-db.sql.gz
+ - name: Save Shopware project and database
+ if: steps.shopware.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v4
+ with:
+ path: |
+ shopware
+ !shopware/var/cache
+ !shopware/var/log
+ shopware-db.sql.gz
+ key: shopware-${{ steps.sw.outputs.version }}-installed-v1
+ - name: Restore the database from the cache
+ if: steps.shopware.outputs.cache-hit == 'true'
+ run: |
+ set -e
+ CLIENT=$(command -v mariadb || command -v mysql)
+ gunzip -c shopware-db.sql.gz | "$CLIENT" -h database -P 3306 -u root -proot --skip-ssl
+ - name: Resolve plugin name and install the plugin sources
+ run: |
+ # plugin directory name = short class name of extra.shopware-plugin-class
+ CLASS=$(php -r 'echo json_decode(file_get_contents("plugin-src/composer.json"), true)["extra"]["shopware-plugin-class"];')
+ PLUGIN_NAME="${CLASS##*\\}"
+ echo "PLUGIN_NAME=${PLUGIN_NAME}" >> "$GITHUB_ENV"
+ rm -rf "shopware/custom/plugins/${PLUGIN_NAME}"
+ cp -r plugin-src "shopware/custom/plugins/${PLUGIN_NAME}"
+ - name: Run phpunit
+ working-directory: shopware
+ run: vendor/bin/phpunit -c "custom/plugins/${PLUGIN_NAME}/phpunit.xml"
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..7d8bcda
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,154 @@
+name: release
+
+# Plugin-agnostic: owner, repo, host, package name and version come from the `github` context
+# and composer.json. shopware-cli runs from its official Docker image, intentionally unpinned (latest).
+# RELEASE_TOKEN is the single token used for releases, assets and the Composer registry.
+#
+# Every step is idempotent (release/asset reuse, registry 409 = already published), so a failed run can
+# simply be re-run and picks up where it stopped. There is deliberately no "tag exists -> skip" guard:
+# it made a half-finished release (tag + release but no registry package) impossible to heal.
+
+on:
+ push:
+ branches: [main]
+
+permissions:
+ contents: write
+ packages: write
+
+# Never run two releases at once; a second push waits instead of racing on the tag.
+concurrency:
+ group: release
+ cancel-in-progress: false
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ container:
+ image: ghcr.io/shopware/shopware-cli:latest
+ steps:
+ - name: Ensure helper tools (jq, curl, unzip, zip, git)
+ run: |
+ command -v jq >/dev/null && command -v curl >/dev/null && command -v unzip >/dev/null && command -v zip >/dev/null && command -v git >/dev/null && exit 0
+ (apt-get update -q && apt-get install -y -q --no-install-recommends jq curl unzip zip git) || apk add --no-cache jq curl unzip zip git
+
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.RELEASE_TOKEN }}
+
+ - name: Determine version, tag and file names
+ id: meta
+ run: |
+ set -e
+ VERSION=$(jq -r .version composer.json)
+ PACKAGE_NAME=$(jq -r .name composer.json)
+ REPO_NAME="${GITHUB_REPOSITORY#*/}"
+ TAG="v${VERSION}"
+ {
+ echo "version=${VERSION}"
+ echo "package_name=${PACKAGE_NAME}"
+ echo "tag=${TAG}"
+ echo "zip=${REPO_NAME}-${TAG}.zip"
+ } >> "$GITHUB_OUTPUT"
+
+ # Reuse the zip the PR workflow already built and tested for this exact tree.
+ - name: Tree hash of this commit
+ id: tree
+ run: echo "hash=$(git rev-parse "${GITHUB_SHA}^{tree}")" >> "$GITHUB_OUTPUT"
+
+ - name: Restore the tested zip from the PR run
+ id: prebuilt
+ uses: actions/cache/restore@v4
+ with:
+ path: .build/package.zip
+ key: plugin-zip-${{ steps.tree.outputs.hash }}
+
+ - name: Name the prebuilt zip
+ if: steps.prebuilt.outputs.cache-hit == 'true'
+ run: mv .build/package.zip ".build/${{ steps.meta.outputs.zip }}"
+
+ # Packages only on a cache miss; always validates and asserts what ships (guards a bad cache entry).
+ - uses: ./.gitea/actions/package
+ with:
+ filename: ${{ steps.meta.outputs.zip }}
+ build: ${{ steps.prebuilt.outputs.cache-hit != 'true' }}
+
+ - name: Extract changelog section
+ run: |
+ awk -v ver="${{ steps.meta.outputs.version }}" '
+ BEGIN { found=0 }
+ /^## \[/ {
+ if (found) exit
+ if ($0 ~ "\\[" ver "\\]") { found=1; next }
+ }
+ found { print }
+ ' CHANGELOG.md > release-notes.md
+ cat release-notes.md
+
+ - name: Create Gitea release (creates the tag) and upload the zip
+ env:
+ TOKEN: ${{ secrets.RELEASE_TOKEN }}
+ run: |
+ set -e
+ API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
+ TAG="${{ steps.meta.outputs.tag }}"
+ ZIP="${{ steps.meta.outputs.zip }}"
+ CODE=$(curl -s -o /tmp/release.json -w "%{http_code}" -H "Authorization: token ${TOKEN}" "${API}/releases/tags/${TAG}")
+ if [ "$CODE" = "200" ]; then
+ echo "Release ${TAG} already exists, reusing it."
+ else
+ BODY=$(jq -n --arg tag "$TAG" --arg sha "$GITHUB_SHA" --rawfile notes release-notes.md \
+ '{tag_name:$tag, target_commitish:$sha, name:$tag, body:$notes}')
+ curl -sf -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" -d "$BODY" "${API}/releases" > /tmp/release.json
+ fi
+ RELEASE_ID=$(jq -r .id /tmp/release.json)
+ echo "release id: ${RELEASE_ID}"
+ if jq -e --arg n "$ZIP" '.assets[]? | select(.name == $n)' /tmp/release.json > /dev/null; then
+ echo "Asset ${ZIP} already uploaded."
+ else
+ curl -sf -H "Authorization: token ${TOKEN}" -F "attachment=@.build/${ZIP}" \
+ "${API}/releases/${RELEASE_ID}/assets?name=${ZIP}" > /dev/null
+ fi
+
+ # Gitea's Composer registry needs composer.json at the zip root, but shopware-cli zips wrap
+ # everything in one top-level folder (the registry answers 400 "composer.json file is missing").
+ # Same prebuilt content, repacked root-level.
+ - name: Repack the zip for the Composer registry
+ run: |
+ set -e
+ rm -rf /tmp/repack && mkdir /tmp/repack
+ unzip -q ".build/${{ steps.meta.outputs.zip }}" -d /tmp/repack
+ if [ -f /tmp/repack/composer.json ]; then
+ ROOT=/tmp/repack
+ else
+ ROOT=$(find /tmp/repack -mindepth 1 -maxdepth 1 -type d | head -1)
+ fi
+ test -f "$ROOT/composer.json" || { echo "::error::no composer.json found in the packaged zip"; exit 1; }
+ OUT="$PWD/.build/composer-${{ steps.meta.outputs.zip }}"
+ # explicit file list with the leading "./" stripped, so entries are stored as "composer.json", "src/..."
+ (cd "$ROOT" && find . -mindepth 1 | sed 's|^\./||' | zip -q "$OUT" -@)
+ unzip -l "$OUT" | grep -E ' composer\.json$' > /dev/null || { echo "::error::repacked zip has no root-level composer.json"; exit 1; }
+ echo "repacked: $OUT"
+
+ - name: Publish to the Composer registry
+ run: |
+ STATUS=$(curl -s -o /tmp/composer-publish.log -w "%{http_code}" \
+ -u "${GITHUB_REPOSITORY_OWNER}:${{ secrets.RELEASE_TOKEN }}" \
+ -T ".build/composer-${{ steps.meta.outputs.zip }}" \
+ "${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/composer")
+ cat /tmp/composer-publish.log
+ if [ "$STATUS" != "201" ] && [ "$STATUS" != "409" ]; then
+ echo "Unexpected HTTP status $STATUS publishing to the Composer registry"
+ exit 1
+ fi
+
+ - name: Link Composer package to this repository
+ run: |
+ ENCODED_NAME=$(printf '%s' "${{ steps.meta.outputs.package_name }}" | sed 's#/#%2F#g')
+ STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "${GITHUB_REPOSITORY_OWNER}:${{ secrets.RELEASE_TOKEN }}" -X POST \
+ "${GITHUB_SERVER_URL}/api/v1/packages/${GITHUB_REPOSITORY_OWNER}/composer/${ENCODED_NAME}/-/link/${GITHUB_REPOSITORY#*/}")
+ echo "link status: $STATUS"
+ if [ "$STATUS" != "201" ] && [ "$STATUS" != "400" ]; then
+ echo "::warning::Unexpected status linking the Composer package to this repository (HTTP $STATUS) - not fatal, release already published"
+ fi
diff --git a/.gitignore b/.gitignore
index 13ff59d..66bf08c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,54 +1,39 @@
-# ---> Symfony
-# Cache and logs (Symfony2)
-/app/cache/*
-/app/logs/*
-!app/cache/.gitkeep
-!app/logs/.gitkeep
+# PHP
+vendor/
+composer.phar
+.phpunit.result.cache
+.phpunit.cache/
+.php-cs-fixer.cache
-# Email spool folder
-/app/spool/*
+# Symfony / Shopware
+/var/
+/public/bundles/
+/public/assets/
+/custom/
+/src/Shopware/Platform
-# Cache, session files and logs (Symfony3)
-/var/cache/*
-/var/logs/*
-/var/sessions/*
-!var/cache/.gitkeep
-!var/logs/.gitkeep
-!var/sessions/.gitkeep
+# Frontend
+*.vite/
+**/public/administration/assets/
+**/public/static/js/
-# Logs (Symfony4)
-/var/log/*
-!var/log/.gitkeep
+# Environment
+.env
+.env.local
+.env.*.local
-# Parameters
-/app/config/parameters.yml
-/app/config/parameters.ini
+# Agent skills (symlinked via skills.sh, tracked through skills-lock.json)
+/.agents/
+/.claude/
+/.qwen/
+/.opencode/
+/.scratch/
-# Managed by Composer
-/app/bootstrap.php.cache
-/var/bootstrap.php.cache
-/bin/*
-!bin/console
-!bin/symfony_requirements
-/vendor/
-
-# Assets and user uploads
-/web/bundles/
-/web/uploads/
-
-# PHPUnit
-/app/phpunit.xml
-/phpunit.xml
-
-# Build data
-/build/
-
-# Composer PHAR
-/composer.phar
-
-# Backup entities generated with doctrine:generate:entities command
-**/Entity/*~
-
-# Embedded web-server pid file
-/.web-server-pid
+# IDE
+.idea/
+.vscode/
+*.swp
+# OS
+.DS_Store
+Thumbs.db
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..a6fce0a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+@CLAUDE.md
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f4fd31c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,15 @@
+# Changelog
+
+All notable changes to AeonShopwareTools will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+## [0.0.1] - 2026-11-02
+
+### Added
+
+- Plugin skeleton for Shopware 6.6 and 6.7 (`shopware/core ~6.6.0 || ~6.7.0`, PHP `^8.2`).
+- PR checks (version/changelog, package + validate, phpunit against the latest 6.6 and 6.7) and the release pipeline (Gitea Release + Composer registry), taken over from AeonDumpManager.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..68c33c4
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,40 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this is
+
+AeonShopwareTools is a public Shopware 6 plugin (`shopware-platform-plugin`, MIT) that bundles scripting
+and developer tooling. It supports **Shopware 6.6 and 6.7 from one codebase**
+(`shopware/core ~6.6.0 || ~6.7.0`, PHP `^8.2`): only use APIs that exist in both, and test against both.
+
+- Plugin bootstrap class: `src/AeonShopwareTools.php` (wired via `composer.json` → `extra.shopware-plugin-class`).
+- Services, commands and scheduled tasks are registered explicitly in `src/Resources/config/services.yaml`
+ (create it with the first service; no autowiring), each with its tag (`console.command`,
+ `shopware.scheduled.task`, ...).
+
+## Running the plugin
+
+There is no standalone runtime. `docker-compose.yml` mounts the repo into a `dockware/shopware` container at
+`custom/plugins/AeonShopwareTools`; install with `bin/console plugin:refresh` and
+`bin/console plugin:install --activate AeonShopwareTools` inside the container.
+
+## Tests
+
+PHPUnit through Shopware's `TestBootstrapper` (`tests/TestBootstrap.php`), so tests need a real Shopware
+installation and database.
+
+```bash
+vendor/bin/phpunit -c custom/plugins/AeonShopwareTools/phpunit.xml
+```
+
+## CI / release pipeline
+
+Same Gitea pipeline as AeonDumpManager, plugin-agnostic (name/version come from `composer.json`):
+
+- `workflows/plugin-checks.yml` (PRs into `main`): `version-changelog` (version greater than the latest
+ release, matching `## [x.y.z]` heading in CHANGELOG.md), `package` (`shopware-cli` package + validate,
+ `scripts/ci/assert-built-assets.sh`) and `phpunit`, a matrix over the latest 6.6 and 6.7. Keep the matrix
+ in sync with `require.shopware/core`.
+- `workflows/release.yml` (push to `main`): Gitea Release with the zip, Composer registry publish. Uses `RELEASE_TOKEN`.
+- Required checks on `main` are set with `scripts/protect-main.sh` (`tea api`).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..1af5d86
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Arthur Erlich
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/QWEN.md b/QWEN.md
new file mode 100644
index 0000000..a6fce0a
--- /dev/null
+++ b/QWEN.md
@@ -0,0 +1 @@
+@CLAUDE.md
\ No newline at end of file
diff --git a/README.md b/README.md
index ff8bf79..1e40d34 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,23 @@
# AeonShopwareTools
+Scripting and developer tooling for Shopware 6.6 and 6.7.
+
+## Installation
+
+```bash
+composer require arthurerlich/aeon-shopware-tools
+bin/console plugin:refresh
+bin/console plugin:install --activate AeonShopwareTools
+```
+
+Or drop the release zip into `custom/plugins/` and install it from the admin.
+
+## Compatibility
+
+| Shopware | PHP |
+|----------|--------|
+| 6.6, 6.7 | ^8.2 |
+
+## License
+
+MIT, see [LICENSE](LICENSE). Changes are listed in [CHANGELOG.md](CHANGELOG.md).
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..7f7ac43
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,65 @@
+{
+ "name": "arthurerlich/aeon-shopware-tools",
+ "description": "Scripting and developer tooling for Shopware 6.6 and 6.7.",
+ "type": "shopware-platform-plugin",
+ "license": "MIT",
+ "version": "0.0.1",
+ "keywords": [
+ "shopware",
+ "shopware6",
+ "plugin",
+ "tooling",
+ "scripting",
+ "developer-tools"
+ ],
+ "homepage": "https://git.arthurerlich.de/haylan/AeonShopwareTools",
+ "authors": [
+ {
+ "name": "Arthur Erlich",
+ "homepage": "https://arthurerlich.de",
+ "role": "Developer"
+ }
+ ],
+ "support": {
+ "issues": "https://git.arthurerlich.de/haylan/AeonShopwareTools/issues",
+ "source": "https://git.arthurerlich.de/haylan/AeonShopwareTools"
+ },
+ "require": {
+ "php": "^8.2",
+ "shopware/core": "~6.6.0 || ~6.7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.0"
+ },
+ "extra": {
+ "shopware-plugin-class": "AeonShopwareTools\\AeonShopwareTools",
+ "copyright": "(c) by Arthur Erlich",
+ "label": {
+ "de-DE": "Shopware Tools",
+ "en-GB": "Shopware Tools"
+ },
+ "description": {
+ "de-DE": "Für Entwickler und Betreiber von Shopware 6.6 und 6.7 bündelt Shopware Tools Skripte und Konsolenwerkzeuge, die wiederkehrende Aufgaben im Shop automatisieren.",
+ "en-GB": "For developers and operators of Shopware 6.6 and 6.7, Shopware Tools bundles scripts and console tooling that automate recurring maintenance tasks in the shop."
+ },
+ "manufacturerLink": {
+ "de-DE": "https://arthurerlich.de",
+ "en-GB": "https://arthurerlich.de"
+ },
+ "supportLink": {
+ "de-DE": "https://git.arthurerlich.de/haylan/AeonShopwareTools/issues",
+ "en-GB": "https://git.arthurerlich.de/haylan/AeonShopwareTools/issues"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "AeonShopwareTools\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "AeonShopwareTools\\Tests\\": "tests/"
+ }
+ },
+ "minimum-stability": "stable"
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f148ac3
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,31 @@
+services:
+ shopware:
+ image: dockware/shopware:6.7.14.2
+ container_name: shopware
+ ports:
+ - "80:80" # Shop / Admin / Adminer / Mailcatcher (all served via path on this port)
+ - "443:443" # HTTPS
+ #- "3306:3306" # MySQL (exposed for external DB clients, e.g. TablePlus/DBeaver)
+ - "22:22" # SSH into the container
+ - "8888:8888" # Admin watcher (bin/build-administration.sh --watch)
+ - "9998:9998" # Storefront watcher proxy
+ - "9999:9999" # Storefront watcher
+ volumes:
+ - "db_volume:/var/lib/mysql"
+ - "shop_volume:/var/www/html"
+ - "./:/var/www/html/custom/plugins/AeonShopwareTools"
+ networks:
+ - web
+ environment:
+ - PHP_VERSION=8.3
+ - XDEBUG_ENABLED=1
+ - SW_CURRENCY=EUR
+ - SSH_USER=shopware
+ - SSH_PWD=shopware
+
+volumes:
+ db_volume:
+ shop_volume:
+
+networks:
+ web:
\ No newline at end of file
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..1e8cb61
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ ./src/
+
+
+
+
+
+
+
+
+
+
+
+ tests
+
+
+
diff --git a/scripts/ci/assert-built-assets.sh b/scripts/ci/assert-built-assets.sh
new file mode 100644
index 0000000..9baba7a
--- /dev/null
+++ b/scripts/ci/assert-built-assets.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Assert that a packaged extension zip contains the built admin/storefront JS when the sources exist.
+# Usage: scripts/ci/assert-built-assets.sh (run from the repo root; BusyBox-safe: uses unzip -l)
+set -euo pipefail
+ZIP="${1:?usage: assert-built-assets.sh }"
+LIST=$(unzip -l "$ZIP")
+
+check() { #