diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5dfcc62 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab + +[*.{yml,yaml,json,xml,md}] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1bcff2a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: PHP console plugin + +on: + pull_request: + push: + branches: [main, feature/jcb-mcp-runtime] + +permissions: + contents: read + +concurrency: + group: plugin-${{ github.ref }} + cancel-in-progress: true + +jobs: + package: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4'] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, intl, mbstring, simplexml, zip + coverage: none + - name: PHP syntax + run: find . -type f -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l + - name: Manifest and reproducible package contracts + run: | + php tests/run.php + php tests/release.php + - uses: actions/checkout@v7 + with: + repository: joomengine/mcp_component + ref: feature/jcb-mcp-runtime + path: build/component-contract + persist-credentials: false + - name: Native Joomla console registration and runtime contracts + env: + JOOMLA_ROOT: ${{ runner.temp }}/native-joomla + MCP_COMPONENT_SOURCE: ${{ github.workspace }}/build/component-contract + run: | + bash tests/prepare-native.sh + php tests/native.php + - uses: actions/upload-artifact@v7 + if: matrix.php == '8.3' + with: + name: console-plugin-development-package + path: | + build/plg_console_joomengine_mcp-*.zip + build/plg_console_joomengine_mcp-*.zip.sha256 + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/installed.yml b/.github/workflows/installed.yml new file mode 100644 index 0000000..6cbf19f --- /dev/null +++ b/.github/workflows/installed.yml @@ -0,0 +1,83 @@ +name: Installed Joomla console plugin + +on: + workflow_call: + inputs: + component_ref: + type: string + default: main + pull_request: + push: + branches: [main, feature/jcb-mcp-runtime] + +permissions: + contents: read + +concurrency: + group: plugin-installed-${{ github.ref }} + cancel-in-progress: true + +jobs: + installed: + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4'] + services: + mysql: + image: mysql:8.4 + env: + MYSQL_DATABASE: mcp_fixture + MYSQL_USER: mcp_test + MYSQL_PASSWORD: disposable-test-password + MYSQL_ROOT_PASSWORD: disposable-root-password + ports: ['3306:3306'] + options: >- + --health-cmd "mysqladmin ping -h localhost -pdisposable-root-password" + --health-interval 5s --health-timeout 5s --health-retries 15 + steps: + - uses: actions/checkout@v7 + with: + path: plugin + persist-credentials: false + - uses: actions/checkout@v7 + with: + repository: joomengine/mcp_component + ref: ${{ inputs.component_ref || (github.ref_name == 'main' && 'main' || 'feature/jcb-mcp-runtime') }} + path: component + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, dom, fileinfo, intl, json, mbstring, mysqli, pdo_mysql, simplexml, sodium, xml, zip + tools: composer:v2 + coverage: none + - name: Build the matching component + working-directory: component + run: | + bash tools/build.sh + bash tools/build-distribution.sh + - name: Install and exercise this plugin checkout through native Joomla CLI + working-directory: component + env: + MCP_TEST_ALLOW_DESTRUCTIVE: '1' + JOOMLA_ROOT: ${{ runner.temp }}/joomla + MCP_PLUGIN_SOURCE: ${{ github.workspace }}/plugin + MCP_TEST_DB_TYPE: mysqli + MCP_TEST_DB_HOST: 127.0.0.1:3306 + MCP_TEST_DB_USER: mcp_test + MCP_TEST_DB_PASS: disposable-test-password + MCP_TEST_DB_NAME: mcp_fixture + run: | + bash tests/integration/run.sh + test -s build/evidence/live-console-plugin.log + - name: Preserve installed console evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: installed-console-php-${{ matrix.php }} + path: component/build/evidence/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4f75dda --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Publish console plugin release + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: console-plugin-release + cancel-in-progress: false + +jobs: + installed: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/installed.yml + with: + component_ref: main + publish: + needs: installed + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: dom, simplexml, zip + coverage: none + - name: Validate immutable version and rebuild package + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + php tests/run.php + php tests/release.php + version="$(php -r 'echo (string) simplexml_load_file("joomengine_mcp.xml")->version;')" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + tag="v$version" + git fetch origin main --tags + [[ "$(git rev-parse origin/main)" == "$GITHUB_SHA" ]] + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo 'This immutable version already exists; bump the manifest in a reviewed change.' >&2 + exit 1 + fi + printf 'PLUGIN_VERSION=%s\nPLUGIN_TAG=%s\n' "$version" "$tag" >> "$GITHUB_ENV" + - name: Publish verified versioned archive and checksum + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + archive="build/plg_console_joomengine_mcp-$PLUGIN_VERSION.zip" + gh release create "$PLUGIN_TAG" "$archive" "$archive.sha256" \ + --target "$GITHUB_SHA" --title "JoomEngine MCP console $PLUGIN_VERSION" \ + --notes-file CHANGELOG.md --draft + gh release edit "$PLUGIN_TAG" --draft=false + mkdir -p build/published + gh release download "$PLUGIN_TAG" --dir build/published --pattern '*.zip' --pattern '*.sha256' + cmp "$archive" "build/published/$(basename "$archive")" + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$PLUGIN_TAG" > build/published/release.json + php tools/update-feed.php build/published/release.json "build/published/$(basename "$archive")" + - name: Commit feed only after publication succeeds + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add joomengine_mcp_update_server.xml + git commit -m "Publish console plugin $PLUGIN_VERSION update metadata" + git push origin HEAD:main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b3e47d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/build/ +/vendor/ +/.reference/ +/.phpunit.cache/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d963a7c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# Agent contract — console plugin + +Complete the original companion/CLI migration from `joomengine/joomla-mcp@2cff50f4f6b440da3c684f9995a77efad32e1a36` without losing supported commands, schemas, native actions, preview/plan, approval, verification or recovery. **Also complete the JCB CLI integration required by docs/JCB-INTEGRATION.md and the component's canonical JCB roadmap.** Core-only support is not the completed objective. + +Stay on `feature/jcb-mcp-runtime` / PR #1. Push coherent commits and update docs/IMPLEMENTATION.md with actual tests and remaining work. Do not replace branches, force-push, merge, publish or alter the original MCP/JCB source repositories without separate instruction. + +Use element `joomengine_mcp`, group `console`, namespace `VDM\Plugin\Console\JoomEngineMcp`; dependency `com_joomengine_mcp`, namespace `VDM\Component\JoomEngineMcp`. Joomla 6 native plugin/event/DI/console contracts are authoritative. Follow JCB's plugin-root manifest/installer/services/src/language/update layout. PHP style authority: https://github.com/extension-builder/joomla/blob/main/docs/development/php-code-style.md (tabs, LF, Allman braces, explicit typed properties/constructor injection, meaningful docblocks, no closing tags or isolated strict_types/promotion/readonly changes). Preserve inherited signatures. + +External Composer client and remote stdio bridge belong only to `joomengine/mcp_client`, package `joomengine/mcp-client`. Do not implement or depend on them here. The component owns HTTP routing glue, catalogue/schema resolution, business handlers, permissions/plans, jobs/artifacts, verification and audit. The plugin supplies local entry and adaptation only. + +Only the real console application under CLI can establish server-owner authority. JSON, headers, tokens and database rows cannot do so. Local requests still validate inputs and bindings and retain audit/recovery; HTTP-originated jobs never acquire unrestricted authority just because a local worker executes them. + +JCB's installed command plugin owns `componentbuilder:*` registration. Inventory it after registration, verify exact command identity/InputDefinitions, and invoke only reviewed database-selected mappings. Do not generate all family/entity combinations from the 45-entity factory map, replace JCB commands, dynamically instantiate arbitrary classes or spawn row-supplied shell programs. Preserve local file-input forms, effective global/environment options, dependencies, stdout/stderr, exit codes and partial effects. JCB package get is not an ordinary read-only lookup. Long operations use shared durable jobs, not uncontrolled timeouts/retries. + +Stdio stdout contains only JSON-RPC. Keep banners/notices/logs off it; preserve nonzero failures and EOF/byte bounds. Missing/incompatible component or JCB dependencies must fail the affected operation clearly without breaking unrelated Joomla/core commands. Restore native identity/input/factory state or use isolated job workers so consecutive requests cannot contaminate one another. + +Run syntax, provider/registration, manifest/package tests and coordinated installed Joomla/JCB API/CLI/stdio tests. Exercise true writes/read-back/cleanup, dependency queues, compile/install artifacts, command ordering, concurrency, cancellation, errors and HTTP/local-authority separation. Package checks are not live passes. Align server package versions/update feeds, retain licences and never advertise unpublished artifacts or completed JCB coverage without evidence. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c428673 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## Unreleased + +- Establish the exact joomengine_mcp console plugin identity, local-server authority and shared component contract. +- Add native plugin/provider/lazy command adapters, output isolation, installer checks, languages/update metadata and PHP-only reproducible packaging. +- Expose explicit local JCB catalogue synchronization through the component-owned runtime without replacing JCB's commands. +- Preserve native global options, atomic command registration and output restoration after console errors. +- Verify native Joomla console contracts and 18 actual installed command/stdio assertions, including whitespace and exact-limit NDJSON frames, on PHP 8.3 and 8.4. +- Verify coordinated installed component/plugin/client execution on MySQL and PostgreSQL; retain separate JCB golden-image evidence in the component acceptance checklist. +- Add explicit main-only release publication with verified versioned archives, checksums and post-publication update metadata. +- Separate external Composer-client/remote-bridge ownership into `joomengine/mcp_client`; no server/plugin dependency on that package. +- Require complete first-class JCB API/CLI coverage and document native command registration, compiler/package semantics, shared jobs and installed acceptance responsibilities. + +Exact tested revisions and workflow results are recorded in [implementation evidence](docs/IMPLEMENTATION.md). Coordinated JCB compiler/package/job acceptance is tracked in the [component checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349). No release has been published by this implementation work. diff --git a/README.md b/README.md index 101eb1e..8d1e9d2 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ -# mcp_plugin \ No newline at end of file +# JoomEngine MCP console plugin + +PHP-only local Joomla console integration for `com_joomengine_mcp`. + +Requires the built component version **0.1.1 or later in the same major version**, including its explicit JCB synchronization runtime. + +**Element:** `joomengine_mcp` +**Group:** `console` +**Extension:** `plg_console_joomengine_mcp` +**Namespace:** `VDM\Plugin\Console\JoomEngineMcp` + +The plugin connects Joomla's console lifecycle to the component-owned database catalogue and execution engine. It provides the `joomla:mcp:serve`, `describe`, `dispatch`, `self-test`, `cli-inventory` and `jcb-sync` adapters. It does not contain a second MCP catalogue or an HTTP webservices plugin. + +## Three repository boundaries + +- [`mcp_component`](https://github.com/joomengine/mcp_component): installed server, database definitions, HTTP authentication/ACL/routing, administrator application, API/native handlers, durable plans/jobs and verification. +- This repository: trusted local console entry, typed command/runtime integration, protocol output isolation and plugin distribution. +- [`mcp_client`](https://github.com/joomengine/mcp_client): external Composer client `joomengine/mcp-client` and remote stdio bridge. Neither component nor plugin depends on it. + +Direct local server stdio is not the remote bridge. A client talking over HTTP remains restricted by its Joomla API token regardless of whether it speaks stdio to an AI application on the workstation. + +## Required JCB coverage + +The server and this plugin must support **all actual Joomla Component Builder API and registered CLI capabilities**, alongside Joomla core. For this plugin that includes correct discovery/invocation of JCB's compiler and package get/init/pull/push/reset commands, all registered entity/area variants, native options, output/exit semantics and long-operation handling through the shared component. + +Read [JCB integration responsibilities](docs/JCB-INTEGRATION.md). JCB handlers, database synchronization and durable jobs are implemented in the component and consumed by this plugin. The [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349) records installed compiler/package/job results and native limitations; command inventory alone is not proof of successful execution. The plugin does not copy JCB's compiler or register duplicate `componentbuilder:*` commands. + +After installing or upgrading JCB, the server owner runs `php cli/joomla.php joomla:mcp:jcb-sync` to synchronize reviewed installed JCB definitions through the component. The plugin only forwards this explicit local operation; schema discovery, identity validation and persisted catalogue updates remain component-owned. + +## Status and local authority + +Implementation is on `feature/jcb-mcp-runtime` / [PR #1](https://github.com/joomengine/mcp_plugin/pull/1). The native provider, lazy command adapters, output guard, installer and PHP-only package builder are implemented. Native Joomla console tests cover registration, global options, typed runtime delegation and output restoration; installed workflows exercise this checkout through the actual Joomla CLI and the shared JCB runtime. The PR records current check results and review status; [implementation evidence](docs/IMPLEMENTATION.md) describes the verification layers. + +Local execution uses the genuine Joomla console application under CLI SAPI, without a Joomla API token or row-viewing-level restriction. Input validation, explicit action semantics, grants/plans, bounded output, audit, verification and recovery still apply. HTTP requests and database values cannot manufacture this local privilege. + +Original migration source: `joomengine/joomla-mcp@2cff50f4f6b440da3c684f9995a77efad32e1a36`, especially companion/plugin. Preserve licences and all supported request/result/command behaviours. The source repository is unchanged. + +## Verification and release + +Run `php tests/run.php` and `php tests/release.php` for packaging and publication metadata checks. With a full Joomla distribution in `JOOMLA_ROOT` and the component checkout in `MCP_COMPONENT_SOURCE`, run `php tests/native.php` for actual Joomla class contracts. Installed acceptance requires the component's disposable fixture and `MCP_PLUGIN_SOURCE` pointing to this checkout; its runner installs the plugin and executes `tests/installed.php` before teardown. + +Release publication is an explicit manual workflow on `main`, after merge and review. It runs installed acceptance against the component's `main`, refuses an existing version tag, publishes the versioned archive and checksum, downloads and verifies those assets, then commits the update feed. The feed remains empty until an archive is published. The component owns combined server package assembly. diff --git a/build.php b/build.php new file mode 100644 index 0000000..39c4115 --- /dev/null +++ b/build.php @@ -0,0 +1,76 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +if (PHP_SAPI !== 'cli' || !class_exists(ZipArchive::class)) +{ + fwrite(STDERR, "Build requires PHP CLI with the zip extension.\n"); + exit(1); +} + +$root = __DIR__; +$manifest = simplexml_load_file($root . '/joomengine_mcp.xml'); + +if ($manifest === false || preg_match('/\A\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?\z/D', (string) $manifest->version) !== 1) +{ + throw new RuntimeException('Invalid plugin manifest version.'); +} + +$files = ['joomengine_mcp.xml', 'script.php', 'LICENSE']; + +foreach (['src', 'services', 'language'] as $directory) +{ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/' . $directory, FilesystemIterator::SKIP_DOTS)) as $file) + { + if ($file->isLink()) + { + throw new RuntimeException('Plugin archives may not contain symbolic links.'); + } + + if ($file->isFile()) + { + $files[] = substr($file->getPathname(), strlen($root) + 1); + } + } +} + +sort($files, SORT_STRING); +$output = $root . '/build'; + +if (!is_dir($output) && !mkdir($output, 0775, true)) +{ + throw new RuntimeException('Cannot create the build directory.'); +} + +$path = $output . '/plg_console_joomengine_mcp-' . (string) $manifest->version . '.zip'; +$zip = new ZipArchive(); + +if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) +{ + throw new RuntimeException('Cannot create the plugin archive.'); +} + +$epoch = getenv('SOURCE_DATE_EPOCH'); +$mtime = $epoch !== false && ctype_digit($epoch) ? max(315532800, (int) $epoch) : 1789603200; + +foreach ($files as $file) +{ + if (!$zip->addFile($root . '/' . $file, $file) || !$zip->setMtimeName($file, $mtime) + || !$zip->setExternalAttributesName($file, ZipArchive::OPSYS_UNIX, 0100644 << 16)) + { + throw new RuntimeException('Cannot add a file to the plugin archive.'); + } +} + +if (!$zip->close()) +{ + throw new RuntimeException('Cannot finalize the plugin archive.'); +} + +file_put_contents($path . '.sha256', hash_file('sha256', $path) . ' ' . basename($path) . "\n"); +echo $path . PHP_EOL; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..2033af0 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,29 @@ +# Console plugin architecture + +## Identity and repository boundary + +`plg_console_joomengine_mcp`: element `joomengine_mcp`, group `console`, namespace `VDM\Plugin\Console\JoomEngineMcp`. Root manifest/installer/services/src/language/update files form a Joomla-native, JCB-aligned plugin project. Do not nest it in another installable plugin root. + +The plugin adapts the real Joomla console to the shared `com_joomengine_mcp` runtime. It owns neither HTTP webservices routing nor an external Composer client. HTTP routing glue belongs to the component; external client/remote stdio belongs to `joomengine/mcp_client`. No server-to-client package dependency is permitted. + +## Local execution and wire framing + +The native Joomla console lifecycle registers lazy `joomla:mcp:serve`, `describe`, `dispatch`, `self-test`, `cli-inventory` and `jcb-sync` adapters. Invocation verifies the real console application/CLI SAPI and resolves the component's typed ConsoleRuntimeProviderInterface/ConsoleRuntimeInterface. Missing component dependencies produce a command failure without eagerly breaking unrelated commands. + +Local server ownership is the requested authority boundary: no API token or row viewing permission is needed, but schema validation, explicit effects/grants/plans, verification and recovery remain. The authority cannot be requested from remote JSON, database content or tokens. A remote stdio-to-HTTP client is a different product and remains API-ACL-restricted. + +MCP stdout contains JSON-RPC only. Legacy command JSON/NDJSON framing, input bounds, EOF and nonzero outcomes are preserved independently. Isolate Joomla banners, ANSI messages and native command diagnostics; do not convert warnings/partial mutation into an empty success envelope. + +## Required JCB integration + +Full JCB API and CLI support is a first-class server objective; this plugin supplies its local console adaptation. See JCB-INTEGRATION.md and the canonical component roadmap. JCB's own command plugin remains responsible for registering native compiler/package commands. Inventory actual registered names/aliases/arguments/options after all relevant plugins load; do not duplicate command registrations or invent names from entity counts. + +Shared component services implement JCB provider/schema/action/binding/target resolution, reviewed native/API adapters, durable jobs/artifacts and verification. The plugin must support invoking those bindings while preserving native compiler/global/environment/file/dependency/output semantics. No separate catalogue or JCB compiler copy belongs here. + +Job workers need explicit authority provenance. A job requested over HTTP must retain the initiating Joomla user's permission limits, even when processed by a local worker; only jobs requested through the trusted local track have unrestricted server-owner authority. State/identity/input/factory isolation and cancellation/reconciliation are part of the handler contract. + +## Distribution and acceptance + +Require compatible Joomla/PHP and component dependencies. Preserve installation enablement/settings through updates, provide a standalone PHP-built plugin archive and join the component's .octojpack package assembly. Versioned update feeds may reference only published archives. Retain original notices. + +Validate native DI/event/namespace/manifest contracts and actual legacy/stdio command execution in disposable Joomla. Extend that matrix to JCB absent/disabled/installed/upgraded; registered compiler/package operations, persisted entity/repository changes, output archives, partial failures and cleanup. Source/packaging tests alone are not installed-runtime certification. Track current evidence and remaining work in IMPLEMENTATION.md. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md new file mode 100644 index 0000000..9fb1854 --- /dev/null +++ b/docs/IMPLEMENTATION.md @@ -0,0 +1,41 @@ +# Implementation status — 24 September 2026 + +## Branch + +Implementation is on `feature/jcb-mcp-runtime` / [PR #1](https://github.com/joomengine/mcp_plugin/pull/1). The PR records current checks and review status; the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349) tracks coordinated Joomla/JCB execution evidence. + +Plugin version 0.1.0 requires component version 0.1.1 or later within the same major version, because the explicit JCB synchronization operation is part of that runtime contract. + +## Implemented runtime + +Exact plugin element/group/namespace, Joomla DI/event integration, lazy adapters for serve/describe/dispatch/self-test/cli-inventory/jcb-sync, local-only checks and shared typed component runtime resolution are present. The output guard isolates protocol framing from Joomla diagnostics. Installer checks, initial enablement with update-state preservation, languages/update/changelog metadata and reproducible PHP ZIP building exist. + +The component supplies ConsoleRuntimeInterface/ConsoleRuntimeProviderInterface and the runtime composition. The plugin forwards native input/output objects and exact exit status; it contains no JCB catalogue or business handlers. Registration checks all MCP names before mutation, binds native global options before selecting protocol output protection, and restores formatter state after successful execution and native application errors. + +The component's existing installed stdio suite exercises its shared runtime directly. This repository now also has an installed workflow which installs this plugin checkout and tests `cli/joomla.php joomla:mcp:*`, including the real `serve` adapter. These are distinct evidence layers. + +## Current scope update + +External Composer-client/remote-stdio ownership is exclusively in `joomengine/mcp_client`; the server and plugin do not depend on it. Full JCB API/CLI support remains mandatory, with the plugin boundary documented in JCB-INTEGRATION.md and actual source/execution evidence maintained by the component. + +JCB handlers, reviewed command/API bindings, input freezing, jobs and artifacts belong to the component. The plugin consumes them through its existing shared runtime. Its console adapters do not re-register JCB's native commands or depend on the external client. The canonical JCB acceptance matrix remains docs/JCB-INTEGRATION.md and the component roadmap. + +## Verification layers + +Local PHP 8.3.6 verification: syntax, manifest/language/reproducible package checks; 29 assertions using genuine Joomla 6.1.3 console/plugin/input/output classes; and five release metadata assertions. The native class suite verifies idempotent/atomic registration, global-option handling, help/core output preservation, error restoration, lazy runtime resolution, native input forwarding and nonzero statuses. It uses a substitutable shared-runtime implementation and is not an installed JCB execution test. + +The installed suite covers the actual plugin's describe/self-test/inventory/dispatch adapters, JSON/NDJSON framing and nonzero outcomes, EOF/byte bounds, MCP handshake/discovery/native action execution through `serve`, and component-disabled/core-command isolation. All 18 installed assertions passed on Joomla 6.1.3 / MySQL with PHP 8.3 and 8.4, together with the component's installation, administrator, HTTP, native CRUD, upgrade and uninstall suites. + +Four of these assertions cover NDJSON boundaries: oversized spaces, tabs and non-JSON input each return `REQUEST_TOO_LARGE`, while bounded blank frames and a valid request exactly at the one-MiB wire limit remain accepted. These exercise the shared component runtime through the installed plugin, including the component fix that checks frame size before ignoring whitespace. The 24 September local review also reran the 29 native Joomla assertions, reproducible package checks and five release metadata assertions successfully. + +Verified runtime and test revision: plugin `3526cae818803a02971374c044a2e2184f1c2c61`. + +- [Native console, package and release metadata CI](https://github.com/joomengine/mcp_plugin/actions/runs/35983036798): passed on PHP 8.3 and 8.4. +- [Installed console plugin CI](https://github.com/joomengine/mcp_plugin/actions/runs/35983036883): all 18 plugin assertions passed on PHP 8.3 and 8.4, including actual plugin entrypoints and the component/package lifecycle. Exact checked-out revisions are retained in that run's logs. +- [Coordinated installed core matrix](https://github.com/joomengine/mcp_component/actions/runs/35983426742): all four PHP 8.3/8.4 and MySQL 8.4/PostgreSQL 16 jobs passed. Component `75d9685268332241de846935aad2edc2e92c8459` pins this plugin revision and client `72d02491fe80dadc581d3d9d67b1dfbc917d095d`; the matrix exercises the installed plugin and authenticated remote client alongside the component. + +These installed core fixtures have no JCB installation. The component's golden-image workflow installs a pinned version of this plugin alongside JCB and runs the same actual-entrypoint suite; its JCB operation matrix supplies the separate compiler/package/job evidence. The verified golden-image revisions, results and inherited native limitations are recorded in the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349). + +Ordinary installed CI pairs the feature branches before merge and uses the component's `main` for plugin `main`. Reusable callers can select an explicit component revision. Manual main-only publication runs installed acceptance first, refuses reused version tags, publishes immutable versioned ZIP/checksum assets, verifies downloaded bytes and updates the feed only after publication. No release has been published by this work. + +The component golden-image suite exercises shared JCB operations, native options/dependencies, persisted read-back, generated/install artifacts, state isolation, long jobs/cancellation/recovery and cleanup. Each result belongs to its recorded component/JCB/plugin revisions. The linked PR and acceptance checklist are authoritative for current completion; historical runs do not certify later runtime changes. External-client interoperability is tracked in `mcp_client` and the coordinated component suite. Review/merge and deliberate release publication remain separate actions. diff --git a/docs/JCB-INTEGRATION.md b/docs/JCB-INTEGRATION.md new file mode 100644 index 0000000..7dadb3e --- /dev/null +++ b/docs/JCB-INTEGRATION.md @@ -0,0 +1,40 @@ +# Required JCB console integration + +## Canonical scope + +The authoritative cross-repository roadmap and planning inventory live in the component: + +- https://github.com/joomengine/mcp_component/blob/feature/jcb-mcp-runtime/docs/integrations/JCB.md +- https://github.com/joomengine/mcp_component/blob/feature/jcb-mcp-runtime/docs/integrations/jcb-surface.json + +JCB source is pinned to `extension-builder/joomla@5ee658dd07eb749dca43ed4722f6cca7eb8208cf`; CLI documentation to `joomengine/jcb-documentation@ecd3670232d344295fc4f673b2d3dc40a64b3bf6`, english/CLI-Command-Suite.md. Full JCB support is mandatory alongside Joomla core. The shared component implements installed discovery, planning and job execution. This page defines the plugin contract; IMPLEMENTATION.md describes verification layers, and the [component acceptance checklist](https://github.com/joomengine/mcp_component/pull/1#issuecomment-5732685349) records current compiler/package/job results and native limitations. + +## Native registration and invocation + +JCB's package identifies a separate `ComponentBuilderCommands` console plugin. Keep its command ownership intact. Capture the installed registry after registration: exact names, aliases, InputDefinitions, implementation identity, arguments, defaults and option modes. The documented pattern is componentbuilder::, with componentbuilder:compile:component explicitly documented, but the 45-entity factory map does not prove that every verb/area combination is registered. Additional actual commands are also in scope. + +Let the component's JCB handler/provider and database targets select reviewed command objects or native services. Do not add a plugin hard-coded duplicate list, arbitrary class resolver or generic process/shell endpoint. Command discovery alone does not establish execution parity. Preserve unrelated Joomla/core commands when JCB is missing or disabled. + +## Families and semantics to preserve + +Cover compiler and every actually registered get/init/pull/push/reset package command. Package get synchronizes definitions/dependencies and can write; it is not a normal read-only entity getter. Init/pull/reset have their own initialization/overwrite/tracking/dependency semantics. Push publishes configured remote definition graphs synchronously and can have partial external effects despite a command returning normally. + +Retain GUID/identifier validation, CSV/newline/JSON, native local @file/--items-file forms, repository/force/resolve options where supported, global/environment fallbacks and exact defaults. Do not silently discard an option that needs an explicitly designed HTTP counterpart. Never forward a workstation token or URL as authority to run unrestricted local operations. + +Compiler parity includes selectors, options bundles, backup/local repository export, placeholders/debug/minify/powers/power repository, target Joomla 3/4/5/6, indentation/build dates, and compile-install. Host Joomla compatibility and output target compatibility differ. Omission preserves native GLOBAL behaviour; freeze the reviewed effective options/environment and reject stale plans. + +## Output, jobs and state isolation + +The compiler deliberately separates machine paths on stdout from human diagnostics on stderr. Preserve that distinction, all native exit codes and per-component results; do not let either stream corrupt MCP framing. Verify output/archive existence and hashes through the component's artifact service. Redact credential-bearing repository messages and sensitive paths for remote job consumers. + +JCB factories, message buses and dependency queues contain mutable operation state. Resolve related services from the same factory, reset them correctly or use isolated workers, and restore Joomla identity/input after invocation. Test consecutive requests for state leakage. + +Long work uses the component-owned durable execution/job/lease/artifact protocol. A client disconnect is not a rollback or permission to replay. Cancellation/restart/expired leases need honest partial/uncertain outcomes. HTTP-originated jobs retain the requester's Joomla ACL and scoped consent; local worker execution must not turn them into server-owner jobs. No blanket HTTP-to-CLI privilege bridge is allowed. + +## Plugin implementation and acceptance + +The implemented lazy adapters forward native input/output and exit status to the component without shadowing JCB's command plugin. Native and installed tests cover registration order, global options, framing/EOF/bounds, nonzero exits and output restoration; the explicit `jcb-sync` entrypoint delegates inventory and persistence to the component. + +The component's disposable Joomla matrix exercises real package/compiler workflows, persisted read-back, artifacts, cleanup and original options/environment semantics. Its acceptance contract also covers missing/disabled JCB behavior, dependency handling, state isolation, principal/grant boundaries, concurrent jobs, cancellation and recovery. Exact component/JCB/plugin revisions and current results belong to the linked acceptance checklist and run artifacts, including explicit inherited native limitations. + +The external `joomengine/mcp-client` only discovers and consumes the server contract. This plugin must never depend on its client package or duplicate its remote bridge. diff --git a/joomengine_mcp.xml b/joomengine_mcp.xml new file mode 100644 index 0000000..70d5984 --- /dev/null +++ b/joomengine_mcp.xml @@ -0,0 +1,29 @@ + + + PLG_CONSOLE_JOOMENGINE_MCP + Vast Development Method + September 2026 + Copyright (C) 2026 Vast Development Method. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + joomla@vdm.io + https://dev.vdm.io + 0.1.0 + PLG_CONSOLE_JOOMENGINE_MCP_XML_DESCRIPTION + VDM\Plugin\Console\JoomEngineMcp + script.php + + services + src + language + LICENSE + + + language/en-GB/plg_console_joomengine_mcp.ini + language/en-GB/plg_console_joomengine_mcp.sys.ini + + + https://raw.githubusercontent.com/joomengine/mcp_plugin/main/joomengine_mcp_update_server.xml + + https://raw.githubusercontent.com/joomengine/mcp_plugin/main/joomengine_mcp_changelog.xml + + diff --git a/joomengine_mcp_changelog.xml b/joomengine_mcp_changelog.xml new file mode 100644 index 0000000..15ee61a --- /dev/null +++ b/joomengine_mcp_changelog.xml @@ -0,0 +1,4 @@ + + + + diff --git a/joomengine_mcp_update_server.xml b/joomengine_mcp_update_server.xml new file mode 100644 index 0000000..4ed8d98 --- /dev/null +++ b/joomengine_mcp_update_server.xml @@ -0,0 +1,4 @@ + + + + diff --git a/language/en-GB/plg_console_joomengine_mcp.ini b/language/en-GB/plg_console_joomengine_mcp.ini new file mode 100644 index 0000000..fe29834 --- /dev/null +++ b/language/en-GB/plg_console_joomengine_mcp.ini @@ -0,0 +1,2 @@ +PLG_CONSOLE_JOOMENGINE_MCP="Console - JoomEngine MCP" +PLG_CONSOLE_JOOMENGINE_MCP_XML_DESCRIPTION="Provides local Joomla MCP stdio and companion commands through the shared JoomEngine MCP component. Local console execution is trusted server access and is never available to HTTP requests." diff --git a/language/en-GB/plg_console_joomengine_mcp.sys.ini b/language/en-GB/plg_console_joomengine_mcp.sys.ini new file mode 100644 index 0000000..fe29834 --- /dev/null +++ b/language/en-GB/plg_console_joomengine_mcp.sys.ini @@ -0,0 +1,2 @@ +PLG_CONSOLE_JOOMENGINE_MCP="Console - JoomEngine MCP" +PLG_CONSOLE_JOOMENGINE_MCP_XML_DESCRIPTION="Provides local Joomla MCP stdio and companion commands through the shared JoomEngine MCP component. Local console execution is trusted server access and is never available to HTTP requests." diff --git a/script.php b/script.php new file mode 100644 index 0000000..4bed967 --- /dev/null +++ b/script.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ +use Joomla\CMS\Factory; +use Joomla\CMS\Installer\InstallerScriptInterface; +use Joomla\Database\DatabaseInterface; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use VDM\Plugin\Console\JoomEngineMcp\Installer\InstallerScript; + +\defined('_JEXEC') or die; + +if (!class_exists(InstallerScript::class, false)) +{ + require_once __DIR__ . '/src/Installer/InstallerScript.php'; +} + +/** + * Explicitly injected native Joomla installer provider. + * + * @since 0.1.0 + */ +return new class implements ServiceProviderInterface +{ + /** @inheritDoc */ + public function register(Container $container): void + { + $container->set(InstallerScriptInterface::class, static function (Container $container): InstallerScriptInterface + { + return new InstallerScript($container->get(DatabaseInterface::class), Factory::getApplication()); + }); + } +}; diff --git a/services/provider.php b/services/provider.php new file mode 100644 index 0000000..b35175a --- /dev/null +++ b/services/provider.php @@ -0,0 +1,42 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use VDM\Plugin\Console\JoomEngineMcp\Extension\JoomEngineMcpPlugin; + + +defined('_JEXEC') or die; + +/** Compose the plugin at Joomla's extension-service boundary. */ +return new class implements ServiceProviderInterface +{ + /** + * Register the console plugin without booting the MCP component eagerly. + * + * @param Container $container Joomla extension container. + * @return void + * @since 0.1.0 + */ + public function register(Container $container): void + { + $container->set(PluginInterface::class, static function (Container $container): PluginInterface + { + $plugin = new JoomEngineMcpPlugin( + (array) PluginHelper::getPlugin('console', 'joomengine_mcp') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + }); + } +}; diff --git a/src/Console/McpCommand.php b/src/Console/McpCommand.php new file mode 100644 index 0000000..a66ec45 --- /dev/null +++ b/src/Console/McpCommand.php @@ -0,0 +1,152 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ +namespace VDM\Plugin\Console\JoomEngineMcp\Console; + + +use Closure; +use InvalidArgumentException; +use Joomla\CMS\Application\ConsoleApplication; +use Joomla\Console\Command\AbstractCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; +use VDM\Component\JoomEngineMcp\Administrator\Contract\ConsoleRuntimeInterface; + + +/** + * Lazy console adapter preserving legacy framing and adding native MCP stdio. + * + * @since 0.1.0 + */ +final class McpCommand extends AbstractCommand +{ + /** + * Fixed command operation selected by the plugin, never by a request body. + * + * @var string + * @since 0.1.0 + */ + private string $operation; + + /** + * Component runtime resolver invoked only when this command executes. + * + * @var Closure():ConsoleRuntimeInterface + * @since 0.1.0 + */ + private Closure $resolveRuntime; + + /** + * Bind one known command to the component composition root. + * + * @param string $operation Known console operation. + * @param callable $resolveRuntime Lazy component runtime resolver. + * @throws InvalidArgumentException For an unknown command operation. + * @since 0.1.0 + */ + public function __construct(string $operation, callable $resolveRuntime) + { + if (!in_array($operation, ['serve', 'describe', 'dispatch', 'self-test', 'cli-inventory', 'jcb-sync'], true)) + { + throw new InvalidArgumentException('Unknown Joomla MCP console operation.'); + } + + $this->operation = $operation; + $this->resolveRuntime = Closure::fromCallable($resolveRuntime); + parent::__construct('joomla:mcp:' . $operation); + } + + /** + * Preserve the companion's public options; stdio has no alternate framing. + * + * @return void + * @since 0.1.0 + */ + protected function configure(): void + { + $this->setDescription($this->operation === 'serve' + ? 'Serve the installed database-driven Joomla MCP over PHP stdio.' + : 'Run the shared JoomEngine MCP ' . $this->operation . ' console operation.'); + + if ($this->operation !== 'serve') + { + $this->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format; dispatch also accepts ndjson.', 'json'); + } + + if ($this->operation === 'dispatch') + { + $this->addOption('input', null, InputOption::VALUE_REQUIRED, 'Only stdin (-) is supported.', '-'); + } + } + + /** + * Resolve the local component runtime and execute without a shell process. + * + * @param InputInterface $input Joomla console input. + * @param OutputInterface $output Joomla console output. + * @return int Zero only for successful command execution. + * @since 0.1.0 + */ + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + if (PHP_SAPI !== 'cli' || !$this->getApplication() instanceof ConsoleApplication) + { + return $this->failure('LOCAL_CONSOLE_REQUIRED', 'This command requires the local Joomla console.'); + } + + try + { + $runtime = ($this->resolveRuntime)(); + + if (!$runtime instanceof ConsoleRuntimeInterface) + { + return $this->failure('DEPENDENCY_UNAVAILABLE', 'A compatible JoomEngine MCP component is required.'); + } + + if ($this->operation === 'serve') + { + return $runtime->serveStdio(); + } + + return $runtime->executeCommand($this->operation, $input, $output); + } + catch (Throwable) + { + return $this->failure('MCP_COMMAND_FAILED', 'The JoomEngine MCP command could not complete; check the component installation and server logs.'); + } + } + + /** + * Keep diagnostics out of MCP stdout and retain legacy structured errors. + * + * @param string $code Stable error code. + * @param string $message Non-sensitive operator diagnostic. + * @return int + * @since 0.1.0 + */ + private function failure(string $code, string $message): int + { + if ($this->operation === 'serve') + { + fwrite(STDERR, $message . PHP_EOL); + } + else + { + fwrite(STDOUT, json_encode([ + 'protocol' => 'joomla-mcp/1', + 'id' => null, + 'ok' => false, + 'error' => ['code' => $code, 'message' => $message], + ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL); + } + + return 1; + } +} diff --git a/src/Console/OutputGuard.php b/src/Console/OutputGuard.php new file mode 100644 index 0000000..19eb867 --- /dev/null +++ b/src/Console/OutputGuard.php @@ -0,0 +1,91 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ +namespace VDM\Plugin\Console\JoomEngineMcp\Console; + + +use Joomla\CMS\Application\ConsoleApplication; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Output\StreamOutput; + + +/** + * Keep Joomla's post-command messages out of the newline-delimited MCP stream. + * + * Protocol frames write directly to STDOUT. Joomla's console formatter is muted + * until AFTER_EXECUTE, while native exception diagnostics still use STDERR. + * All formatting state is restored after Joomla has flushed its message queue. + * + * @since 0.1.0 + */ +final class OutputGuard +{ + /** @var OutputInterface Native console formatter, not the protocol stream. @since 0.1.0 */ + private OutputInterface $output; + /** @var ?OutputInterface Previous exception output. @since 0.1.0 */ + private ?OutputInterface $errorOutput = null; + /** @var int Previous console verbosity. @since 0.1.0 */ + private int $verbosity; + /** @var string|false Previous PHP notice-output mode. @since 0.1.0 */ + private string|false $displayErrors; + /** @var bool Whether the guard still owns the formatter state. @since 0.1.0 */ + private bool $active = true; + + /** @param ConsoleApplication $application Actual local console. @since 0.1.0 */ + public function __construct(ConsoleApplication $application) + { + $this->output = $application->getConsoleOutput(); + $this->verbosity = $this->output->getVerbosity(); + $this->displayErrors = ini_get('display_errors'); + + if ($this->output instanceof ConsoleOutputInterface) + { + $this->errorOutput = $this->output->getErrorOutput(); + } + + $this->output->setVerbosity(OutputInterface::VERBOSITY_QUIET); + + if ($this->output instanceof ConsoleOutputInterface) + { + $this->output->setErrorOutput(new StreamOutput(STDERR, $this->verbosity, false)); + } + + ini_set('display_errors', 'stderr'); + } + + /** @param ConsoleApplication $application Finished command application. @return void Restore after Joomla's post-command output. @since 0.1.0 */ + public function restore(ConsoleApplication $application): void + { + if (!$this->active) + { + return; + } + + foreach ($application->getMessageQueue() as $type => $messages) + { + // Message bodies can contain untrusted content or secrets. Operation + // results carry their own safe diagnostics; report only queue counts. + fwrite(STDERR, 'Joomla queued ' . count($messages) . ' ' . preg_replace('/[^a-z_-]/i', '', (string) $type) . ' message(s) during the MCP command.' . PHP_EOL); + } + + if ($this->errorOutput !== null && $this->output instanceof ConsoleOutputInterface) + { + $this->output->setErrorOutput($this->errorOutput); + } + + $this->output->setVerbosity($this->verbosity); + + if ($this->displayErrors !== false) + { + ini_set('display_errors', $this->displayErrors); + } + + $this->active = false; + } +} diff --git a/src/Extension/JoomEngineMcpPlugin.php b/src/Extension/JoomEngineMcpPlugin.php new file mode 100644 index 0000000..2570a25 --- /dev/null +++ b/src/Extension/JoomEngineMcpPlugin.php @@ -0,0 +1,135 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ +namespace VDM\Plugin\Console\JoomEngineMcp\Extension; + + +use Joomla\Application\ApplicationEvents; +use Joomla\CMS\Application\ConsoleApplication; +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Console\ConsoleEvents; +use Joomla\Event\SubscriberInterface; +use RuntimeException; +use Symfony\Component\Console\Exception\ExceptionInterface; +use VDM\Component\JoomEngineMcp\Administrator\Contract\ConsoleRuntimeInterface; +use VDM\Component\JoomEngineMcp\Administrator\Contract\ConsoleRuntimeProviderInterface; +use VDM\Plugin\Console\JoomEngineMcp\Console\McpCommand; +use VDM\Plugin\Console\JoomEngineMcp\Console\OutputGuard; + + +/** + * Registers local-only commands while keeping the component runtime shared. + * + * @since 0.1.0 + */ +final class JoomEngineMcpPlugin extends CMSPlugin implements SubscriberInterface +{ + /** @var ?OutputGuard Scoped console formatter protection. @since 0.1.0 */ + private ?OutputGuard $outputGuard = null; + + /** + * Subscribe to Joomla's console lifecycle, not web request events. + * + * @return array + * @since 0.1.0 + */ + public static function getSubscribedEvents(): array + { + return [ApplicationEvents::BEFORE_EXECUTE => 'registerCommands', + ApplicationEvents::AFTER_EXECUTE => 'restoreOutput', ConsoleEvents::APPLICATION_ERROR => 'restoreOutput']; + } + + /** + * Register lazy adapters without affecting unrelated commands or web traffic. + * + * @return void + * @throws RuntimeException When a different extension owns an MCP command. + * @since 0.1.0 + */ + public function registerCommands(): void + { + $application = $this->getApplication(); + + if (PHP_SAPI !== 'cli' || !$application instanceof ConsoleApplication) + { + return; + } + + $resolve = static function () use ($application): ConsoleRuntimeInterface + { + if (!ComponentHelper::isEnabled('com_joomengine_mcp')) + { + throw new RuntimeException('The JoomEngine MCP component is disabled or not installed.'); + } + + $component = $application->bootComponent('com_joomengine_mcp'); + + if (!$component instanceof ConsoleRuntimeProviderInterface) + { + throw new RuntimeException('A compatible JoomEngine MCP component must be installed and enabled.'); + } + + return $component->getConsoleRuntime($application); + }; + + $operations = ['serve', 'describe', 'dispatch', 'self-test', 'cli-inventory', 'jcb-sync']; + + // Validate the complete namespace before changing the registry or formatter. + foreach ($operations as $operation) + { + $name = 'joomla:mcp:' . $operation; + + if ($application->hasCommand($name) && !$application->getCommand($name) instanceof McpCommand) + { + throw new RuntimeException('Refusing to replace an existing Joomla MCP console command.'); + } + } + + foreach ($operations as $operation) + { + if (!$application->hasCommand('joomla:mcp:' . $operation)) + { + $application->addCommand(new McpCommand($operation, $resolve)); + } + } + + $input = $application->getConsoleInput(); + + try + { + // As in Joomla's dispatcher, bind global options before determining the + // command. Otherwise --live-site URL may be mistaken for its name. + $input->bind($application->getDefinition()); + } + catch (ExceptionInterface) + { + // Command-specific options are validated by the actual command later. + } + + $selected = $input->getFirstArgument(); + + if (is_string($selected) && str_starts_with($selected, 'joomla:mcp:') + && !$input->hasParameterOption(['--help', '-h', '--version', '-V'], true)) + { + $this->outputGuard ??= new OutputGuard($application); + } + } + + /** @return void Restore native formatter state after Joomla flushes its queue. @since 0.1.0 */ + public function restoreOutput(): void + { + $application = $this->getApplication(); + + if ($this->outputGuard !== null && $application instanceof ConsoleApplication) + { + $this->outputGuard->restore($application); + $this->outputGuard = null; + } + } +} diff --git a/src/Installer/InstallerScript.php b/src/Installer/InstallerScript.php new file mode 100644 index 0000000..6fc70fb --- /dev/null +++ b/src/Installer/InstallerScript.php @@ -0,0 +1,113 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ +namespace VDM\Plugin\Console\JoomEngineMcp\Installer; + + +use Joomla\CMS\Application\CMSApplicationInterface; +use Joomla\CMS\Installer\InstallerAdapter; +use Joomla\CMS\Installer\InstallerScriptInterface; +use Joomla\CMS\Version; +use Joomla\Database\DatabaseInterface; +use RuntimeException; +use Throwable; + + +/** + * Joomla-native dependency and version checks for the independent console adapter. + * + * @since 0.1.0 + */ +final class InstallerScript implements InstallerScriptInterface +{ + /** @var DatabaseInterface Joomla extension registry. @since 0.1.0 */ + private DatabaseInterface $database; + /** @var CMSApplicationInterface Native installer application. @since 0.1.0 */ + private CMSApplicationInterface $application; + + /** @param DatabaseInterface $database Native database. @param CMSApplicationInterface $application Actual installer. @since 0.1.0 */ + public function __construct(DatabaseInterface $database, CMSApplicationInterface $application) + { + $this->database = $database; + $this->application = $application; + } + + /** @inheritDoc */ + public function preflight(string $type, InstallerAdapter $adapter): bool + { + if ($type === 'uninstall') + { + return true; + } + + try + { + $version = (new Version())->getShortVersion(); + + if (version_compare(PHP_VERSION, '8.3.0', '<') || version_compare($version, '6.1.0', '<') || version_compare($version, '7.0.0', '>=')) + { + throw new RuntimeException('JoomEngine MCP requires Joomla 6.1–6.x and PHP 8.3 or later.'); + } + + $db = $this->database; + $query = $db->createQuery()->select($db->quoteName(['enabled', 'manifest_cache']))->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_joomengine_mcp')); + $row = $db->setQuery($query)->loadAssoc(); + $manifest = json_decode($row['manifest_cache'] ?? '{}', true); + $componentVersion = (string) ($manifest['version'] ?? '0'); + $pluginVersion = (string) $adapter->getManifest()->version; + + if ($row === null || (int) $row['enabled'] !== 1 || version_compare($componentVersion, '0.1.1', '<') + || explode('.', $componentVersion)[0] !== explode('.', $pluginVersion)[0] + || !is_file(JPATH_ADMINISTRATOR . '/components/com_joomengine_mcp/vendor/autoload.php')) + { + throw new RuntimeException('Install and enable the built JoomEngine MCP component version 0.1.1 or later in the same major version before its console plugin.'); + } + + return true; + } + catch (Throwable $error) + { + $this->application->enqueueMessage($error instanceof RuntimeException ? $error->getMessage() : 'The console plugin dependency check failed.', 'error'); + + return false; + } + } + + /** @inheritDoc */ + public function install(InstallerAdapter $adapter): bool + { + $db = $this->database; + $query = $db->createQuery()->update($db->quoteName('#__extensions'))->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('console')) + ->where($db->quoteName('element') . ' = ' . $db->quote('joomengine_mcp')); + $db->setQuery($query)->execute(); + + return true; + } + + /** @inheritDoc */ + public function update(InstallerAdapter $adapter): bool + { + return true; + } + + /** @inheritDoc */ + public function uninstall(InstallerAdapter $adapter): bool + { + return true; + } + + /** @inheritDoc */ + public function postflight(string $type, InstallerAdapter $adapter): bool + { + return true; + } +} diff --git a/tests/installed.php b/tests/installed.php new file mode 100644 index 0000000..329e9cb --- /dev/null +++ b/tests/installed.php @@ -0,0 +1,208 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use Joomla\Database\DatabaseInterface; +use Mcp\Client; +use Mcp\Client\Transport\StdioTransport; + +$component = realpath((string) getenv('MCP_COMPONENT_SOURCE')); + +if ($component === false || !is_file($component . '/tests/integration/bootstrap.php')) +{ + throw new RuntimeException('Set MCP_COMPONENT_SOURCE to the matching component checkout.'); +} + +// This bootstrap requires explicit destructive-test consent and a fixture marker. +require $component . '/tests/integration/bootstrap.php'; +$app->bootComponent('com_joomengine_mcp'); +$db = $container->get(DatabaseInterface::class); +$arguments = []; + +if (is_string(php_ini_loaded_file())) +{ + $arguments = ['-c', php_ini_loaded_file()]; +} + +$arguments = array_merge($arguments, ['-d', 'extension_dir=' . ini_get('extension_dir'), '-d', 'display_errors=stderr', JPATH_ROOT . '/cli/joomla.php']); +$checks = 0; +$check = static function (bool $condition, string $message) use (&$checks): void +{ + if (!$condition) + { + throw new RuntimeException($message); + } + + $checks++; + echo 'PASS ' . $message . PHP_EOL; +}; + +/** Run the real installed CLI with bounded pipes, a deadline and no shell. */ +$run = static function (array $command, string $input = '') use ($arguments): array +{ + $process = proc_open(array_merge([PHP_BINARY], $arguments, $command), + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, JPATH_ROOT, null, ['bypass_shell' => true]); + + if (!is_resource($process)) + { + throw new RuntimeException('Cannot start the installed Joomla console.'); + } + + foreach ($pipes as $pipe) + { + stream_set_blocking($pipe, false); + } + + $stdout = ''; + $stderr = ''; + $offset = 0; + $deadline = hrtime(true) + 60000000000; + + try + { + while (true) + { + if (hrtime(true) >= $deadline) + { + throw new RuntimeException('Installed console command exceeded its test deadline.'); + } + + if (isset($pipes[0])) + { + if ($offset < strlen($input)) + { + $written = fwrite($pipes[0], substr($input, $offset, 8192)); + + if ($written === false) + { + throw new RuntimeException('Installed console input pipe failed.'); + } + + $offset += $written; + } + + if ($offset === strlen($input)) + { + fclose($pipes[0]); + unset($pipes[0]); + } + } + + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + + if (strlen($stdout) + strlen($stderr) > 16777216) + { + throw new RuntimeException('Installed console output exceeded its test bound.'); + } + + $status = proc_get_status($process); + + if (!$status['running']) + { + return [(int) $status['exitcode'], $stdout . stream_get_contents($pipes[1]), $stderr . stream_get_contents($pipes[2])]; + } + + usleep(10000); + } + } + finally + { + if (proc_get_status($process)['running']) + { + proc_terminate($process, 9); + } + + foreach ($pipes as $pipe) + { + fclose($pipe); + } + + proc_close($process); + } +}; +$decode = static fn (string $text): array => json_decode(trim($text), true, 128, JSON_THROW_ON_ERROR); +$request = json_encode(['protocol' => 'joomla-mcp/1', 'id' => 'system', 'action' => 'system.info', 'input' => (object) []], JSON_THROW_ON_ERROR); + +foreach (['describe', 'self-test', 'cli-inventory'] as $operation) +{ + [$status, $stdout] = $run(['joomla:mcp:' . $operation, '--no-ansi', '--no-interaction']); + $result = $decode($stdout); + $check($status === 0 && ($result['protocol'] ?? '') === 'joomla-mcp/1' && ($result['ok'] ?? false), + 'Installed ' . $operation . ' returns one clean native protocol response'); +} + +[$status, $stdout] = $run(['--live-site', 'https://example.test', 'joomla:mcp:dispatch', '--input=-'], $request); +$result = $decode($stdout); +$check($status === 0 && ($result['id'] ?? '') === 'system' && ($result['result']['joomlaVersion'] ?? '') === JVERSION, + 'Installed dispatch preserves global options and executes the actual Joomla handler'); + +[$status, $stdout] = $run(['joomla:mcp:dispatch', '--format=ndjson'], "{invalid}\n" . $request . "\n"); +$lines = array_map($decode, explode("\n", trim($stdout))); +$check($status !== 0 && count($lines) === 2 && !$lines[0]['ok'] && $lines[1]['ok'] && $lines[1]['id'] === 'system', + 'NDJSON retains failed-frame status and continues with the next valid request'); + +foreach ([['joomla:mcp:dispatch', '--format=xml'], ['joomla:mcp:dispatch', '--input=unsupported']] as $command) +{ + [$status, $stdout] = $run($command, $request); + $result = $decode($stdout); + $check($status !== 0 && !$result['ok'], 'Unsupported framing or input source fails with a structured response'); +} + +[$status, $stdout] = $run(['joomla:mcp:dispatch']); +$check($status !== 0 && !$decode($stdout)['ok'], 'EOF without a JSON request fails promptly'); +[$status, $stdout] = $run(['joomla:mcp:dispatch'], str_repeat(' ', 1048577)); +$check($status !== 0 && ($decode($stdout)['error']['code'] ?? '') === 'REQUEST_TOO_LARGE', 'Oversized JSON is rejected at the native input bound'); + +foreach ([' ', "\t", 'x'] as $fill) +{ + [$status, $stdout] = $run(['joomla:mcp:dispatch', '--format=ndjson'], str_repeat($fill, 1048577)); + $check($status !== 0 && ($decode($stdout)['error']['code'] ?? '') === 'REQUEST_TOO_LARGE', + 'Oversized NDJSON frames fail before blank-frame or JSON-content handling'); +} + +[$status, $stdout] = $run(['joomla:mcp:dispatch', '--format=ndjson'], " \t\n" . $request . str_repeat(' ', 1048575 - strlen($request)) . "\n"); +$result = $decode($stdout); +$check($status === 0 && ($result['id'] ?? '') === 'system' && ($result['ok'] ?? false), + 'Bounded blank NDJSON frames are ignored and a request exactly at the byte bound remains valid'); + +$client = Client::builder()->setClientInfo('installed-plugin-fixture', '1.0.0')->setInitTimeout(15)->setRequestTimeout(30)->setMaxRetries(0)->build(); + +try +{ + $client->connect(new StdioTransport(PHP_BINARY, array_merge($arguments, ['joomla:mcp:serve']), JPATH_ROOT, maxBufferSize: 16777216)); + $names = array_map(static fn ($tool): string => $tool->name, $client->listTools()->tools); + $check(in_array('joomla_action_read', $names, true), 'Installed plugin serve completes MCP handshake and discovery'); + $result = $client->callTool('joomla_action_read', ['action' => 'system.info', 'transport' => 'cli']); + $content = json_decode(json_encode($result->structuredContent, JSON_THROW_ON_ERROR), true, 128, JSON_THROW_ON_ERROR); + $check(!$result->isError && ($content['response']['data']['joomlaVersion'] ?? '') === JVERSION, 'Installed plugin serve executes the component-owned native action'); + $client->ping(); + $check($client->isConnected(), 'Native diagnostics leave subsequent protocol frames usable'); +} +finally +{ + $client->disconnect(); +} + +$where = $db->quoteName('type') . ' = ' . $db->quote('component') . ' AND ' . $db->quoteName('element') . ' = ' . $db->quote('com_joomengine_mcp'); +$previous = (int) $db->setQuery('SELECT enabled FROM ' . $db->quoteName('#__extensions') . ' WHERE ' . $where)->loadResult(); + +try +{ + $db->setQuery('UPDATE ' . $db->quoteName('#__extensions') . ' SET enabled = 0 WHERE ' . $where)->execute(); + [$status, $stdout] = $run(['joomla:mcp:describe']); + $check($status !== 0 && !$decode($stdout)['ok'], 'Unavailable component fails only the affected MCP command'); + [$status] = $run(['list', '--no-ansi', '--no-interaction']); + $check($status === 0, 'Unrelated Joomla commands remain available with the component disabled'); +} +finally +{ + $db->setQuery('UPDATE ' . $db->quoteName('#__extensions') . ' SET enabled = ' . $previous . ' WHERE ' . $where)->execute(); +} + +echo json_encode(['checks' => $checks, 'joomla' => JVERSION, 'database' => $db->getServerType(), 'actualPluginEntrypoints' => true], JSON_THROW_ON_ERROR) . PHP_EOL; diff --git a/tests/native.php b/tests/native.php new file mode 100644 index 0000000..997a0fb --- /dev/null +++ b/tests/native.php @@ -0,0 +1,177 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use Joomla\Application\ApplicationEvents; +use Joomla\CMS\Application\ConsoleApplication; +use Joomla\CMS\Language\Language; +use Joomla\Console\Command\AbstractCommand; +use Joomla\Console\ConsoleEvents; +use Joomla\DI\Container; +use Joomla\Event\Dispatcher; +use Joomla\Event\Event; +use Joomla\Registry\Registry; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Output\StreamOutput; +use VDM\Component\JoomEngineMcp\Administrator\Contract\ConsoleRuntimeInterface; +use VDM\Plugin\Console\JoomEngineMcp\Console\McpCommand; +use VDM\Plugin\Console\JoomEngineMcp\Extension\JoomEngineMcpPlugin; + +$joomla = realpath((string) getenv('JOOMLA_ROOT')); +$component = realpath((string) getenv('MCP_COMPONENT_SOURCE')); + +if (PHP_SAPI !== 'cli' || $joomla === false || $component === false + || !is_file($joomla . '/libraries/vendor/autoload.php') + || !is_file($component . '/admin/src/Contract/ConsoleRuntimeInterface.php')) +{ + throw new RuntimeException('Set JOOMLA_ROOT to the full Joomla distribution and MCP_COMPONENT_SOURCE to its matching component checkout.'); +} + +define('_JEXEC', 1); +define('JPATH_BASE', $joomla); +require $joomla . '/includes/defines.php'; +require $joomla . '/libraries/bootstrap.php'; +require $component . '/admin/src/Contract/ConsoleRuntimeInterface.php'; +require dirname(__DIR__) . '/src/Console/McpCommand.php'; +require dirname(__DIR__) . '/src/Console/OutputGuard.php'; +require dirname(__DIR__) . '/src/Extension/JoomEngineMcpPlugin.php'; + +/** Native console behaviour without adding the database-backed core commands. */ +final class FixtureConsole extends ConsoleApplication +{ + /** @inheritDoc */ + protected function getDefaultCommands(): array + { + return []; + } +} + +$checks = 0; +$check = static function (bool $condition, string $message) use (&$checks): void +{ + if (!$condition) + { + throw new RuntimeException($message); + } + + $checks++; + echo 'PASS ' . $message . PHP_EOL; +}; +$make = static function (array $arguments): array +{ + $output = new ConsoleOutput(OutputInterface::VERBOSITY_VERBOSE, false); + $output->setErrorOutput(new StreamOutput(fopen('php://memory', 'w+'), OutputInterface::VERBOSITY_VERBOSE)); + $dispatcher = new Dispatcher(); + $app = new FixtureConsole(new Registry(), $dispatcher, new Container(), new Language('en-GB'), new ArgvInput($arguments), $output); + $plugin = new JoomEngineMcpPlugin(['name' => 'joomengine_mcp', 'type' => 'console']); + $plugin->setApplication($app); + $dispatcher->addSubscriber($plugin); + + return [$app, $plugin, $output, $dispatcher]; +}; + +foreach ([['joomla.php', 'joomla:mcp:serve'], ['joomla.php', '--live-site', 'https://example.test', 'joomla:mcp:serve']] as $arguments) +{ + [$app, $plugin, $output, $dispatcher] = $make($arguments); + $previousErrors = ini_get('display_errors'); + $previousStderr = $output->getErrorOutput(); + $dispatcher->dispatch(ApplicationEvents::BEFORE_EXECUTE, new Event(ApplicationEvents::BEFORE_EXECUTE)); + $check($output->isQuiet() && ini_get('display_errors') === 'stderr', 'MCP entry isolates formatter and PHP diagnostics with global options'); + $first = $app->getCommand('joomla:mcp:serve'); + $plugin->registerCommands(); + $check(count($app->getAllCommands()) === 6 && $first === $app->getCommand('joomla:mcp:serve'), 'Repeated registration is idempotent without booting the component'); + $dispatcher->dispatch(ConsoleEvents::APPLICATION_ERROR, new Event(ConsoleEvents::APPLICATION_ERROR)); + $check($output->getVerbosity() === OutputInterface::VERBOSITY_VERBOSE && $output->getErrorOutput() === $previousStderr + && ini_get('display_errors') === $previousErrors, 'Native error events restore the exact formatter and PHP diagnostic state'); + $plugin->restoreOutput(); + $check($output->getErrorOutput() === $previousStderr, 'Repeated output restoration is harmless'); +} + +foreach ([['joomla.php', 'list'], ['joomla.php', 'joomla:mcp:serve', '--help'], ['joomla.php', 'joomla:mcp:serve', '--version']] as $arguments) +{ + [$app, $plugin, $output] = $make($arguments); + $plugin->registerCommands(); + $check($output->getVerbosity() === OutputInterface::VERBOSITY_VERBOSE, 'Core commands, help and version retain normal output'); +} + +[$app, $plugin, $output] = $make(['joomla.php', 'joomla:mcp:serve']); +$conflicting = new class('joomla:mcp:cli-inventory') extends AbstractCommand +{ + /** @inheritDoc */ + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + return 0; + } +}; +$app->addCommand($conflicting); +$rejected = false; + +try +{ + $plugin->registerCommands(); +} +catch (RuntimeException) +{ + $rejected = true; +} + +$check($rejected && count($app->getAllCommands()) === 1 && $app->getCommand('joomla:mcp:cli-inventory') === $conflicting + && $output->getVerbosity() === OutputInterface::VERBOSITY_VERBOSE, 'Registration conflicts preserve the existing registry and output state'); + +$runtime = new class implements ConsoleRuntimeInterface +{ + /** @var array Recorded typed calls. */ + public array $calls = []; + + /** @inheritDoc */ + public function serveStdio(): int + { + $this->calls[] = ['serve']; + + return 23; + } + + /** @inheritDoc */ + public function executeCommand(string $operation, InputInterface $input, OutputInterface $output): int + { + $this->calls[] = [$operation, $input, $output]; + + return 17; + } +}; +[$app, $plugin, $output] = $make(['joomla.php', 'list']); +$resolved = 0; +$resolve = static function () use ($runtime, &$resolved): ConsoleRuntimeInterface +{ + $resolved++; + + return $runtime; +}; + +foreach (['serve', 'describe', 'dispatch', 'self-test', 'cli-inventory', 'jcb-sync'] as $operation) +{ + $command = new McpCommand($operation, $resolve); + $app->addCommand($command); + $check($resolved === count($runtime->calls), 'Constructing adapters never resolves component runtime eagerly'); + $input = new ArrayInput($operation === 'dispatch' ? ['--input' => '-', '--format' => 'ndjson'] : []); + $status = $command->execute($input, $output); + $call = $runtime->calls[array_key_last($runtime->calls)]; + $check($status === ($operation === 'serve' ? 23 : 17) && $call[0] === $operation, 'Adapters preserve selected operation and nonzero native status'); + + if ($operation !== 'serve') + { + $check($call[1] === $input && $call[2] === $output && $input->getOption('format') === ($operation === 'dispatch' ? 'ndjson' : 'json'), + 'Typed native input and output reach the shared runtime without reconstruction'); + } +} + +echo json_encode(['checks' => $checks, 'joomla' => JVERSION, 'nativeConsoleClasses' => true, 'installedRuntime' => false], JSON_THROW_ON_ERROR) . PHP_EOL; diff --git a/tests/prepare-native.sh b/tests/prepare-native.sh new file mode 100644 index 0000000..416919a --- /dev/null +++ b/tests/prepare-native.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${JOOMLA_ROOT:?Set an empty directory for the pinned Joomla distribution}" +[[ ! -e "$JOOMLA_ROOT" ]] +work="$(mktemp -d)" +trap 'rm -rf -- "$work"' EXIT +curl --fail --location --proto '=https' --tlsv1.2 --retry 3 --connect-timeout 30 --max-time 180 \ + https://github.com/joomla/joomla-cms/releases/download/6.1.3/Joomla_6.1.3-Stable-Full_Package.tar.gz \ + --output "$work/joomla.tar.gz" +printf '%s %s\n' '184f8c582cde5981693de7c28547c6e834c48c50cb377c7b8421bbfd33bbdf6f' "$work/joomla.tar.gz" | sha256sum --check +mkdir -p -- "$JOOMLA_ROOT" +tar --no-same-owner -xzf "$work/joomla.tar.gz" -C "$JOOMLA_ROOT" diff --git a/tests/release.php b/tests/release.php new file mode 100644 index 0000000..8ca6fc9 --- /dev/null +++ b/tests/release.php @@ -0,0 +1,102 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +$root = dirname(__DIR__); +$version = (string) simplexml_load_file($root . '/joomengine_mcp.xml')->version; +$name = 'plg_console_joomengine_mcp-' . $version . '.zip'; +$directory = sys_get_temp_dir() . '/mcp-plugin-release-' . bin2hex(random_bytes(8)); +mkdir($directory . '/tools', 0700, true); +$checks = 0; +$check = static function (bool $condition, string $message) use (&$checks): void +{ + if (!$condition) + { + throw new RuntimeException($message); + } + + $checks++; + echo 'PASS ' . $message . PHP_EOL; +}; +$run = static function (string $directory, string $name): bool +{ + $argv = [$directory . '/tools/update-feed.php', $directory . '/release.json', $directory . '/' . $name]; + ob_start(); + + try + { + require $argv[0]; + + return true; + } + catch (RuntimeException) + { + return false; + } + finally + { + ob_end_clean(); + } +}; + +try +{ + copy($root . '/tools/update-feed.php', $directory . '/tools/update-feed.php'); + copy($root . '/joomengine_mcp.xml', $directory . '/joomengine_mcp.xml'); + copy($root . '/joomengine_mcp_update_server.xml', $directory . '/joomengine_mcp_update_server.xml'); + copy($root . '/build/' . $name, $directory . '/' . $name); + copy($root . '/build/' . $name . '.sha256', $directory . '/' . $name . '.sha256'); + $tag = 'v' . $version; + $base = 'https://github.com/joomengine/mcp_plugin/releases/'; + $release = ['tag_name' => $tag, 'draft' => true, 'prerelease' => false, 'published_at' => '2026-09-21T00:00:00Z', + 'html_url' => $base . 'tag/' . $tag, 'assets' => []]; + + foreach ([$name, $name . '.sha256'] as $asset) + { + $release['assets'][] = ['name' => $asset, 'state' => 'uploaded', 'size' => filesize($directory . '/' . $asset), + 'browser_download_url' => $base . 'download/' . $tag . '/' . $asset]; + } + + $write = static fn () => file_put_contents($directory . '/release.json', json_encode($release, JSON_THROW_ON_ERROR)); + $before = file_get_contents($directory . '/joomengine_mcp_update_server.xml'); + $write(); + $check(!$run($directory, $name) && file_get_contents($directory . '/joomengine_mcp_update_server.xml') === $before, + 'Unpublished releases cannot advertise an update'); + $release['draft'] = false; + file_put_contents($directory . '/release.json', json_encode($release, JSON_THROW_ON_ERROR)); + $check($run($directory, $name), 'Published matching release generates an update'); + $feed = simplexml_load_file($directory . '/joomengine_mcp_update_server.xml'); + $entries = $feed->xpath('update[version="' . $version . '"]'); + $check(count($entries) === 1 && (string) $entries[0]->sha256 === hash_file('sha256', $directory . '/' . $name) + && (string) $entries[0]->downloads->downloadurl === $release['assets'][0]['browser_download_url'], + 'Published feed binds the correct archive URL, version and checksum'); + $before = file_get_contents($directory . '/joomengine_mcp_update_server.xml'); + $check($run($directory, $name) && file_get_contents($directory . '/joomengine_mcp_update_server.xml') === $before, + 'Repeating verified metadata generation is idempotent'); + file_put_contents($directory . '/' . $name . '.sha256', str_repeat('0', 64) . ' ' . $name . "\n"); + $check(!$run($directory, $name) && file_get_contents($directory . '/joomengine_mcp_update_server.xml') === $before, + 'Mismatched downloaded checksums leave the published feed unchanged'); +} +finally +{ + foreach (glob($directory . '/tools/*') as $file) + { + unlink($file); + } + + rmdir($directory . '/tools'); + + foreach (glob($directory . '/*') as $file) + { + unlink($file); + } + + rmdir($directory); +} + +echo json_encode(['checks' => $checks, 'releaseMetadata' => 'passed'], JSON_THROW_ON_ERROR) . PHP_EOL; diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..f867a3a --- /dev/null +++ b/tests/run.php @@ -0,0 +1,75 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +$root = dirname(__DIR__); +$manifest = simplexml_load_file($root . '/joomengine_mcp.xml'); + +if ($manifest === false || (string) $manifest['group'] !== 'console' + || (string) $manifest->namespace !== 'VDM\\Plugin\\Console\\JoomEngineMcp' + || (string) $manifest->files->folder[0]['plugin'] !== 'joomengine_mcp') +{ + throw new RuntimeException('Plugin identity or namespace contract is invalid.'); +} + +$language = parse_ini_file($root . '/language/en-GB/plg_console_joomengine_mcp.sys.ini'); + +if ($language === false || !isset($language[(string) $manifest->description])) +{ + throw new RuntimeException('The plugin manifest description has no language value.'); +} + +$feed = simplexml_load_file($root . '/joomengine_mcp_update_server.xml'); +$changelog = simplexml_load_file($root . '/joomengine_mcp_changelog.xml'); + +if ($feed === false || $changelog === false) +{ + throw new RuntimeException('Update or changelog XML is invalid.'); +} + +require $root . '/build.php'; +$archive = $root . '/build/plg_console_joomengine_mcp-' . (string) $manifest->version . '.zip'; +$firstHash = hash_file('sha256', $archive); +require $root . '/build.php'; + +if (!hash_equals($firstHash, hash_file('sha256', $archive))) +{ + throw new RuntimeException('The same plugin source did not produce a reproducible archive.'); +} + +$zip = new ZipArchive(); +$zip->open($archive); + +foreach (['joomengine_mcp.xml', 'services/provider.php', 'src/Extension/JoomEngineMcpPlugin.php', 'src/Console/McpCommand.php', 'script.php', 'LICENSE'] as $required) +{ + if ($zip->locateName($required) === false) + { + throw new RuntimeException('The plugin archive is missing an installation dependency.'); + } +} + +for ($index = 0; $index < $zip->numFiles; $index++) +{ + $name = $zip->getNameIndex($index); + $system = 0; + $attributes = 0; + + if (!$zip->getExternalAttributesIndex($index, $system, $attributes) + || $system !== ZipArchive::OPSYS_UNIX || ($attributes >> 16) !== 0100644) + { + throw new RuntimeException('The plugin archive does not normalize source file permissions.'); + } + + if (str_starts_with($name, '/') || str_contains($name, '..') || str_starts_with($name, 'tests/') || str_ends_with($name, '.ts')) + { + throw new RuntimeException('The plugin archive contains a forbidden path.'); + } +} + +$zip->close(); +echo json_encode(['manifest' => 'passed', 'languages' => 'passed', 'package' => 'passed', 'reproducible' => true, 'installedRuntime' => 'Run tests/installed.php separately.'], JSON_PRETTY_PRINT) . PHP_EOL; diff --git a/tools/update-feed.php b/tools/update-feed.php new file mode 100644 index 0000000..d3076d6 --- /dev/null +++ b/tools/update-feed.php @@ -0,0 +1,125 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +/** Generate Joomla update metadata only for an already published verified release. */ +$root = dirname(__DIR__); +$manifest = simplexml_load_file($root . '/joomengine_mcp.xml'); +$version = (string) $manifest->version; +$metadata = $argv[1] ?? ''; +$archive = $argv[2] ?? ''; + +if (PHP_SAPI !== 'cli' || !is_file($metadata) || !is_file($archive) + || preg_match('/\A\d+\.\d+\.\d+\z/D', $version) !== 1) +{ + throw new RuntimeException('Supply published GitHub release JSON and its downloaded plugin ZIP for a stable manifest version.'); +} + +$release = json_decode(file_get_contents($metadata), true, 64, JSON_THROW_ON_ERROR); +$tag = 'v' . $version; +$filename = 'plg_console_joomengine_mcp-' . $version . '.zip'; +$base = 'https://github.com/joomengine/mcp_plugin/releases/'; +$url = $base . 'download/' . $tag . '/' . $filename; + +if (($release['tag_name'] ?? '') !== $tag || ($release['draft'] ?? true) || ($release['prerelease'] ?? true) + || ($release['html_url'] ?? '') !== $base . 'tag/' . $tag || empty($release['published_at']) || basename($archive) !== $filename) +{ + throw new RuntimeException('Update feeds require the matching published stable GitHub release.'); +} + +$assets = []; + +foreach ($release['assets'] ?? [] as $asset) +{ + $assets[$asset['name']] = $asset; +} + +foreach ([$filename, $filename . '.sha256'] as $asset) +{ + if (($assets[$asset]['state'] ?? '') !== 'uploaded' + || ($assets[$asset]['browser_download_url'] ?? '') !== $base . 'download/' . $tag . '/' . $asset) + { + throw new RuntimeException('A release archive or checksum has not been published at its immutable version URL.'); + } +} + +$checksum = hash_file('sha256', $archive); +$expected = is_file($archive . '.sha256') ? trim(file_get_contents($archive . '.sha256')) : ''; + +if (!hash_equals($checksum . ' ' . $filename, $expected) || (int) ($assets[$filename]['size'] ?? -1) !== filesize($archive)) +{ + throw new RuntimeException('The downloaded release archive does not match its published checksum or asset size.'); +} + +$zip = new ZipArchive(); + +if ($zip->open($archive) !== true) +{ + throw new RuntimeException('The published archive is not a ZIP.'); +} + +$packaged = simplexml_load_string((string) $zip->getFromName('joomengine_mcp.xml')); +$zip->close(); + +if ($packaged === false || (string) $packaged->version !== $version + || (string) $packaged['group'] !== 'console' || (string) $packaged->namespace !== (string) $manifest->namespace) +{ + throw new RuntimeException('The published archive has a different extension identity or version.'); +} + +$document = new DOMDocument('1.0', 'utf-8'); +$document->preserveWhiteSpace = false; +$document->formatOutput = true; + +if (!$document->load($root . '/joomengine_mcp_update_server.xml', LIBXML_NONET) || $document->documentElement->nodeName !== 'updates') +{ + throw new RuntimeException('The existing update feed is invalid.'); +} + +$query = new DOMXPath($document); + +foreach ($query->query('/updates/update[version="' . $version . '"]') as $old) +{ + $old->parentNode->removeChild($old); +} + +$update = $document->createElement('update'); +$append = static function (DOMNode $parent, string $name, string $value) use ($document): DOMElement +{ + $element = $document->createElement($name); + $element->appendChild($document->createTextNode($value)); + $parent->appendChild($element); + + return $element; +}; +$append($update, 'name', 'JoomEngine MCP Console'); +$append($update, 'description', 'Local Joomla console integration for JoomEngine MCP.'); +$append($update, 'element', 'joomengine_mcp'); +$append($update, 'type', 'plugin'); +$append($update, 'folder', 'console'); +$append($update, 'version', $version); +$downloads = $document->createElement('downloads'); +$update->appendChild($downloads); +$download = $append($downloads, 'downloadurl', $url); +$download->setAttribute('type', 'full'); +$download->setAttribute('format', 'zip'); +$append($update, 'sha256', $checksum); +$append($update, 'tags', '')->appendChild($document->createElement('tag', 'stable')); +$target = $append($update, 'targetplatform', ''); +$target->setAttribute('name', 'joomla'); +$target->setAttribute('version', '6\\.[1-9][0-9]*'); +$append($update, 'php_minimum', '8.3.0'); +$append($update, 'detailsurl', $release['html_url']); +$document->documentElement->appendChild($update); + +if ($document->save($root . '/joomengine_mcp_update_server.xml') === false) +{ + throw new RuntimeException('Cannot save verified release metadata.'); +} + +echo 'Published update metadata for ' . $tag . PHP_EOL;