diff --git a/.mokogitea/metadata.xml b/.mokogitea/metadata.xml
new file mode 100644
index 0000000..e69de29
diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml
index d4329c1..f056073 100644
--- a/.mokogitea/workflows/issue-branch.yml
+++ b/.mokogitea/workflows/issue-branch.yml
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
-# VERSION: 09.26.00
+# VERSION: 09.26.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a42b72..ca2d889 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,20 @@ BRIEF: Release changelog
# Changelog
## [Unreleased]
+### Added
+- `cli/manifest_integrity.php` — org-wide manifest validation tool (564 lines)
+- `manifest_detect.php` — detect `display_name`, `target_version`, `php_minimum` fields
+
+### Changed
+- MCP servers extracted from monorepo to standalone `A:/MCP/` directories
+- All 9 MCP servers published to npm (`@mokoconsulting/`) and Gitea package registry
+- `.mcp.json` converted from local file paths to `npx -y @mokoconsulting/...@latest`
+- `NPM_TOKEN` saved as MokoConsulting org secret for CI/CD
+- Templates: Joomla Makefile and composer.json updated with MokoSuite references
+
+### Removed
+- `mcp/servers/` directory — all MCP server source moved to `A:/MCP/mcp_*/`
+
## [09.26.00] --- 2026-06-07
### Added
diff --git a/README.md b/README.md
index 0ebbf9f..18ce55c 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ DEFGROUP: MokoPlatform.Root
INGROUP: MokoPlatform
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
PATH: /README.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: Project overview and documentation
-->
diff --git a/cli/branch_rename.php b/cli/branch_rename.php
index 679ba8d..fdb266e 100644
--- a/cli/branch_rename.php
+++ b/cli/branch_rename.php
@@ -10,7 +10,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/branch_rename.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
*/
diff --git a/cli/bulk_workflow_push.php b/cli/bulk_workflow_push.php
index 691c498..542c9f6 100644
--- a/cli/bulk_workflow_push.php
+++ b/cli/bulk_workflow_push.php
@@ -12,7 +12,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/bulk_workflow_push.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
*/
diff --git a/cli/bulk_workflow_trigger.php b/cli/bulk_workflow_trigger.php
index 1befecc..84ec699 100644
--- a/cli/bulk_workflow_trigger.php
+++ b/cli/bulk_workflow_trigger.php
@@ -12,7 +12,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/bulk_workflow_trigger.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Trigger a workflow across multiple repos at once
*/
diff --git a/cli/client_dashboard.php b/cli/client_dashboard.php
index f49f6db..a73dee4 100644
--- a/cli/client_dashboard.php
+++ b/cli/client_dashboard.php
@@ -12,7 +12,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/client_dashboard.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Generate unified client dashboard HTML
*/
diff --git a/cli/client_inventory.php b/cli/client_inventory.php
index dcfa339..fb15c7c 100644
--- a/cli/client_inventory.php
+++ b/cli/client_inventory.php
@@ -12,7 +12,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/client_inventory.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Discover and list all client-waas repos with their server configuration status
*/
diff --git a/cli/client_provision.php b/cli/client_provision.php
index 300edd5..33cc4e7 100644
--- a/cli/client_provision.php
+++ b/cli/client_provision.php
@@ -12,7 +12,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/client_provision.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Provision a new client environment end-to-end
*/
diff --git a/cli/grafana_dashboard.php b/cli/grafana_dashboard.php
index ee7580f..7ffbdee 100644
--- a/cli/grafana_dashboard.php
+++ b/cli/grafana_dashboard.php
@@ -12,7 +12,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/grafana_dashboard.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Manage Grafana dashboards via API
*/
diff --git a/cli/joomla_build.php b/cli/joomla_build.php
index a6cc210..eeadc88 100644
--- a/cli/joomla_build.php
+++ b/cli/joomla_build.php
@@ -10,7 +10,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/joomla_build.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
* NOTE: Called by pre-release and auto-release workflows.
*/
diff --git a/cli/manifest_detect.php b/cli/manifest_detect.php
index c3251d3..2438237 100644
--- a/cli/manifest_detect.php
+++ b/cli/manifest_detect.php
@@ -1,716 +1,4 @@
#!/usr/bin/env php
- *
- * SPDX-License-Identifier: GPL-3.0-or-later
- *
- * FILE INFORMATION
- * DEFGROUP: mokoplatform.CLI
- * INGROUP: mokoplatform
- * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
- * PATH: /cli/manifest_detect.php
- * VERSION: 09.26.00
- * BRIEF: Auto-detect manifest fields from source files and optionally push to API
- */
-
-declare(strict_types=1);
-
-require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
-
-use MokoEnterprise\{CliFramework, SourceResolver};
-
-class ManifestDetectCli extends CliFramework
-{
- protected function configure(): void
- {
- $this->setDescription('Auto-detect manifest fields from source files');
- $this->addArgument('--path', 'Repository root path', '.');
- $this->addArgument('--json', 'Output as JSON', false);
- $this->addArgument('--diff', 'Show diff against current manifest API values', false);
- $this->addArgument('--update', 'Push detected fields to manifest API', false);
- $this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
- $this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
- $this->addArgument('--org', 'Gitea org', 'MokoConsulting');
- $this->addArgument('--repo', 'Gitea repo name (auto-detected from remote if empty)', '');
- $this->addArgument('--github-output', 'Append fields to $GITHUB_OUTPUT', false);
- }
-
- protected function run(): int
- {
- $path = $this->getArgument('--path');
- $jsonMode = (bool) $this->getArgument('--json');
- $diffMode = (bool) $this->getArgument('--diff');
- $updateMode = (bool) $this->getArgument('--update');
- $ghOutput = (bool) $this->getArgument('--github-output');
- $token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
- $apiBase = rtrim($this->getArgument('--api-base'), '/');
- $org = $this->getArgument('--org');
- $repoName = $this->getArgument('--repo');
-
- $root = realpath($path) ?: $path;
-
- if (!is_dir($root)) {
- $this->log('ERROR', "Path does not exist: {$path}");
- return 1;
- }
-
- // Auto-detect repo name from git remote
- if ($repoName === '') {
- $repoName = $this->detectRepoName($root);
- }
-
- // ── Detect all fields ───────────────────────────────────────
- $detected = $this->detectAll($root, $repoName);
-
- // ── Warn about missing fields ────────────────────────────────
- $expected = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point'];
- foreach ($expected as $field) {
- if (!isset($detected[$field]) || $detected[$field] === '') {
- $this->log('WARN', "Could not detect: {$field}");
- }
- }
-
- // ── Output ──────────────────────────────────────────────────
- if ($diffMode || $updateMode) {
- if ($token === '') {
- $this->log('ERROR', 'API token required for --diff/--update (use --token or GITEA_TOKEN env)');
- return 1;
- }
- if ($repoName === '') {
- $this->log('ERROR', 'Could not determine repo name (use --repo)');
- return 1;
- }
-
- $current = $this->fetchManifest($apiBase, $org, $repoName, $token);
- if ($current === null) {
- $this->log('ERROR', 'Failed to fetch current manifest from API');
- return 1;
- }
-
- $changes = $this->computeDiff($current, $detected);
-
- if ($diffMode) {
- if (empty($changes)) {
- $this->log('INFO', 'No differences — manifest matches source');
- } else {
- $this->sectionHeader('Manifest Drift');
- foreach ($changes as $field => $info) {
- $this->log('WARN', sprintf(
- '%-20s API: %-30s Detected: %s',
- $field,
- $info['current'] === '' ? '(empty)' : $info['current'],
- $info['detected']
- ));
- }
- }
- }
-
- if ($updateMode) {
- if (empty($changes)) {
- $this->log('INFO', 'Nothing to update');
- } else {
- $update = array_map(fn($i) => $i['detected'], $changes);
- $ok = $this->pushManifest($apiBase, $org, $repoName, $token, $current, $update);
- if ($ok) {
- $this->log('OK', 'Updated ' . count($update) . ' field(s): ' . implode(', ', array_keys($update)));
- } else {
- $this->log('ERROR', 'Failed to push manifest update');
- return 1;
- }
- }
- }
-
- return 0;
- }
-
- if ($ghOutput) {
- $outputFile = getenv('GITHUB_OUTPUT');
- $lines = [];
- foreach ($detected as $k => $v) {
- $envKey = str_replace('-', '_', $k);
- $lines[] = "{$envKey}={$v}";
- }
- if ($outputFile !== false && $outputFile !== '') {
- file_put_contents($outputFile, implode("\n", $lines) . "\n", FILE_APPEND);
- $this->log('INFO', 'Wrote ' . count($detected) . ' fields to GITHUB_OUTPUT');
- } else {
- $this->log('WARN', 'GITHUB_OUTPUT not set — printing to stdout instead');
- echo implode("\n", $lines) . "\n";
- }
- return 0;
- }
-
- if ($jsonMode) {
- echo json_encode($detected, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
- } else {
- foreach ($detected as $k => $v) {
- echo "{$k}={$v}\n";
- }
- }
-
- return 0;
- }
-
- // =====================================================================
- // Detection engine
- // =====================================================================
-
- private function detectAll(string $root, string $repoName): array
- {
- $platform = $this->detectPlatform($root);
-
- $fields = [
- 'platform' => $platform,
- 'name' => '',
- 'description' => '',
- 'version' => '',
- 'element_name' => '',
- 'package_type' => '',
- 'language' => '',
- 'entry_point' => '',
- 'license_spdx' => '',
- ];
-
- switch ($platform) {
- case 'joomla':
- $this->detectJoomla($root, $repoName, $fields);
- break;
- case 'dolibarr':
- $this->detectDolibarr($root, $repoName, $fields);
- break;
- case 'go':
- $this->detectGo($root, $repoName, $fields);
- break;
- case 'mcp':
- $this->detectNode($root, $repoName, $fields);
- break;
- case 'node':
- $this->detectNode($root, $repoName, $fields);
- $fields['platform'] = 'node';
- break;
- default:
- $this->detectGeneric($root, $repoName, $fields);
- break;
- }
-
- // Fallbacks
- if ($fields['name'] === '') {
- $fields['name'] = $repoName ?: basename($root);
- }
- if ($fields['entry_point'] === '') {
- $fields['entry_point'] = $this->detectEntryPoint($root);
- }
- if ($fields['license_spdx'] === '') {
- $fields['license_spdx'] = $this->detectLicense($root);
- }
- // description: only from platform-specific source, never guessed
-
- // Strip empty values
- return array_filter($fields, fn($v) => $v !== '');
- }
-
- // ── Platform detection ──────────────────────────────────────────
-
- private function detectPlatform(string $root): string
- {
- // Joomla: look for pkg_*.xml or extension XML in source dirs
- $joomlaXmls = array_merge(
- SourceResolver::globSource($root, 'pkg_*.xml'),
- glob("{$root}/pkg_*.xml") ?: []
- );
- if (!empty($joomlaXmls)) {
- return 'joomla';
- }
-
- // Check source dirs for any Joomla extension XML
- foreach (SourceResolver::globSource($root, '*.xml') as $xmlFile) {
- $content = file_get_contents($xmlFile);
- if (strpos($content, 'findJoomlaManifest($root);
- if ($extManifest === null) {
- return;
- }
-
- $xml = file_get_contents($extManifest);
-
- // Type
- $extType = '';
- if (preg_match('/type="([^"]*)"/', $xml, $m)) {
- $extType = $m[1];
- }
- $fields['package_type'] = $extType;
-
- // Element name
- $element = '';
- if (preg_match('/([^<]+)<\/element>/', $xml, $m)) {
- $element = $m[1];
- }
- if ($element === '' && preg_match('/module="([^"]*)"/', $xml, $m)) {
- $element = $m[1];
- }
- if ($element === '' && preg_match('/plugin="([^"]*)"/', $xml, $m)) {
- $element = $m[1];
- }
- if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $m)) {
- $element = $m[1];
- }
- if ($element === '') {
- $element = strtolower(basename($extManifest, '.xml'));
- }
-
- // Ensure element has type prefix (API stores full element_name like pkg_mokosuite)
- $prefixMap = [
- 'package' => 'pkg_', 'component' => 'com_', 'module' => 'mod_',
- 'template' => 'tpl_', 'library' => 'lib_', 'file' => 'file_',
- ];
- if (isset($prefixMap[$extType])) {
- $prefix = $prefixMap[$extType];
- if (strpos($element, $prefix) !== 0 && strpos($element, '_') === false) {
- $element = $prefix . $element;
- }
- } elseif ($extType === 'plugin') {
- $folder = '';
- if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
- $folder = $gm[1];
- }
- if ($folder !== '' && strpos($element, 'plg_') !== 0) {
- $element = "plg_{$folder}_" . $element;
- }
- }
- $fields['element_name'] = $element;
-
- // Name
- if (preg_match('/([^<]+)<\/name>/', $xml, $m)) {
- $fields['name'] = trim($m[1]);
- }
-
- // Version
- if (preg_match('/([^<]+)<\/version>/', $xml, $m)) {
- $fields['version'] = trim($m[1]);
- }
-
- // Description
- if (preg_match('/([^<]+)<\/description>/', $xml, $m)) {
- $desc = trim($m[1]);
- // Skip language string keys like COM_MOKOSUITE_DESCRIPTION
- if (strpos($desc, '_') === false || strlen($desc) > 60) {
- $fields['description'] = $desc;
- }
- }
-
- // License
- if (preg_match('/([^<]+)<\/license>/', $xml, $m)) {
- $fields['license_spdx'] = $this->normalizeLicense(trim($m[1]));
- }
- }
-
- private function findJoomlaManifest(string $root): ?string
- {
- // Priority: pkg_*.xml (package manifest)
- $pkgXmls = array_merge(
- SourceResolver::globSource($root, 'pkg_*.xml'),
- glob("{$root}/pkg_*.xml") ?: []
- );
- if (!empty($pkgXmls)) {
- return $pkgXmls[0];
- }
-
- // Any extension XML in source dir
- foreach (SourceResolver::globSource($root, '*.xml') as $file) {
- $content = file_get_contents($file);
- if (strpos($content, 'findDolibarrModule($root);
- if ($modFile === null) {
- return;
- }
-
- $content = file_get_contents($modFile);
-
- // Element name from class file
- $modBasename = basename($modFile, '.class.php');
- $fields['element_name'] = strtolower(preg_replace('/^mod/', '', $modBasename));
-
- // Name
- if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
- $fields['name'] = $m[1];
- }
-
- // Version
- if (preg_match('/\$this->version\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
- $fields['version'] = $m[1];
- }
-
- // Description
- if (preg_match('/\$this->description\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
- $desc = $m[1];
- if (strpos($desc, '$') === false) {
- $fields['description'] = $desc;
- }
- }
-
- // License
- if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) {
- $fields['license_spdx'] = $m[1];
- }
- }
-
- private function findDolibarrModule(string $root): ?string
- {
- $candidates = array_merge(
- SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
- glob("{$root}/core/modules/mod*.class.php") ?: []
- );
- foreach ($candidates as $file) {
- if (strpos(file_get_contents($file), 'DolibarrModules') !== false) {
- return $file;
- }
- }
- return null;
- }
-
- // ── Go ──────────────────────────────────────────────────────────
-
- private function detectGo(string $root, string $repoName, array &$fields): void
- {
- $fields['language'] = 'Go';
- $fields['package_type'] = 'application';
- $fields['entry_point'] = './';
-
- $goMod = "{$root}/go.mod";
- if (!file_exists($goMod)) {
- return;
- }
-
- $content = file_get_contents($goMod);
-
- // Module path → name
- if (preg_match('/^module\s+(\S+)/m', $content, $m)) {
- $modulePath = $m[1];
- $parts = explode('/', $modulePath);
- $fields['name'] = end($parts);
- }
-
- // Go version
- if (preg_match('/^go\s+(\S+)/m', $content, $m)) {
- // This is Go language version, not the project version
- // Project version comes from git tags or source files
- }
-
- // License
- $fields['license_spdx'] = $this->detectLicense($root);
- }
-
- // ── Node / MCP ──────────────────────────────────────────────────
-
- private function detectNode(string $root, string $repoName, array &$fields): void
- {
- $pkgFile = "{$root}/package.json";
- if (!file_exists($pkgFile)) {
- return;
- }
-
- $pkg = json_decode(file_get_contents($pkgFile), true) ?? [];
-
- $fields['name'] = $pkg['name'] ?? '';
- // Strip npm scope
- if (strpos($fields['name'], '/') !== false) {
- $fields['name'] = explode('/', $fields['name'])[1];
- }
-
- $fields['version'] = $pkg['version'] ?? '';
- $fields['description'] = $pkg['description'] ?? '';
- $fields['license_spdx'] = $pkg['license'] ?? '';
-
- // Language detection
- if (file_exists("{$root}/tsconfig.json")) {
- $fields['language'] = 'TypeScript';
- } else {
- $fields['language'] = 'JavaScript';
- }
-
- // Package type
- $deps = array_merge(
- array_keys($pkg['dependencies'] ?? []),
- array_keys($pkg['devDependencies'] ?? [])
- );
- $isMcp = false;
- foreach ($deps as $dep) {
- if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') {
- $isMcp = true;
- break;
- }
- }
- $fields['package_type'] = $isMcp ? 'mcp-server' : 'application';
-
- // Entry point
- if (file_exists("{$root}/dist")) {
- $fields['entry_point'] = 'dist/';
- } elseif (file_exists("{$root}/src")) {
- $fields['entry_point'] = 'src/';
- } else {
- $fields['entry_point'] = './';
- }
- }
-
- // ── Generic ─────────────────────────────────────────────────────
-
- private function detectGeneric(string $root, string $repoName, array &$fields): void
- {
- $fields['package_type'] = 'generic';
-
- // Try to detect language from file extensions
- $fields['language'] = $this->detectLanguageFromFiles($root);
- $fields['license_spdx'] = $this->detectLicense($root);
- }
-
- // =====================================================================
- // Shared detection helpers
- // =====================================================================
-
- private function detectEntryPoint(string $root): string
- {
- $abs = SourceResolver::resolveAbsolute($root);
- if ($abs !== null) {
- return basename($abs) . '/';
- }
- if (is_dir("{$root}/dist")) return 'dist/';
- if (is_dir("{$root}/src")) return 'src/';
- return './';
- }
-
- private function detectLicense(string $root): string
- {
- // Check LICENSE file
- foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $name) {
- $file = "{$root}/{$name}";
- if (!file_exists($file)) continue;
- $content = file_get_contents($file);
-
- // SPDX header
- if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) {
- return $m[1];
- }
-
- // Common license patterns
- if (strpos($content, 'GNU GENERAL PUBLIC LICENSE') !== false) {
- if (strpos($content, 'Version 3') !== false) return 'GPL-3.0-or-later';
- if (strpos($content, 'Version 2') !== false) return 'GPL-2.0-or-later';
- }
- if (strpos($content, 'MIT License') !== false) return 'MIT';
- if (strpos($content, 'Apache License') !== false && strpos($content, 'Version 2.0') !== false) return 'Apache-2.0';
- }
-
- return '';
- }
-
-
- private function detectLanguageFromFiles(string $root): string
- {
- $counts = ['PHP' => 0, 'Go' => 0, 'TypeScript' => 0, 'JavaScript' => 0, 'Python' => 0, 'Shell' => 0];
-
- $extensions = [
- 'php' => 'PHP', 'go' => 'Go', 'ts' => 'TypeScript',
- 'js' => 'JavaScript', 'py' => 'Python', 'sh' => 'Shell',
- ];
-
- // Quick scan: only check top two levels
- foreach (glob("{$root}/*") ?: [] as $item) {
- $ext = pathinfo($item, PATHINFO_EXTENSION);
- if (isset($extensions[$ext])) {
- $counts[$extensions[$ext]]++;
- }
- if (is_dir($item) && basename($item)[0] !== '.') {
- foreach (glob("{$item}/*") ?: [] as $subItem) {
- $ext = pathinfo($subItem, PATHINFO_EXTENSION);
- if (isset($extensions[$ext])) {
- $counts[$extensions[$ext]]++;
- }
- }
- }
- }
-
- arsort($counts);
- $top = key($counts);
- return $counts[$top] > 0 ? $top : '';
- }
-
- private function normalizeLicense(string $license): string
- {
- $lower = strtolower($license);
- $isGpl = strpos($lower, 'gpl') !== false || strpos($lower, 'general public license') !== false;
- if ($isGpl && strpos($lower, '3') !== false) return 'GPL-3.0-or-later';
- if ($isGpl && strpos($lower, '2') !== false) return 'GPL-2.0-or-later';
- if ($lower === 'mit' || strpos($lower, 'mit license') !== false) return 'MIT';
- if (strpos($lower, 'apache') !== false) return 'Apache-2.0';
- return $license;
- }
-
- private function detectRepoName(string $root): string
- {
- $gitConfig = "{$root}/.git/config";
- if (!file_exists($gitConfig)) {
- return basename($root);
- }
-
- $content = file_get_contents($gitConfig);
- if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
- return $m[1];
- }
-
- return basename($root);
- }
-
- // =====================================================================
- // API interaction
- // =====================================================================
-
- private function fetchManifest(string $apiBase, string $org, string $repo, string $token): ?array
- {
- $url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
- $ctx = stream_context_create([
- 'http' => [
- 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
- 'timeout' => 10,
- ],
- ]);
-
- $body = @file_get_contents($url, false, $ctx);
- if ($body === false) return null;
-
- return json_decode($body, true);
- }
-
- private function computeDiff(array $current, array $detected): array
- {
- // Map detected keys to API keys (underscores match)
- $changes = [];
-
- foreach ($detected as $key => $value) {
- $apiKey = $key;
- $currentVal = $current[$apiKey] ?? '';
-
- // Only flag as changed if detected value is non-empty and differs
- if ($value !== '' && $value !== $currentVal) {
- // Don't overwrite a non-empty API value with a detected value
- // unless the API value is actually empty
- if ($currentVal === '' || $this->shouldOverride($key, $currentVal, $value)) {
- $changes[$key] = [
- 'current' => $currentVal,
- 'detected' => $value,
- ];
- }
- }
- }
-
- return $changes;
- }
-
- private function shouldOverride(string $field, string $current, string $detected): bool
- {
- // Version: detected from source is authoritative
- if ($field === 'version') return true;
-
- // These fields: source files are authoritative
- if (in_array($field, ['element_name', 'package_type', 'language', 'entry_point'], true)) {
- return true;
- }
-
- // For other fields, only fill empty — don't overwrite manual edits
- return false;
- }
-
- private function pushManifest(string $apiBase, string $org, string $repo, string $token, array $current, array $update): bool
- {
- $merged = array_merge($current, $update);
- $url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
- $payload = json_encode($merged);
-
- $ctx = stream_context_create([
- 'http' => [
- 'method' => 'PUT',
- 'header' => "Authorization: token {$token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
- 'content' => $payload,
- 'timeout' => 10,
- ],
- ]);
-
- $body = @file_get_contents($url, false, $ctx);
- return $body !== false;
- }
-}
-
-$app = new ManifestDetectCli();
-exit($app->execute());
+// Backward-compatibility wrapper — manifest_* renamed to metadata_*
+require __DIR__ . '/metadata_detect.php';
diff --git a/cli/manifest_element.php b/cli/manifest_element.php
index 3be1ee1..c2d0209 100644
--- a/cli/manifest_element.php
+++ b/cli/manifest_element.php
@@ -1,191 +1,4 @@
#!/usr/bin/env php
- *
- * SPDX-License-Identifier: GPL-3.0-or-later
- *
- * FILE INFORMATION
- * DEFGROUP: mokoplatform.CLI
- * INGROUP: mokoplatform
- * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
- * PATH: /cli/manifest_element.php
- * BRIEF: Extract element name, type, type prefix, and ZIP name from manifest
- */
-
-declare(strict_types=1);
-
-require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
-
-use MokoEnterprise\{CliFramework, SourceResolver};
-
-class ManifestElementCli extends CliFramework
-{
- protected function configure(): void
- {
- $this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
- $this->addArgument('--path', 'Repository root', '.');
- $this->addArgument('--version', 'Version string', null);
- $this->addArgument('--stability', 'Stability level', 'stable');
- $this->addArgument('--repo', 'Repository name', '');
- $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
- }
-
- protected function run(): int
- {
- $path = $this->getArgument('--path');
- $version = $this->getArgument('--version');
- $stability = $this->getArgument('--stability');
- $repoName = $this->getArgument('--repo');
- $githubOutput = (bool) $this->getArgument('--github-output');
- $root = realpath($path) ?: $path;
- $platform = 'generic';
- $manifestXml = "{$root}/.mokogitea/manifest.xml";
- if (file_exists($manifestXml)) {
- $content = file_get_contents($manifestXml);
- if (preg_match('/([^<]+)<\/platform>/', $content, $pm)) {
- $platform = trim($pm[1]);
- }
- }
- $extManifest = null;
- $manifestFiles = array_merge(SourceResolver::globSource($root, 'pkg_*.xml'), SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: []);
- foreach ($manifestFiles as $file) {
- $c = file_get_contents($file);
- if (strpos($c, '([^<]+)<\/element>/', $xml, $em)) {
- $extElement = $em[1];
- }
- if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
- $extElement = $mm[1];
- }
- if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
- $extElement = $pm2[1];
- }
- if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) {
- $extElement = $pn[1];
- }
- if (empty($extElement)) {
- $extElement = strtolower(basename($extManifest, '.xml'));
- if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
- $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
- }
- }
- if (preg_match('/([^<]+)<\/name>/', $xml, $nm)) {
- $extName = trim($nm[1]);
- }
- break;
- case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
- $extType = 'dolibarr-module';
- $modBasename = basename($modFile, '.class.php');
- $extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
- $modContent = file_get_contents($modFile);
- if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
- $extName = $nm[1];
- }
- break;
- default:
- $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
- $extType = 'generic';
- break;
- }
- $extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
- $typePrefix = '';
- switch ($extType) {
- case 'plugin':
- $typePrefix = "plg_{$extFolder}_";
- break;
- case 'module':
- $typePrefix = 'mod_';
- break;
- case 'component':
- $typePrefix = 'com_';
- break;
- case 'template':
- $typePrefix = 'tpl_';
- break;
- case 'library':
- $typePrefix = 'lib_';
- break;
- case 'package':
- $typePrefix = 'pkg_';
- break;
- }
- $suffixMap = [
- 'development' => '-dev',
- 'dev' => '-dev',
- 'alpha' => '-alpha',
- 'beta' => '-beta',
- 'rc' => '-rc',
- 'release-candidate' => '-rc',
- 'stable' => '',
- ];
- $suffix = $suffixMap[$stability] ?? '';
- $zipName = '';
- if ($version !== null) {
- $zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
- }
- if (empty($extName)) {
- $extName = $repoName ?: basename($root);
- }
- $outputs = [
- 'platform' => $platform,
- 'ext_element' => $extElement,
- 'ext_type' => $extType,
- 'ext_folder' => $extFolder,
- 'ext_name' => $extName,
- 'type_prefix' => $typePrefix,
- 'zip_name' => $zipName,
- ];
- if ($githubOutput) {
- $ghOutput = getenv('GITHUB_OUTPUT');
- $lines = [];
- foreach ($outputs as $key => $value) {
- $lines[] = "{$key}={$value}";
- }
- if ($ghOutput) {
- file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
- } else {
- foreach ($outputs as $key => $value) {
- echo "::set-output name={$key}::{$value}\n";
- }
- }
- } else {
- foreach ($outputs as $key => $value) {
- echo "{$key}={$value}\n";
- }
- }
- return 0;
- }
-}
-
-$app = new ManifestElementCli();
-exit($app->execute());
+// Backward-compatibility wrapper — manifest_* renamed to metadata_*
+require __DIR__ . '/metadata_element.php';
diff --git a/cli/manifest_integrity.php b/cli/manifest_integrity.php
new file mode 100644
index 0000000..67b8879
--- /dev/null
+++ b/cli/manifest_integrity.php
@@ -0,0 +1,4 @@
+#!/usr/bin/env php
+
- *
- * SPDX-License-Identifier: GPL-3.0-or-later
- *
- * FILE INFORMATION
- * DEFGROUP: mokoplatform.CLI
- * INGROUP: mokoplatform
- * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
- * PATH: /cli/manifest_licensing.php
- * VERSION: 09.26.00
- * BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests
- */
-
-declare(strict_types=1);
-
-require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
-
-use MokoEnterprise\{CliFramework, SourceResolver};
-
-/**
- * Reads the block from .mokogitea/manifest.xml and ensures that the
- * Joomla extension manifest contains the correct and tags.
- *
- * manifest.xml licensing block example:
- *
- *
- * true
- * true
- * https://git.mokoconsulting.tech/{org}/{repo}/updates.xml
- * MyExtension Updates
- *
- *
- * Supports {org} and {repo} placeholders in update-server URL, resolved from
- * the manifest's block or git remote.
- */
-class ManifestLicensingCli extends CliFramework
-{
- protected function configure(): void
- {
- $this->setDescription('Ensure licensing tags (updateservers, dlid) in Joomla extension manifests');
- $this->addArgument('--path', 'Repository root path', '.');
- $this->addArgument('--fix', 'Apply fixes (default: dry-run check only)', false);
- $this->addArgument('--github-output', 'Write results to $GITHUB_OUTPUT', false);
- }
-
- protected function run(): int
- {
- $root = realpath($this->getArgument('--path')) ?: $this->getArgument('--path');
- $fix = (bool) $this->getArgument('--fix');
- $ghOutput = (bool) $this->getArgument('--github-output');
-
- // ── 1. Read manifest.xml ──────────────────────────────────────────
- $manifestFile = "{$root}/.mokogitea/manifest.xml";
-
- if (!file_exists($manifestFile)) {
- $this->log('WARN', "No manifest.xml found at {$manifestFile}");
- $this->outputResult($ghOutput, 'skipped', 'No manifest.xml');
- return 0;
- }
-
- $xml = @simplexml_load_file($manifestFile);
-
- if ($xml === false) {
- $this->log('ERROR', "Failed to parse {$manifestFile}");
- return 1;
- }
-
- // ── 2. Check if licensing is enabled ──────────────────────────────
- if (!isset($xml->licensing) || (string) ($xml->licensing->enabled ?? '') !== 'true') {
- $this->log('INFO', 'Licensing not enabled in manifest.xml — skipping');
- $this->outputResult($ghOutput, 'skipped', 'Licensing not enabled');
- return 0;
- }
-
- $licensingNode = $xml->licensing;
- $dlidEnabled = ((string) ($licensingNode->dlid ?? 'true')) === 'true';
- $updateServerUrl = (string) ($licensingNode->{'update-server'} ?? '');
- $updateServerName = (string) ($licensingNode->{'update-server-name'} ?? '');
-
- // ── 3. Resolve placeholders ───────────────────────────────────────
- $org = (string) ($xml->identity->org ?? '');
- $repo = (string) ($xml->identity->name ?? '');
-
- // Fallback to git remote if manifest doesn't have org/name
- if (empty($org) || empty($repo)) {
- $remote = trim((string) @shell_exec("cd " . escapeshellarg($root) . " && git remote get-url origin 2>/dev/null"));
-
- if (preg_match('#[/:]([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
- if (empty($org)) {
- $org = $m[1];
- }
- if (empty($repo)) {
- $repo = $m[2];
- }
- }
- }
-
- // Default update server URL if not specified
- if (empty($updateServerUrl) && !empty($org) && !empty($repo)) {
- $updateServerUrl = "https://git.mokoconsulting.tech/{$org}/{$repo}/updates.xml";
- }
-
- // Resolve {org} and {repo} placeholders
- $updateServerUrl = str_replace(['{org}', '{repo}'], [$org, $repo], $updateServerUrl);
-
- // Default server name from display-name or repo name
- if (empty($updateServerName)) {
- $displayName = (string) ($xml->identity->{'display-name'} ?? $repo);
- $updateServerName = $displayName . ' Updates';
- }
-
- if (empty($updateServerUrl)) {
- $this->log('ERROR', 'Cannot determine update server URL — set in manifest.xml or ensure org/repo are available');
- return 1;
- }
-
- $this->log('INFO', "Licensing enabled — org={$org}, repo={$repo}");
- $this->log('INFO', "Update server: {$updateServerUrl}");
- $this->log('INFO', "DLID required: " . ($dlidEnabled ? 'yes' : 'no'));
-
- // ── 4. Find Joomla extension manifests ────────────────────────────
- $xmlFiles = array_merge(
- SourceResolver::globSource($root, '*.xml'),
- SourceResolver::globSource($root, 'packages/*/*.xml'),
- glob("{$root}/*.xml") ?: []
- );
-
- $packageManifest = null;
-
- foreach ($xmlFiles as $file) {
- $content = file_get_contents($file);
-
- if (!str_contains($content, 'log('WARN', 'No Joomla extension manifest found');
- $this->outputResult($ghOutput, 'skipped', 'No extension manifest');
- return 0;
- }
-
- $relPath = str_replace($root . '/', '', str_replace('\\', '/', $packageManifest));
- $this->log('INFO', "Package manifest: {$relPath}");
-
- // ── 5. Check and fix the manifest ─────────────────────────────────
- $content = file_get_contents($packageManifest);
- $original = $content;
- $changes = [];
-
- // --- 5a. Ensure block with correct URL ---
- if (preg_match('#\s*#s', $content)) {
- // Empty updateservers block — inject the server
- $replacement = "\n"
- . " {$updateServerUrl}\n"
- . " ";
- $content = preg_replace('#\s*#s', $replacement, $content);
- $changes[] = 'Added update server URL to empty ';
- } elseif (!str_contains($content, '')) {
- // No updateservers at all — add before
- $serverBlock = "\n \n"
- . " {$updateServerUrl}\n"
- . " \n";
- $content = str_replace('', $serverBlock . '', $content);
- $changes[] = 'Added block';
- } else {
- // updateservers exists — verify URL is correct
- if (preg_match('#]*>([^<]+)#', $content, $m)) {
- if ($m[1] !== $updateServerUrl) {
- $content = preg_replace(
- '#(]*>)[^<]+()#',
- "\${1}{$updateServerUrl}\${2}",
- $content
- );
- $changes[] = "Updated server URL: {$m[1]} → {$updateServerUrl}";
- }
- }
- }
-
- // --- 5b. Ensure tag if required ---
- if ($dlidEnabled) {
- if (!str_contains($content, ' if present, otherwise before
- $dlidTag = ' ' . "\n";
-
- if (str_contains($content, '')) {
- $content = str_replace('', $dlidTag . "\n ", $content);
- } else {
- $content = str_replace('', $dlidTag . '', $content);
- }
-
- $changes[] = 'Added tag';
- }
- }
-
- // --- 5c. Ensure for packages ---
- if (str_contains($content, 'type="package"') && !str_contains($content, '')) {
- $blockTag = ' true' . "\n";
-
- if (str_contains($content, '
- $content = preg_replace(
- '#(\s*\n)#',
- "\${1}{$blockTag}",
- $content
- );
- } elseif (str_contains($content, '')) {
- $content = str_replace('', $blockTag . "\n ", $content);
- } else {
- $content = str_replace('', $blockTag . '', $content);
- }
-
- $changes[] = 'Added true';
- }
-
- // ── 6. Report and apply ───────────────────────────────────────────
- if (empty($changes)) {
- $this->log('INFO', 'All licensing tags are correct — no changes needed');
- $this->outputResult($ghOutput, 'ok', 'No changes needed');
- return 0;
- }
-
- foreach ($changes as $change) {
- $this->log($fix ? 'INFO' : 'WARN', ($fix ? 'Fixed: ' : 'Needs fix: ') . $change);
- }
-
- if ($fix) {
- file_put_contents($packageManifest, $content);
- $this->log('INFO', "Wrote {$relPath} with " . count($changes) . " change(s)");
- $this->outputResult($ghOutput, 'fixed', implode('; ', $changes));
- } else {
- $this->log('WARN', 'Run with --fix to apply changes');
- $this->outputResult($ghOutput, 'needs-fix', implode('; ', $changes));
- return 1;
- }
-
- return 0;
- }
-
- /**
- * Write result to $GITHUB_OUTPUT if requested.
- */
- private function outputResult(bool $ghOutput, string $status, string $detail): void
- {
- if (!$ghOutput) {
- return;
- }
-
- $outputFile = getenv('GITHUB_OUTPUT');
-
- if ($outputFile === false || $outputFile === '') {
- echo "licensing_status={$status}\n";
- echo "licensing_detail={$detail}\n";
- return;
- }
-
- $fh = fopen($outputFile, 'a');
- fwrite($fh, "licensing_status={$status}\n");
- fwrite($fh, "licensing_detail={$detail}\n");
- fclose($fh);
- }
-}
-
-$app = new ManifestLicensingCli();
-exit($app->execute());
+// Backward-compatibility wrapper — manifest_* renamed to metadata_*
+require __DIR__ . '/metadata_licensing.php';
diff --git a/cli/manifest_read.php b/cli/manifest_read.php
index 988b39c..2937e2b 100644
--- a/cli/manifest_read.php
+++ b/cli/manifest_read.php
@@ -1,170 +1,4 @@
#!/usr/bin/env php
- *
- * SPDX-License-Identifier: GPL-3.0-or-later
- *
- * FILE INFORMATION
- * DEFGROUP: mokoplatform.CLI
- * INGROUP: mokoplatform
- * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
- * PATH: /cli/manifest_read.php
- * VERSION: 09.26.00
- * BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption
- */
-
-declare(strict_types=1);
-
-require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
-
-use MokoEnterprise\CliFramework;
-
-class ManifestReadCli extends CliFramework
-{
- protected function configure(): void
- {
- $this->setDescription('Parse manifest.xml and output requested field(s) for CI consumption');
- $this->addArgument('--path', 'Repository root path', '.');
- $this->addArgument('--field', 'Single field name to output', '');
- $this->addArgument('--all', 'Print all fields as KEY=VALUE lines', false);
- $this->addArgument('--github-output', 'Append all fields to $GITHUB_OUTPUT', false);
- $this->addArgument('--json', 'Output all fields as JSON', false);
- }
-
- protected function run(): int
- {
- $path = $this->getArgument('--path');
- $field = $this->getArgument('--field');
- $showAll = $this->getArgument('--all');
- $ghOutput = $this->getArgument('--github-output');
- $jsonMode = $this->getArgument('--json');
-
- // Determine mode
- if ($ghOutput) {
- $mode = 'github-output';
- } elseif ($showAll) {
- $mode = 'all';
- } elseif ($jsonMode) {
- $mode = 'json';
- } else {
- $mode = 'field';
- }
-
- // -- Locate manifest --
- $root = realpath($path) ?: $path;
- $manifestFile = null;
-
- // Priority: manifest.xml (current standard)
- $candidates = [
- "{$root}/.mokogitea/manifest.xml",
- "{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed)
- "{$root}/.mokogitea/.mokoplatform", // legacy v4
- ];
-
- foreach ($candidates as $candidate) {
- if (file_exists($candidate)) {
- $manifestFile = $candidate;
- break;
- }
- }
-
- if ($manifestFile === null) {
- $this->log('ERROR', "No manifest found in {$root}");
- return 1;
- }
-
- // -- Parse XML --
- $xml = @simplexml_load_file($manifestFile);
-
- if ($xml === false) {
- // Fallback: try YAML format (.mokostandards legacy)
- $content = file_get_contents($manifestFile);
- $fields = [];
- if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
- $fields['platform'] = trim($m[1], " \t\n\r\"'");
- }
- if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) {
- $fields['standards-version'] = trim($m[1], " \t\n\r\"'");
- }
- if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) {
- $fields['name'] = trim($m[1], " \t\n\r\"'");
- }
- } else {
- // Register namespace for XPath (optional, simple path works without)
- $fields = [
- 'name' => (string)($xml->identity->name ?? ''),
- 'display-name' => (string)($xml->identity->{"display-name"} ?? ''),
- 'org' => (string)($xml->identity->org ?? ''),
- 'description' => (string)($xml->identity->description ?? ''),
- 'license' => (string)($xml->identity->license ?? ''),
- 'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''),
- 'platform' => (string)($xml->governance->platform ?? ''),
- 'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''),
- 'standards-source' => (string)($xml->governance->{"standards-source"} ?? ''),
- 'language' => (string)($xml->build->language ?? ''),
- 'package-type' => (string)($xml->build->{"package-type"} ?? ''),
- 'entry-point' => (string)($xml->build->{"entry-point"} ?? ''),
- 'version' => (string)($xml->identity->version ?? ''),
- 'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''),
- 'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''),
- 'excludes' => (string)($xml->deploy->excludes ?? ''),
- 'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''),
- 'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''),
- 'manifest-file' => $manifestFile,
- ];
- }
-
- // Strip empty values for cleaner output
- $fields = array_filter($fields, fn($v) => $v !== '');
-
- // -- Output --
- switch ($mode) {
- case 'field':
- if ($field === '') {
- $this->log('ERROR', "Usage: manifest_read.php --path --field ");
- $this->log('ERROR', " manifest_read.php --path --all");
- $this->log('ERROR', " manifest_read.php --path --json");
- $this->log('ERROR', " manifest_read.php --path --github-output");
- return 2;
- }
- echo ($fields[$field] ?? '') . "\n";
- break;
-
- case 'all':
- foreach ($fields as $k => $v) {
- echo "{$k}={$v}\n";
- }
- break;
-
- case 'json':
- echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
- break;
-
- case 'github-output':
- $outputFile = getenv('GITHUB_OUTPUT');
- if ($outputFile === false || $outputFile === '') {
- $this->log('ERROR', 'GITHUB_OUTPUT not set — printing to stdout instead');
- foreach ($fields as $k => $v) {
- // Convert field-name to FIELD_NAME for env var style
- $envKey = str_replace('-', '_', $k);
- echo "{$envKey}={$v}\n";
- }
- } else {
- $fh = fopen($outputFile, 'a');
- foreach ($fields as $k => $v) {
- $envKey = str_replace('-', '_', $k);
- fwrite($fh, "{$envKey}={$v}\n");
- }
- fclose($fh);
- $this->log('INFO', "Wrote " . count($fields) . " fields to GITHUB_OUTPUT");
- }
- break;
- }
-
- return 0;
- }
-}
-
-$app = new ManifestReadCli();
-exit($app->execute());
+// Backward-compatibility wrapper — manifest_* renamed to metadata_*
+require __DIR__ . '/metadata_read.php';
diff --git a/cli/metadata_detect.php b/cli/metadata_detect.php
new file mode 100644
index 0000000..5cb6203
--- /dev/null
+++ b/cli/metadata_detect.php
@@ -0,0 +1,749 @@
+#!/usr/bin/env php
+
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * FILE INFORMATION
+ * DEFGROUP: mokoplatform.CLI
+ * INGROUP: mokoplatform
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
+ * PATH: /cli/manifest_detect.php
+ * VERSION: 09.26.01
+ * BRIEF: Auto-detect manifest fields from source files and optionally push to API
+ */
+
+declare(strict_types=1);
+
+require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
+
+use MokoEnterprise\{CliFramework, SourceResolver};
+
+class ManifestDetectCli extends CliFramework
+{
+ protected function configure(): void
+ {
+ $this->setDescription('Auto-detect manifest fields from source files');
+ $this->addArgument('--path', 'Repository root path', '.');
+ $this->addArgument('--json', 'Output as JSON', false);
+ $this->addArgument('--diff', 'Show diff against current manifest API values', false);
+ $this->addArgument('--update', 'Push detected fields to manifest API', false);
+ $this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
+ $this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
+ $this->addArgument('--org', 'Gitea org', 'MokoConsulting');
+ $this->addArgument('--repo', 'Gitea repo name (auto-detected from remote if empty)', '');
+ $this->addArgument('--github-output', 'Append fields to $GITHUB_OUTPUT', false);
+ }
+
+ protected function run(): int
+ {
+ $path = $this->getArgument('--path');
+ $jsonMode = (bool) $this->getArgument('--json');
+ $diffMode = (bool) $this->getArgument('--diff');
+ $updateMode = (bool) $this->getArgument('--update');
+ $ghOutput = (bool) $this->getArgument('--github-output');
+ $token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
+ $apiBase = rtrim($this->getArgument('--api-base'), '/');
+ $org = $this->getArgument('--org');
+ $repoName = $this->getArgument('--repo');
+
+ $root = realpath($path) ?: $path;
+
+ if (!is_dir($root)) {
+ $this->log('ERROR', "Path does not exist: {$path}");
+ return 1;
+ }
+
+ // Auto-detect repo name from git remote
+ if ($repoName === '') {
+ $repoName = $this->detectRepoName($root);
+ }
+
+ // ── Detect all fields ───────────────────────────────────────
+ $detected = $this->detectAll($root, $repoName);
+
+ // ── Warn about missing fields ────────────────────────────────
+ $expected = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point'];
+ foreach ($expected as $field) {
+ if (!isset($detected[$field]) || $detected[$field] === '') {
+ $this->log('WARN', "Could not detect: {$field}");
+ }
+ }
+
+ // ── Output ──────────────────────────────────────────────────
+ if ($diffMode || $updateMode) {
+ if ($token === '') {
+ $this->log('ERROR', 'API token required for --diff/--update (use --token or GITEA_TOKEN env)');
+ return 1;
+ }
+ if ($repoName === '') {
+ $this->log('ERROR', 'Could not determine repo name (use --repo)');
+ return 1;
+ }
+
+ $current = $this->fetchManifest($apiBase, $org, $repoName, $token);
+ if ($current === null) {
+ $this->log('ERROR', 'Failed to fetch current manifest from API');
+ return 1;
+ }
+
+ $changes = $this->computeDiff($current, $detected);
+
+ if ($diffMode) {
+ if (empty($changes)) {
+ $this->log('INFO', 'No differences — manifest matches source');
+ } else {
+ $this->sectionHeader('Manifest Drift');
+ foreach ($changes as $field => $info) {
+ $this->log('WARN', sprintf(
+ '%-20s API: %-30s Detected: %s',
+ $field,
+ $info['current'] === '' ? '(empty)' : $info['current'],
+ $info['detected']
+ ));
+ }
+ }
+ }
+
+ if ($updateMode) {
+ if (empty($changes)) {
+ $this->log('INFO', 'Nothing to update');
+ } else {
+ $update = array_map(fn($i) => $i['detected'], $changes);
+ $ok = $this->pushManifest($apiBase, $org, $repoName, $token, $current, $update);
+ if ($ok) {
+ $this->log('OK', 'Updated ' . count($update) . ' field(s): ' . implode(', ', array_keys($update)));
+ } else {
+ $this->log('ERROR', 'Failed to push manifest update');
+ return 1;
+ }
+ }
+ }
+
+ return 0;
+ }
+
+ if ($ghOutput) {
+ $outputFile = getenv('GITHUB_OUTPUT');
+ $lines = [];
+ foreach ($detected as $k => $v) {
+ $envKey = str_replace('-', '_', $k);
+ $lines[] = "{$envKey}={$v}";
+ }
+ if ($outputFile !== false && $outputFile !== '') {
+ file_put_contents($outputFile, implode("\n", $lines) . "\n", FILE_APPEND);
+ $this->log('INFO', 'Wrote ' . count($detected) . ' fields to GITHUB_OUTPUT');
+ } else {
+ $this->log('WARN', 'GITHUB_OUTPUT not set — printing to stdout instead');
+ echo implode("\n", $lines) . "\n";
+ }
+ return 0;
+ }
+
+ if ($jsonMode) {
+ echo json_encode($detected, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
+ } else {
+ foreach ($detected as $k => $v) {
+ echo "{$k}={$v}\n";
+ }
+ }
+
+ return 0;
+ }
+
+ // =====================================================================
+ // Detection engine
+ // =====================================================================
+
+ private function detectAll(string $root, string $repoName): array
+ {
+ $platform = $this->detectPlatform($root);
+
+ $fields = [
+ 'platform' => $platform,
+ 'name' => '',
+ 'description' => '',
+ 'version' => '',
+ 'element_name' => '',
+ 'package_type' => '',
+ 'language' => '',
+ 'entry_point' => '',
+ 'license_spdx' => '',
+ 'display_name' => '',
+ 'target_version' => '',
+ 'php_minimum' => '',
+ ];
+
+ switch ($platform) {
+ case 'joomla':
+ $this->detectJoomla($root, $repoName, $fields);
+ break;
+ case 'dolibarr':
+ $this->detectDolibarr($root, $repoName, $fields);
+ break;
+ case 'go':
+ $this->detectGo($root, $repoName, $fields);
+ break;
+ case 'mcp':
+ $this->detectNode($root, $repoName, $fields);
+ break;
+ case 'node':
+ $this->detectNode($root, $repoName, $fields);
+ $fields['platform'] = 'node';
+ break;
+ default:
+ $this->detectGeneric($root, $repoName, $fields);
+ break;
+ }
+
+ // Fallbacks
+ if ($fields['name'] === '') {
+ $fields['name'] = $repoName ?: basename($root);
+ }
+ if ($fields['entry_point'] === '') {
+ $fields['entry_point'] = $this->detectEntryPoint($root);
+ }
+ if ($fields['license_spdx'] === '') {
+ $fields['license_spdx'] = $this->detectLicense($root);
+ }
+ // description: only from platform-specific source, never guessed
+
+ // Strip empty values
+ return array_filter($fields, fn($v) => $v !== '');
+ }
+
+ // ── Platform detection ──────────────────────────────────────────
+
+ private function detectPlatform(string $root): string
+ {
+ // Joomla: look for pkg_*.xml or extension XML in source dirs
+ $joomlaXmls = array_merge(
+ SourceResolver::globSource($root, 'pkg_*.xml'),
+ glob("{$root}/pkg_*.xml") ?: []
+ );
+ if (!empty($joomlaXmls)) {
+ return 'joomla';
+ }
+
+ // Check source dirs for any Joomla extension XML
+ foreach (SourceResolver::globSource($root, '*.xml') as $xmlFile) {
+ $content = file_get_contents($xmlFile);
+ if (strpos($content, 'findJoomlaManifest($root);
+ if ($extManifest === null) {
+ return;
+ }
+
+ $xml = file_get_contents($extManifest);
+
+ // Type
+ $extType = '';
+ if (preg_match('/type="([^"]*)"/', $xml, $m)) {
+ $extType = $m[1];
+ }
+ $fields['package_type'] = $extType;
+
+ // Element name
+ $element = '';
+ if (preg_match('/([^<]+)<\/element>/', $xml, $m)) {
+ $element = $m[1];
+ }
+ if ($element === '' && preg_match('/module="([^"]*)"/', $xml, $m)) {
+ $element = $m[1];
+ }
+ if ($element === '' && preg_match('/plugin="([^"]*)"/', $xml, $m)) {
+ $element = $m[1];
+ }
+ if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $m)) {
+ $element = $m[1];
+ }
+ if ($element === '') {
+ $element = strtolower(basename($extManifest, '.xml'));
+ }
+
+ // Ensure element has type prefix (API stores full element_name like pkg_mokosuite)
+ $prefixMap = [
+ 'package' => 'pkg_', 'component' => 'com_', 'module' => 'mod_',
+ 'template' => 'tpl_', 'library' => 'lib_', 'file' => 'file_',
+ ];
+ if (isset($prefixMap[$extType])) {
+ $prefix = $prefixMap[$extType];
+ // Only add prefix if not already present (check all known prefixes)
+ $hasPrefix = false;
+ foreach ($prefixMap as $p) {
+ if (strpos($element, $p) === 0) { $hasPrefix = true; break; }
+ }
+ if (strpos($element, 'plg_') === 0) { $hasPrefix = true; }
+ if (!$hasPrefix) {
+ $element = $prefix . $element;
+ }
+ } elseif ($extType === 'plugin') {
+ $folder = '';
+ if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
+ $folder = $gm[1];
+ }
+ if ($folder !== '' && strpos($element, 'plg_') !== 0) {
+ $element = "plg_{$folder}_" . $element;
+ }
+ }
+ $fields['element_name'] = $element;
+
+ // Name
+ if (preg_match('/([^<]+)<\/name>/', $xml, $m)) {
+ $fields['name'] = trim($m[1]);
+ }
+
+ // Version
+ if (preg_match('/([^<]+)<\/version>/', $xml, $m)) {
+ $fields['version'] = trim($m[1]);
+ }
+
+ // Description
+ if (preg_match('/([^<]+)<\/description>/', $xml, $m)) {
+ $desc = trim($m[1]);
+ // Skip language string keys like COM_MOKOSUITE_DESCRIPTION
+ if (strpos($desc, '_') === false || strlen($desc) > 60) {
+ $fields['description'] = $desc;
+ }
+ }
+
+ // Display name for update feeds
+ if (!empty($fields['name'])) {
+ $name = $fields['name'];
+ // If name already has "Type - " prefix, use as-is
+ if (preg_match('/^(Package|Component|Module|Plugin|Template|Library)\s*-\s*/i', $name)) {
+ $fields['display_name'] = $name;
+ } elseif (!empty($extType)) {
+ $fields['display_name'] = ucfirst($extType) . ' - ' . $name;
+ }
+ }
+
+ // Target Joomla version
+ if (preg_match('/]*version="([^"]+)"/', $xml, $m)) {
+ $fields['target_version'] = trim($m[1]);
+ } else {
+ // Default for Joomla 5/6
+ $fields['target_version'] = '(5|6)\..*';
+ }
+
+ // PHP minimum
+ if (preg_match('/([^<]+)<\/php_minimum>/', $xml, $m)) {
+ $fields['php_minimum'] = trim($m[1]);
+ }
+
+ // License
+ if (preg_match('/([^<]+)<\/license>/', $xml, $m)) {
+ $fields['license_spdx'] = $this->normalizeLicense(trim($m[1]));
+ }
+ }
+
+ private function findJoomlaManifest(string $root): ?string
+ {
+ // Priority: pkg_*.xml (package manifest)
+ $pkgXmls = array_merge(
+ SourceResolver::globSource($root, 'pkg_*.xml'),
+ glob("{$root}/pkg_*.xml") ?: []
+ );
+ if (!empty($pkgXmls)) {
+ return $pkgXmls[0];
+ }
+
+ // Any extension XML in source dir
+ foreach (SourceResolver::globSource($root, '*.xml') as $file) {
+ $content = file_get_contents($file);
+ if (strpos($content, 'findDolibarrModule($root);
+ if ($modFile === null) {
+ return;
+ }
+
+ $content = file_get_contents($modFile);
+
+ // Element name from class file
+ $modBasename = basename($modFile, '.class.php');
+ $fields['element_name'] = strtolower(preg_replace('/^mod/', '', $modBasename));
+
+ // Name
+ if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
+ $fields['name'] = $m[1];
+ }
+
+ // Version
+ if (preg_match('/\$this->version\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
+ $fields['version'] = $m[1];
+ }
+
+ // Description
+ if (preg_match('/\$this->description\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) {
+ $desc = $m[1];
+ if (strpos($desc, '$') === false) {
+ $fields['description'] = $desc;
+ }
+ }
+
+ // License
+ if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) {
+ $fields['license_spdx'] = $m[1];
+ }
+ }
+
+ private function findDolibarrModule(string $root): ?string
+ {
+ $candidates = array_merge(
+ SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
+ glob("{$root}/core/modules/mod*.class.php") ?: []
+ );
+ foreach ($candidates as $file) {
+ if (strpos(file_get_contents($file), 'DolibarrModules') !== false) {
+ return $file;
+ }
+ }
+ return null;
+ }
+
+ // ── Go ──────────────────────────────────────────────────────────
+
+ private function detectGo(string $root, string $repoName, array &$fields): void
+ {
+ $fields['language'] = 'Go';
+ $fields['package_type'] = 'application';
+ $fields['entry_point'] = './';
+
+ $goMod = "{$root}/go.mod";
+ if (!file_exists($goMod)) {
+ return;
+ }
+
+ $content = file_get_contents($goMod);
+
+ // Module path → name
+ if (preg_match('/^module\s+(\S+)/m', $content, $m)) {
+ $modulePath = $m[1];
+ $parts = explode('/', $modulePath);
+ $fields['name'] = end($parts);
+ }
+
+ // Go version
+ if (preg_match('/^go\s+(\S+)/m', $content, $m)) {
+ // This is Go language version, not the project version
+ // Project version comes from git tags or source files
+ }
+
+ // License
+ $fields['license_spdx'] = $this->detectLicense($root);
+ }
+
+ // ── Node / MCP ──────────────────────────────────────────────────
+
+ private function detectNode(string $root, string $repoName, array &$fields): void
+ {
+ $pkgFile = "{$root}/package.json";
+ if (!file_exists($pkgFile)) {
+ return;
+ }
+
+ $pkg = json_decode(file_get_contents($pkgFile), true) ?? [];
+
+ $fields['name'] = $pkg['name'] ?? '';
+ // Strip npm scope
+ if (strpos($fields['name'], '/') !== false) {
+ $fields['name'] = explode('/', $fields['name'])[1];
+ }
+
+ $fields['version'] = $pkg['version'] ?? '';
+ $fields['description'] = $pkg['description'] ?? '';
+ $fields['license_spdx'] = $pkg['license'] ?? '';
+
+ // Language detection
+ if (file_exists("{$root}/tsconfig.json")) {
+ $fields['language'] = 'TypeScript';
+ } else {
+ $fields['language'] = 'JavaScript';
+ }
+
+ // Package type
+ $deps = array_merge(
+ array_keys($pkg['dependencies'] ?? []),
+ array_keys($pkg['devDependencies'] ?? [])
+ );
+ $isMcp = false;
+ foreach ($deps as $dep) {
+ if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') {
+ $isMcp = true;
+ break;
+ }
+ }
+ $fields['package_type'] = $isMcp ? 'mcp-server' : 'application';
+
+ // Entry point
+ if (file_exists("{$root}/dist")) {
+ $fields['entry_point'] = 'dist/';
+ } elseif (file_exists("{$root}/src")) {
+ $fields['entry_point'] = 'src/';
+ } else {
+ $fields['entry_point'] = './';
+ }
+ }
+
+ // ── Generic ─────────────────────────────────────────────────────
+
+ private function detectGeneric(string $root, string $repoName, array &$fields): void
+ {
+ $fields['package_type'] = 'generic';
+
+ // Try to detect language from file extensions
+ $fields['language'] = $this->detectLanguageFromFiles($root);
+ $fields['license_spdx'] = $this->detectLicense($root);
+ }
+
+ // =====================================================================
+ // Shared detection helpers
+ // =====================================================================
+
+ private function detectEntryPoint(string $root): string
+ {
+ $abs = SourceResolver::resolveAbsolute($root);
+ if ($abs !== null) {
+ return basename($abs) . '/';
+ }
+ if (is_dir("{$root}/dist")) return 'dist/';
+ if (is_dir("{$root}/src")) return 'src/';
+ return './';
+ }
+
+ private function detectLicense(string $root): string
+ {
+ // Check LICENSE file
+ foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $name) {
+ $file = "{$root}/{$name}";
+ if (!file_exists($file)) continue;
+ $content = file_get_contents($file);
+
+ // SPDX header
+ if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) {
+ return $m[1];
+ }
+
+ // Common license patterns
+ if (strpos($content, 'GNU GENERAL PUBLIC LICENSE') !== false) {
+ if (strpos($content, 'Version 3') !== false) return 'GPL-3.0-or-later';
+ if (strpos($content, 'Version 2') !== false) return 'GPL-2.0-or-later';
+ }
+ if (strpos($content, 'MIT License') !== false) return 'MIT';
+ if (strpos($content, 'Apache License') !== false && strpos($content, 'Version 2.0') !== false) return 'Apache-2.0';
+ }
+
+ return '';
+ }
+
+
+ private function detectLanguageFromFiles(string $root): string
+ {
+ $counts = ['PHP' => 0, 'Go' => 0, 'TypeScript' => 0, 'JavaScript' => 0, 'Python' => 0, 'Shell' => 0];
+
+ $extensions = [
+ 'php' => 'PHP', 'go' => 'Go', 'ts' => 'TypeScript',
+ 'js' => 'JavaScript', 'py' => 'Python', 'sh' => 'Shell',
+ ];
+
+ // Quick scan: only check top two levels
+ foreach (glob("{$root}/*") ?: [] as $item) {
+ $ext = pathinfo($item, PATHINFO_EXTENSION);
+ if (isset($extensions[$ext])) {
+ $counts[$extensions[$ext]]++;
+ }
+ if (is_dir($item) && basename($item)[0] !== '.') {
+ foreach (glob("{$item}/*") ?: [] as $subItem) {
+ $ext = pathinfo($subItem, PATHINFO_EXTENSION);
+ if (isset($extensions[$ext])) {
+ $counts[$extensions[$ext]]++;
+ }
+ }
+ }
+ }
+
+ arsort($counts);
+ $top = key($counts);
+ return $counts[$top] > 0 ? $top : '';
+ }
+
+ private function normalizeLicense(string $license): string
+ {
+ $lower = strtolower($license);
+ $isGpl = strpos($lower, 'gpl') !== false || strpos($lower, 'general public license') !== false;
+ if ($isGpl && strpos($lower, '3') !== false) return 'GPL-3.0-or-later';
+ if ($isGpl && strpos($lower, '2') !== false) return 'GPL-2.0-or-later';
+ if ($lower === 'mit' || strpos($lower, 'mit license') !== false) return 'MIT';
+ if (strpos($lower, 'apache') !== false) return 'Apache-2.0';
+ return $license;
+ }
+
+ private function detectRepoName(string $root): string
+ {
+ $gitConfig = "{$root}/.git/config";
+ if (!file_exists($gitConfig)) {
+ return basename($root);
+ }
+
+ $content = file_get_contents($gitConfig);
+ if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
+ return $m[1];
+ }
+
+ return basename($root);
+ }
+
+ // =====================================================================
+ // API interaction
+ // =====================================================================
+
+ private function fetchManifest(string $apiBase, string $org, string $repo, string $token): ?array
+ {
+ $url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
+ $ctx = stream_context_create([
+ 'http' => [
+ 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
+ 'timeout' => 10,
+ ],
+ ]);
+
+ $body = @file_get_contents($url, false, $ctx);
+ if ($body === false) return null;
+
+ return json_decode($body, true);
+ }
+
+ private function computeDiff(array $current, array $detected): array
+ {
+ // Map detected keys to API keys (underscores match)
+ $changes = [];
+
+ foreach ($detected as $key => $value) {
+ $apiKey = $key;
+ $currentVal = $current[$apiKey] ?? '';
+
+ // Only flag as changed if detected value is non-empty and differs
+ if ($value !== '' && $value !== $currentVal) {
+ // Don't overwrite a non-empty API value with a detected value
+ // unless the API value is actually empty
+ if ($currentVal === '' || $this->shouldOverride($key, $currentVal, $value)) {
+ $changes[$key] = [
+ 'current' => $currentVal,
+ 'detected' => $value,
+ ];
+ }
+ }
+ }
+
+ return $changes;
+ }
+
+ private function shouldOverride(string $field, string $current, string $detected): bool
+ {
+ // Version: detected from source is authoritative
+ if ($field === 'version') return true;
+
+ // These fields: source files are authoritative
+ if (in_array($field, ['element_name', 'package_type', 'language', 'entry_point'], true)) {
+ return true;
+ }
+
+ // For other fields, only fill empty — don't overwrite manual edits
+ return false;
+ }
+
+ private function pushManifest(string $apiBase, string $org, string $repo, string $token, array $current, array $update): bool
+ {
+ $merged = array_merge($current, $update);
+ $url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
+ $payload = json_encode($merged);
+
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'PUT',
+ 'header' => "Authorization: token {$token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
+ 'content' => $payload,
+ 'timeout' => 10,
+ ],
+ ]);
+
+ $body = @file_get_contents($url, false, $ctx);
+ return $body !== false;
+ }
+}
+
+$app = new ManifestDetectCli();
+exit($app->execute());
diff --git a/cli/metadata_element.php b/cli/metadata_element.php
new file mode 100644
index 0000000..3be1ee1
--- /dev/null
+++ b/cli/metadata_element.php
@@ -0,0 +1,191 @@
+#!/usr/bin/env php
+
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * FILE INFORMATION
+ * DEFGROUP: mokoplatform.CLI
+ * INGROUP: mokoplatform
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
+ * PATH: /cli/manifest_element.php
+ * BRIEF: Extract element name, type, type prefix, and ZIP name from manifest
+ */
+
+declare(strict_types=1);
+
+require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
+
+use MokoEnterprise\{CliFramework, SourceResolver};
+
+class ManifestElementCli extends CliFramework
+{
+ protected function configure(): void
+ {
+ $this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
+ $this->addArgument('--path', 'Repository root', '.');
+ $this->addArgument('--version', 'Version string', null);
+ $this->addArgument('--stability', 'Stability level', 'stable');
+ $this->addArgument('--repo', 'Repository name', '');
+ $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
+ }
+
+ protected function run(): int
+ {
+ $path = $this->getArgument('--path');
+ $version = $this->getArgument('--version');
+ $stability = $this->getArgument('--stability');
+ $repoName = $this->getArgument('--repo');
+ $githubOutput = (bool) $this->getArgument('--github-output');
+ $root = realpath($path) ?: $path;
+ $platform = 'generic';
+ $manifestXml = "{$root}/.mokogitea/manifest.xml";
+ if (file_exists($manifestXml)) {
+ $content = file_get_contents($manifestXml);
+ if (preg_match('/([^<]+)<\/platform>/', $content, $pm)) {
+ $platform = trim($pm[1]);
+ }
+ }
+ $extManifest = null;
+ $manifestFiles = array_merge(SourceResolver::globSource($root, 'pkg_*.xml'), SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: []);
+ foreach ($manifestFiles as $file) {
+ $c = file_get_contents($file);
+ if (strpos($c, '([^<]+)<\/element>/', $xml, $em)) {
+ $extElement = $em[1];
+ }
+ if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
+ $extElement = $mm[1];
+ }
+ if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
+ $extElement = $pm2[1];
+ }
+ if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) {
+ $extElement = $pn[1];
+ }
+ if (empty($extElement)) {
+ $extElement = strtolower(basename($extManifest, '.xml'));
+ if (in_array($extElement, ['templatedetails', 'manifest'], true)) {
+ $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
+ }
+ }
+ if (preg_match('/([^<]+)<\/name>/', $xml, $nm)) {
+ $extName = trim($nm[1]);
+ }
+ break;
+ case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null:
+ $extType = 'dolibarr-module';
+ $modBasename = basename($modFile, '.class.php');
+ $extElement = strtolower(preg_replace('/^mod/', '', $modBasename));
+ $modContent = file_get_contents($modFile);
+ if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) {
+ $extName = $nm[1];
+ }
+ break;
+ default:
+ $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root)));
+ $extType = 'generic';
+ break;
+ }
+ $extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement);
+ $typePrefix = '';
+ switch ($extType) {
+ case 'plugin':
+ $typePrefix = "plg_{$extFolder}_";
+ break;
+ case 'module':
+ $typePrefix = 'mod_';
+ break;
+ case 'component':
+ $typePrefix = 'com_';
+ break;
+ case 'template':
+ $typePrefix = 'tpl_';
+ break;
+ case 'library':
+ $typePrefix = 'lib_';
+ break;
+ case 'package':
+ $typePrefix = 'pkg_';
+ break;
+ }
+ $suffixMap = [
+ 'development' => '-dev',
+ 'dev' => '-dev',
+ 'alpha' => '-alpha',
+ 'beta' => '-beta',
+ 'rc' => '-rc',
+ 'release-candidate' => '-rc',
+ 'stable' => '',
+ ];
+ $suffix = $suffixMap[$stability] ?? '';
+ $zipName = '';
+ if ($version !== null) {
+ $zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip";
+ }
+ if (empty($extName)) {
+ $extName = $repoName ?: basename($root);
+ }
+ $outputs = [
+ 'platform' => $platform,
+ 'ext_element' => $extElement,
+ 'ext_type' => $extType,
+ 'ext_folder' => $extFolder,
+ 'ext_name' => $extName,
+ 'type_prefix' => $typePrefix,
+ 'zip_name' => $zipName,
+ ];
+ if ($githubOutput) {
+ $ghOutput = getenv('GITHUB_OUTPUT');
+ $lines = [];
+ foreach ($outputs as $key => $value) {
+ $lines[] = "{$key}={$value}";
+ }
+ if ($ghOutput) {
+ file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND);
+ } else {
+ foreach ($outputs as $key => $value) {
+ echo "::set-output name={$key}::{$value}\n";
+ }
+ }
+ } else {
+ foreach ($outputs as $key => $value) {
+ echo "{$key}={$value}\n";
+ }
+ }
+ return 0;
+ }
+}
+
+$app = new ManifestElementCli();
+exit($app->execute());
diff --git a/cli/metadata_integrity.php b/cli/metadata_integrity.php
new file mode 100644
index 0000000..16fd8e5
--- /dev/null
+++ b/cli/metadata_integrity.php
@@ -0,0 +1,564 @@
+#!/usr/bin/env php
+
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * FILE INFORMATION
+ * DEFGROUP: mokoplatform.CLI
+ * INGROUP: mokoplatform
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
+ * PATH: /cli/manifest_integrity.php
+ * VERSION: 09.26.01
+ * BRIEF: Cross-check manifest API fields against repo contents across the org
+ */
+
+declare(strict_types=1);
+
+require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
+
+use MokoEnterprise\CliFramework;
+
+class ManifestIntegrityCli extends CliFramework
+{
+ protected function configure(): void
+ {
+ $this->setDescription('Cross-check manifest fields against repo contents across the org');
+ $this->addArgument('--path', 'Single repo path (local mode)', '');
+ $this->addArgument('--org', 'Gitea org (bulk mode)', 'MokoConsulting');
+ $this->addArgument('--repo', 'Single repo name (remote mode)', '');
+ $this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', '');
+ $this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1');
+ $this->addArgument('--fix', 'Push fixes for detected drift', false);
+ $this->addArgument('--json', 'Output as JSON', false);
+ $this->addArgument('--quiet', 'Only show repos with issues', false);
+ }
+
+ protected function run(): int
+ {
+ $path = $this->getArgument('--path');
+ $org = $this->getArgument('--org');
+ $repoName = $this->getArgument('--repo');
+ $token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: '';
+ $apiBase = rtrim($this->getArgument('--api-base'), '/');
+ $fixMode = (bool) $this->getArgument('--fix');
+ $jsonMode = (bool) $this->getArgument('--json');
+ $quiet = (bool) $this->getArgument('--quiet');
+
+ if ($token === '') {
+ $this->log('ERROR', 'API token required (use --token or GITEA_TOKEN env)');
+ return 1;
+ }
+
+ // ── Mode selection ──────────────────────────────────────────
+ if ($path !== '') {
+ // Local mode: detect from source + compare to API
+ return $this->checkLocal($path, $org, $repoName, $token, $apiBase, $fixMode, $jsonMode);
+ }
+
+ if ($repoName !== '') {
+ // Single remote repo
+ return $this->checkRemoteRepo($org, $repoName, $token, $apiBase, $fixMode, $jsonMode);
+ }
+
+ // Bulk mode: all repos in org
+ return $this->checkOrg($org, $token, $apiBase, $fixMode, $jsonMode, $quiet);
+ }
+
+ // =====================================================================
+ // Local mode — detect from source, compare to API
+ // =====================================================================
+
+ private function checkLocal(string $path, string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int
+ {
+ $root = realpath($path) ?: $path;
+ if (!is_dir($root)) {
+ $this->log('ERROR', "Path does not exist: {$path}");
+ return 1;
+ }
+
+ if ($repoName === '') {
+ $repoName = $this->detectRepoName($root);
+ }
+
+ // Run manifest_detect logic
+ $detected = $this->runDetect($root, $repoName);
+ $current = $this->fetchManifest($apiBase, $org, $repoName, $token);
+
+ if ($current === null) {
+ $this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}");
+ return 1;
+ }
+
+ $issues = $this->validate($current, $detected, $repoName);
+
+ if ($json) {
+ echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
+ } else {
+ $this->printIssues($repoName, $issues);
+ }
+
+ if ($fix && !empty($issues)) {
+ return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues);
+ }
+
+ return empty($issues) ? 0 : 1;
+ }
+
+ // =====================================================================
+ // Remote single repo mode — fetch source files via API
+ // =====================================================================
+
+ private function checkRemoteRepo(string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int
+ {
+ $current = $this->fetchManifest($apiBase, $org, $repoName, $token);
+ if ($current === null) {
+ $this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}");
+ return 1;
+ }
+
+ $issues = $this->validateManifestOnly($current, $repoName);
+
+ if ($json) {
+ echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
+ } else {
+ $this->printIssues($repoName, $issues);
+ }
+
+ if ($fix && !empty($issues)) {
+ return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues);
+ }
+
+ return empty($issues) ? 0 : 1;
+ }
+
+ // =====================================================================
+ // Bulk org mode — check all repos
+ // =====================================================================
+
+ private function checkOrg(string $org, string $token, string $apiBase, bool $fix, bool $json, bool $quiet): int
+ {
+ $repos = $this->fetchOrgRepos($apiBase, $org, $token);
+ if ($repos === null) {
+ $this->log('ERROR', "Failed to fetch repos for org {$org}");
+ return 1;
+ }
+
+ $this->log('INFO', "Manifest Integrity Check — {$org} (" . count($repos) . " repos)");
+
+ $allResults = [];
+ $totalIssues = 0;
+ $reposWithIssues = 0;
+
+ foreach ($repos as $repo) {
+ $name = $repo['name'];
+ $manifest = $this->fetchManifest($apiBase, $org, $name, $token);
+
+ if ($manifest === null) {
+ if (!$quiet) {
+ $this->log('WARN', "{$name}: no manifest");
+ }
+ continue;
+ }
+
+ $issues = $this->validateManifestOnly($manifest, $name);
+
+ if (!empty($issues)) {
+ $reposWithIssues++;
+ $totalIssues += count($issues);
+
+ if ($json) {
+ $allResults[] = ['repo' => $name, 'issues' => $issues];
+ } else {
+ $this->printIssues($name, $issues);
+ }
+
+ if ($fix) {
+ $this->applyFixes($apiBase, $org, $name, $token, $manifest, $issues);
+ }
+ } elseif (!$quiet && !$json) {
+ $this->log('OK', "{$name}: clean");
+ }
+ }
+
+ if ($json) {
+ echo json_encode($allResults, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
+ } else {
+ echo "\n";
+ $level = $reposWithIssues > 0 ? 'WARN' : 'OK';
+ $this->log($level, sprintf(
+ 'Summary: %d repos checked, %d with issues (%d total issues)',
+ count($repos),
+ $reposWithIssues,
+ $totalIssues
+ ));
+ }
+
+ return $reposWithIssues > 0 ? 1 : 0;
+ }
+
+ // =====================================================================
+ // Validation rules
+ // =====================================================================
+
+ /**
+ * Full validation: compare API manifest against locally-detected fields.
+ */
+ private function validate(array $current, array $detected, string $repoName): array
+ {
+ $issues = [];
+
+ // Required fields that should never be empty
+ $required = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point'];
+ foreach ($required as $field) {
+ if (empty($current[$field])) {
+ $fix = $detected[$field] ?? null;
+ $issues[] = [
+ 'field' => $field,
+ 'severity' => 'error',
+ 'message' => 'Missing required field',
+ 'current' => '',
+ 'fix' => $fix,
+ ];
+ }
+ }
+
+ // Drift detection: detected value differs from API
+ foreach ($detected as $field => $detectedValue) {
+ $currentValue = $current[$field] ?? '';
+ if ($detectedValue !== '' && $currentValue !== '' && $detectedValue !== $currentValue) {
+ // Version drift is expected on dev branches (suffix)
+ if ($field === 'version' && strpos($detectedValue, $currentValue) === 0) {
+ continue; // e.g., detected "02.34.50-dev" vs API "02.34.50"
+ }
+ if ($field === 'version' && strpos($currentValue, $detectedValue) === 0) {
+ continue;
+ }
+
+ $issues[] = [
+ 'field' => $field,
+ 'severity' => 'warn',
+ 'message' => 'Drift: source differs from manifest',
+ 'current' => $currentValue,
+ 'fix' => $detectedValue,
+ ];
+ }
+ }
+
+ // Platform-specific structure validation
+ $platform = $current['platform'] ?? '';
+ $issues = array_merge($issues, $this->validatePlatformStructure($platform, $current, $repoName));
+
+ return $issues;
+ }
+
+ /**
+ * API-only validation: check manifest fields for completeness and consistency
+ * without access to source files.
+ */
+ private function validateManifestOnly(array $manifest, string $repoName): array
+ {
+ $issues = [];
+
+ // Required fields
+ $required = ['platform', 'name', 'version', 'language'];
+ foreach ($required as $field) {
+ if (empty($manifest[$field])) {
+ $issues[] = [
+ 'field' => $field,
+ 'severity' => 'error',
+ 'message' => 'Missing required field',
+ 'current' => '',
+ 'fix' => null,
+ ];
+ }
+ }
+
+ // Recommended fields
+ $recommended = ['package_type', 'entry_point', 'license_spdx', 'description'];
+ foreach ($recommended as $field) {
+ if (empty($manifest[$field])) {
+ $issues[] = [
+ 'field' => $field,
+ 'severity' => 'info',
+ 'message' => 'Recommended field is empty',
+ 'current' => '',
+ 'fix' => null,
+ ];
+ }
+ }
+
+ // Platform-specific checks
+ $platform = $manifest['platform'] ?? '';
+ $issues = array_merge($issues, $this->validatePlatformStructure($platform, $manifest, $repoName));
+
+ return $issues;
+ }
+
+ /**
+ * Platform-specific validation rules.
+ */
+ private function validatePlatformStructure(string $platform, array $manifest, string $repoName): array
+ {
+ $issues = [];
+
+ switch ($platform) {
+ case 'joomla':
+ case 'waas-component':
+ // Joomla repos must have element_name
+ if (empty($manifest['element_name'])) {
+ $issues[] = [
+ 'field' => 'element_name',
+ 'severity' => 'error',
+ 'message' => 'Joomla repos require element_name',
+ 'current' => '',
+ 'fix' => null,
+ ];
+ }
+ // Language should be PHP
+ if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') {
+ $issues[] = [
+ 'field' => 'language',
+ 'severity' => 'warn',
+ 'message' => 'Joomla repos should have language=PHP',
+ 'current' => $manifest['language'],
+ 'fix' => 'PHP',
+ ];
+ }
+ break;
+
+ case 'dolibarr':
+ case 'crm-module':
+ if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') {
+ $issues[] = [
+ 'field' => 'language',
+ 'severity' => 'warn',
+ 'message' => 'Dolibarr repos should have language=PHP',
+ 'current' => $manifest['language'],
+ 'fix' => 'PHP',
+ ];
+ }
+ break;
+
+ case 'go':
+ if (!empty($manifest['language']) && $manifest['language'] !== 'Go') {
+ $issues[] = [
+ 'field' => 'language',
+ 'severity' => 'warn',
+ 'message' => 'Go repos should have language=Go',
+ 'current' => $manifest['language'],
+ 'fix' => 'Go',
+ ];
+ }
+ break;
+
+ case 'mcp':
+ if (!empty($manifest['language']) && !in_array($manifest['language'], ['TypeScript', 'JavaScript'], true)) {
+ $issues[] = [
+ 'field' => 'language',
+ 'severity' => 'warn',
+ 'message' => 'MCP repos should have language=TypeScript or JavaScript',
+ 'current' => $manifest['language'],
+ 'fix' => null,
+ ];
+ }
+ break;
+ }
+
+ // Version format check: should be XX.YY.ZZ
+ $version = $manifest['version'] ?? '';
+ if ($version !== '' && !preg_match('/^\d{2}\.\d{2}\.\d{2}/', $version)) {
+ // Allow semver for node/go repos
+ if (!in_array($platform, ['mcp', 'node', 'go'], true)) {
+ $issues[] = [
+ 'field' => 'version',
+ 'severity' => 'info',
+ 'message' => 'Version does not match XX.YY.ZZ format',
+ 'current' => $version,
+ 'fix' => null,
+ ];
+ }
+ }
+
+ return $issues;
+ }
+
+ // =====================================================================
+ // Output
+ // =====================================================================
+
+ private function printIssues(string $repoName, array $issues): void
+ {
+ if (empty($issues)) {
+ return;
+ }
+
+ $errors = count(array_filter($issues, fn($i) => $i['severity'] === 'error'));
+ $warns = count(array_filter($issues, fn($i) => $i['severity'] === 'warn'));
+ $infos = count($issues) - $errors - $warns;
+
+ echo "\n";
+ $summary = [];
+ if ($errors > 0) $summary[] = "{$errors} error(s)";
+ if ($warns > 0) $summary[] = "{$warns} warning(s)";
+ if ($infos > 0) $summary[] = "{$infos} info";
+ $this->log($errors > 0 ? 'ERROR' : 'WARN', "{$repoName} — " . implode(', ', $summary));
+
+ foreach ($issues as $issue) {
+ $icon = match ($issue['severity']) {
+ 'error' => 'ERROR',
+ 'warn' => 'WARN',
+ default => 'INFO',
+ };
+ $msg = sprintf(' %-18s %s', $issue['field'], $issue['message']);
+ if ($issue['current'] !== '') {
+ $msg .= " (current: {$issue['current']})";
+ }
+ if ($issue['fix'] !== null) {
+ $msg .= " → fix: {$issue['fix']}";
+ }
+ $this->log($icon, $msg);
+ }
+ }
+
+ // =====================================================================
+ // Fix application
+ // =====================================================================
+
+ private function applyFixes(string $apiBase, string $org, string $repo, string $token, array $current, array $issues): int
+ {
+ $fixes = [];
+ foreach ($issues as $issue) {
+ if ($issue['fix'] !== null && $issue['fix'] !== '') {
+ $fixes[$issue['field']] = $issue['fix'];
+ }
+ }
+
+ if (empty($fixes)) {
+ $this->log('INFO', "{$repo}: no auto-fixable issues");
+ return 0;
+ }
+
+ $merged = array_merge($current, $fixes);
+ $url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
+ $payload = json_encode($merged);
+
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'PUT',
+ 'header' => "Authorization: token {$token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n",
+ 'content' => $payload,
+ 'timeout' => 10,
+ ],
+ ]);
+
+ $body = @file_get_contents($url, false, $ctx);
+ if ($body === false) {
+ $this->log('ERROR', "{$repo}: failed to push fixes");
+ return 1;
+ }
+
+ $this->log('OK', "{$repo}: fixed " . implode(', ', array_keys($fixes)));
+ return 0;
+ }
+
+ // =====================================================================
+ // API helpers
+ // =====================================================================
+
+ private function fetchManifest(string $apiBase, string $org, string $repo, string $token): ?array
+ {
+ $url = "{$apiBase}/repos/{$org}/{$repo}/manifest";
+ $ctx = stream_context_create([
+ 'http' => [
+ 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
+ 'timeout' => 10,
+ ],
+ ]);
+
+ $body = @file_get_contents($url, false, $ctx);
+ if ($body === false) return null;
+
+ $data = json_decode($body, true);
+ return is_array($data) ? $data : null;
+ }
+
+ private function fetchOrgRepos(string $apiBase, string $org, string $token): ?array
+ {
+ $allRepos = [];
+ $page = 1;
+ $limit = 50;
+
+ while (true) {
+ $url = "{$apiBase}/orgs/{$org}/repos?page={$page}&limit={$limit}";
+ $ctx = stream_context_create([
+ 'http' => [
+ 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
+ 'timeout' => 15,
+ ],
+ ]);
+
+ $body = @file_get_contents($url, false, $ctx);
+ if ($body === false) return null;
+
+ $repos = json_decode($body, true);
+ if (!is_array($repos) || empty($repos)) break;
+
+ $allRepos = array_merge($allRepos, $repos);
+
+ if (count($repos) < $limit) break;
+ $page++;
+ }
+
+ // Filter out archived and empty repos
+ return array_filter($allRepos, fn($r) => !($r['archived'] ?? false) && !($r['empty'] ?? false));
+ }
+
+ // =====================================================================
+ // Detection (delegates to manifest_detect logic)
+ // =====================================================================
+
+ private function runDetect(string $root, string $repoName): array
+ {
+ $script = __DIR__ . '/manifest_detect.php';
+ $redirect = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
+ $cmd = sprintf(
+ 'php %s --path %s --repo %s --json --quiet %s',
+ escapeshellarg($script),
+ escapeshellarg($root),
+ escapeshellarg($repoName),
+ $redirect
+ );
+
+ $output = shell_exec($cmd) ?? '';
+
+ // Extract JSON object from output (skip banner/log lines)
+ if (preg_match('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $output, $m)) {
+ $data = json_decode($m[0], true);
+ if (is_array($data)) {
+ return $data;
+ }
+ }
+
+ return [];
+ }
+
+ private function detectRepoName(string $root): string
+ {
+ $gitConfig = "{$root}/.git/config";
+ if (!file_exists($gitConfig)) {
+ return basename($root);
+ }
+
+ $content = file_get_contents($gitConfig);
+ if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) {
+ return $m[1];
+ }
+
+ return basename($root);
+ }
+}
+
+$app = new ManifestIntegrityCli();
+exit($app->execute());
diff --git a/cli/metadata_licensing.php b/cli/metadata_licensing.php
new file mode 100644
index 0000000..6f0c704
--- /dev/null
+++ b/cli/metadata_licensing.php
@@ -0,0 +1,280 @@
+#!/usr/bin/env php
+
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * FILE INFORMATION
+ * DEFGROUP: mokoplatform.CLI
+ * INGROUP: mokoplatform
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
+ * PATH: /cli/manifest_licensing.php
+ * VERSION: 09.26.01
+ * BRIEF: Ensure licensing tags (updateservers, dlid) in Joomla extension manifests
+ */
+
+declare(strict_types=1);
+
+require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
+
+use MokoEnterprise\{CliFramework, SourceResolver};
+
+/**
+ * Reads the block from .mokogitea/manifest.xml and ensures that the
+ * Joomla extension manifest contains the correct and tags.
+ *
+ * manifest.xml licensing block example:
+ *
+ *
+ * true
+ * true
+ * https://git.mokoconsulting.tech/{org}/{repo}/updates.xml
+ * MyExtension Updates
+ *
+ *
+ * Supports {org} and {repo} placeholders in update-server URL, resolved from
+ * the manifest's block or git remote.
+ */
+class ManifestLicensingCli extends CliFramework
+{
+ protected function configure(): void
+ {
+ $this->setDescription('Ensure licensing tags (updateservers, dlid) in Joomla extension manifests');
+ $this->addArgument('--path', 'Repository root path', '.');
+ $this->addArgument('--fix', 'Apply fixes (default: dry-run check only)', false);
+ $this->addArgument('--github-output', 'Write results to $GITHUB_OUTPUT', false);
+ }
+
+ protected function run(): int
+ {
+ $root = realpath($this->getArgument('--path')) ?: $this->getArgument('--path');
+ $fix = (bool) $this->getArgument('--fix');
+ $ghOutput = (bool) $this->getArgument('--github-output');
+
+ // ── 1. Read manifest.xml ──────────────────────────────────────────
+ $manifestFile = "{$root}/.mokogitea/manifest.xml";
+
+ if (!file_exists($manifestFile)) {
+ $this->log('WARN', "No manifest.xml found at {$manifestFile}");
+ $this->outputResult($ghOutput, 'skipped', 'No manifest.xml');
+ return 0;
+ }
+
+ $xml = @simplexml_load_file($manifestFile);
+
+ if ($xml === false) {
+ $this->log('ERROR', "Failed to parse {$manifestFile}");
+ return 1;
+ }
+
+ // ── 2. Check if licensing is enabled ──────────────────────────────
+ if (!isset($xml->licensing) || (string) ($xml->licensing->enabled ?? '') !== 'true') {
+ $this->log('INFO', 'Licensing not enabled in manifest.xml — skipping');
+ $this->outputResult($ghOutput, 'skipped', 'Licensing not enabled');
+ return 0;
+ }
+
+ $licensingNode = $xml->licensing;
+ $dlidEnabled = ((string) ($licensingNode->dlid ?? 'true')) === 'true';
+ $updateServerUrl = (string) ($licensingNode->{'update-server'} ?? '');
+ $updateServerName = (string) ($licensingNode->{'update-server-name'} ?? '');
+
+ // ── 3. Resolve placeholders ───────────────────────────────────────
+ $org = (string) ($xml->identity->org ?? '');
+ $repo = (string) ($xml->identity->name ?? '');
+
+ // Fallback to git remote if manifest doesn't have org/name
+ if (empty($org) || empty($repo)) {
+ $remote = trim((string) @shell_exec("cd " . escapeshellarg($root) . " && git remote get-url origin 2>/dev/null"));
+
+ if (preg_match('#[/:]([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
+ if (empty($org)) {
+ $org = $m[1];
+ }
+ if (empty($repo)) {
+ $repo = $m[2];
+ }
+ }
+ }
+
+ // Default update server URL if not specified
+ if (empty($updateServerUrl) && !empty($org) && !empty($repo)) {
+ $updateServerUrl = "https://git.mokoconsulting.tech/{$org}/{$repo}/updates.xml";
+ }
+
+ // Resolve {org} and {repo} placeholders
+ $updateServerUrl = str_replace(['{org}', '{repo}'], [$org, $repo], $updateServerUrl);
+
+ // Default server name from display-name or repo name
+ if (empty($updateServerName)) {
+ $displayName = (string) ($xml->identity->{'display-name'} ?? $repo);
+ $updateServerName = $displayName . ' Updates';
+ }
+
+ if (empty($updateServerUrl)) {
+ $this->log('ERROR', 'Cannot determine update server URL — set in manifest.xml or ensure org/repo are available');
+ return 1;
+ }
+
+ $this->log('INFO', "Licensing enabled — org={$org}, repo={$repo}");
+ $this->log('INFO', "Update server: {$updateServerUrl}");
+ $this->log('INFO', "DLID required: " . ($dlidEnabled ? 'yes' : 'no'));
+
+ // ── 4. Find Joomla extension manifests ────────────────────────────
+ $xmlFiles = array_merge(
+ SourceResolver::globSource($root, '*.xml'),
+ SourceResolver::globSource($root, 'packages/*/*.xml'),
+ glob("{$root}/*.xml") ?: []
+ );
+
+ $packageManifest = null;
+
+ foreach ($xmlFiles as $file) {
+ $content = file_get_contents($file);
+
+ if (!str_contains($content, 'log('WARN', 'No Joomla extension manifest found');
+ $this->outputResult($ghOutput, 'skipped', 'No extension manifest');
+ return 0;
+ }
+
+ $relPath = str_replace($root . '/', '', str_replace('\\', '/', $packageManifest));
+ $this->log('INFO', "Package manifest: {$relPath}");
+
+ // ── 5. Check and fix the manifest ─────────────────────────────────
+ $content = file_get_contents($packageManifest);
+ $original = $content;
+ $changes = [];
+
+ // --- 5a. Ensure block with correct URL ---
+ if (preg_match('#\s*#s', $content)) {
+ // Empty updateservers block — inject the server
+ $replacement = "\n"
+ . " {$updateServerUrl}\n"
+ . " ";
+ $content = preg_replace('#\s*#s', $replacement, $content);
+ $changes[] = 'Added update server URL to empty ';
+ } elseif (!str_contains($content, '')) {
+ // No updateservers at all — add before
+ $serverBlock = "\n \n"
+ . " {$updateServerUrl}\n"
+ . " \n";
+ $content = str_replace('', $serverBlock . '', $content);
+ $changes[] = 'Added block';
+ } else {
+ // updateservers exists — verify URL is correct
+ if (preg_match('#]*>([^<]+)#', $content, $m)) {
+ if ($m[1] !== $updateServerUrl) {
+ $content = preg_replace(
+ '#(]*>)[^<]+()#',
+ "\${1}{$updateServerUrl}\${2}",
+ $content
+ );
+ $changes[] = "Updated server URL: {$m[1]} → {$updateServerUrl}";
+ }
+ }
+ }
+
+ // --- 5b. Ensure tag if required ---
+ if ($dlidEnabled) {
+ if (!str_contains($content, ' if present, otherwise before
+ $dlidTag = ' ' . "\n";
+
+ if (str_contains($content, '')) {
+ $content = str_replace('', $dlidTag . "\n ", $content);
+ } else {
+ $content = str_replace('', $dlidTag . '', $content);
+ }
+
+ $changes[] = 'Added tag';
+ }
+ }
+
+ // --- 5c. Ensure for packages ---
+ if (str_contains($content, 'type="package"') && !str_contains($content, '')) {
+ $blockTag = ' true' . "\n";
+
+ if (str_contains($content, '
+ $content = preg_replace(
+ '#(\s*\n)#',
+ "\${1}{$blockTag}",
+ $content
+ );
+ } elseif (str_contains($content, '')) {
+ $content = str_replace('', $blockTag . "\n ", $content);
+ } else {
+ $content = str_replace('', $blockTag . '', $content);
+ }
+
+ $changes[] = 'Added true';
+ }
+
+ // ── 6. Report and apply ───────────────────────────────────────────
+ if (empty($changes)) {
+ $this->log('INFO', 'All licensing tags are correct — no changes needed');
+ $this->outputResult($ghOutput, 'ok', 'No changes needed');
+ return 0;
+ }
+
+ foreach ($changes as $change) {
+ $this->log($fix ? 'INFO' : 'WARN', ($fix ? 'Fixed: ' : 'Needs fix: ') . $change);
+ }
+
+ if ($fix) {
+ file_put_contents($packageManifest, $content);
+ $this->log('INFO', "Wrote {$relPath} with " . count($changes) . " change(s)");
+ $this->outputResult($ghOutput, 'fixed', implode('; ', $changes));
+ } else {
+ $this->log('WARN', 'Run with --fix to apply changes');
+ $this->outputResult($ghOutput, 'needs-fix', implode('; ', $changes));
+ return 1;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Write result to $GITHUB_OUTPUT if requested.
+ */
+ private function outputResult(bool $ghOutput, string $status, string $detail): void
+ {
+ if (!$ghOutput) {
+ return;
+ }
+
+ $outputFile = getenv('GITHUB_OUTPUT');
+
+ if ($outputFile === false || $outputFile === '') {
+ echo "licensing_status={$status}\n";
+ echo "licensing_detail={$detail}\n";
+ return;
+ }
+
+ $fh = fopen($outputFile, 'a');
+ fwrite($fh, "licensing_status={$status}\n");
+ fwrite($fh, "licensing_detail={$detail}\n");
+ fclose($fh);
+ }
+}
+
+$app = new ManifestLicensingCli();
+exit($app->execute());
diff --git a/cli/metadata_read.php b/cli/metadata_read.php
new file mode 100644
index 0000000..0b5fd77
--- /dev/null
+++ b/cli/metadata_read.php
@@ -0,0 +1,317 @@
+#!/usr/bin/env php
+
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * FILE INFORMATION
+ * DEFGROUP: mokoplatform.CLI
+ * INGROUP: mokoplatform
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
+ * PATH: /cli/metadata_read.php
+ * VERSION: 09.26.01
+ * BRIEF: Read and set metadata fields in .mokogitea/metadata.xml (or manifest.xml)
+ */
+
+declare(strict_types=1);
+
+require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
+
+use MokoEnterprise\CliFramework;
+
+/** Field name → XPath mapping into the metadata XML */
+const FIELD_MAP = [
+ // identity
+ 'name' => 'identity/name',
+ 'display-name' => 'identity/display-name',
+ 'org' => 'identity/org',
+ 'description' => 'identity/description',
+ 'license' => 'identity/license',
+ 'version' => 'identity/version',
+ // governance
+ 'platform' => 'governance/platform',
+ 'standards-version' => 'governance/standards-version',
+ 'standards-source' => 'governance/standards-source',
+ // build
+ 'language' => 'build/language',
+ 'package-type' => 'build/package-type',
+ 'entry-point' => 'build/entry-point',
+ // deploy
+ 'source-dir' => 'deploy/source-dir',
+ 'remote-subdir' => 'deploy/remote-subdir',
+ 'excludes' => 'deploy/excludes',
+ 'dev-host' => 'deploy/dev-host',
+ 'demo-host' => 'deploy/demo-host',
+];
+
+class MetadataReadCli extends CliFramework
+{
+ protected function configure(): void
+ {
+ $this->setDescription('Read or set metadata fields in .mokogitea/metadata.xml');
+ $this->addArgument('--path', 'Repository root path', '.');
+ $this->addArgument('--field', 'Single field name to read', '');
+ $this->addArgument('--set', 'Set field value (field=value), repeatable', '');
+ $this->addArgument('--all', 'Print all fields as KEY=VALUE lines', false);
+ $this->addArgument('--github-output', 'Append all fields to $GITHUB_OUTPUT', false);
+ $this->addArgument('--json', 'Output all fields as JSON', false);
+ }
+
+ protected function run(): int
+ {
+ $path = $this->getArgument('--path');
+ $field = $this->getArgument('--field');
+ $setValue = $this->getArgument('--set');
+ $showAll = $this->getArgument('--all');
+ $ghOutput = $this->getArgument('--github-output');
+ $jsonMode = $this->getArgument('--json');
+
+ $root = realpath($path) ?: $path;
+
+ // -- Locate metadata file --
+ $metadataFile = $this->findMetadataFile($root);
+
+ if ($metadataFile === null) {
+ $this->log('ERROR', "No metadata file found in {$root}");
+ return 1;
+ }
+
+ // -- Auto-migrate manifest.xml → metadata.xml --
+ $metadataFile = $this->migrateIfNeeded($metadataFile, $root);
+
+ // -- Set mode --
+ if ($setValue !== '') {
+ return $this->handleSet($metadataFile, $setValue);
+ }
+
+ // -- Read mode --
+ $xml = @simplexml_load_file($metadataFile);
+
+ if ($xml === false) {
+ // Fallback: legacy YAML format (.mokoplatform)
+ $fields = $this->parseLegacy($metadataFile);
+ } else {
+ $fields = $this->parseXml($xml, $metadataFile);
+ }
+
+ $fields = array_filter($fields, fn($v) => $v !== '');
+
+ return $this->outputFields($fields, $field, $showAll, $ghOutput, $jsonMode);
+ }
+
+ private function findMetadataFile(string $root): ?string
+ {
+ $candidates = [
+ "{$root}/.mokogitea/metadata.xml",
+ "{$root}/.mokogitea/manifest.xml",
+ "{$root}/.mokogitea/.manifest.xml",
+ "{$root}/.mokogitea/.mokoplatform",
+ ];
+
+ foreach ($candidates as $candidate) {
+ if (file_exists($candidate)) {
+ return $candidate;
+ }
+ }
+ return null;
+ }
+
+ private function migrateIfNeeded(string $metadataFile, string $root): string
+ {
+ $newPath = "{$root}/.mokogitea/metadata.xml";
+
+ // Already at the new location
+ if ($metadataFile === $newPath) {
+ return $metadataFile;
+ }
+
+ // Legacy file found — migrate
+ if (str_ends_with($metadataFile, '.mokoplatform')) {
+ // YAML legacy — can't auto-migrate, just warn
+ $this->log('WARN', "Legacy .mokoplatform format detected — migrate to metadata.xml manually");
+ return $metadataFile;
+ }
+
+ // manifest.xml or .manifest.xml → metadata.xml
+ copy($metadataFile, $newPath);
+ unlink($metadataFile);
+ $this->log('INFO', "Migrated " . basename($metadataFile) . " → metadata.xml");
+ return $newPath;
+ }
+
+ private function parseXml(\SimpleXMLElement $xml, string $filePath): array
+ {
+ $fields = [];
+ foreach (FIELD_MAP as $name => $xpath) {
+ $parts = explode('/', $xpath);
+ $node = $xml;
+ foreach ($parts as $part) {
+ $node = $node->{$part} ?? null;
+ if ($node === null) break;
+ }
+ if ($name === 'license' && $node !== null) {
+ // Also extract spdx attribute
+ $fields['license'] = (string)$node;
+ $fields['license-spdx'] = (string)($node['spdx'] ?? '');
+ } else {
+ $fields[$name] = $node !== null ? (string)$node : '';
+ }
+ }
+ $fields['metadata-file'] = $filePath;
+ return $fields;
+ }
+
+ private function parseLegacy(string $filePath): array
+ {
+ $content = file_get_contents($filePath);
+ $fields = [];
+ if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
+ $fields['platform'] = trim($m[1], " \t\n\r\"'");
+ }
+ if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) {
+ $fields['standards-version'] = trim($m[1], " \t\n\r\"'");
+ }
+ if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) {
+ $fields['name'] = trim($m[1], " \t\n\r\"'");
+ }
+ return $fields;
+ }
+
+ private function handleSet(string $metadataFile, string $setValue): int
+ {
+ // Parse field=value pairs (comma-separated or from repeated --set)
+ $pairs = [];
+ foreach (explode(',', $setValue) as $pair) {
+ $pair = trim($pair);
+ if ($pair === '') continue;
+ $eq = strpos($pair, '=');
+ if ($eq === false) {
+ $this->log('ERROR', "Invalid set format: '{$pair}' — expected field=value");
+ return 1;
+ }
+ $key = trim(substr($pair, 0, $eq));
+ $val = trim(substr($pair, $eq + 1));
+ $pairs[$key] = $val;
+ }
+
+ if (empty($pairs)) {
+ $this->log('ERROR', 'No field=value pairs provided');
+ return 1;
+ }
+
+ // Validate all fields exist in FIELD_MAP
+ foreach ($pairs as $key => $val) {
+ if (!isset(FIELD_MAP[$key])) {
+ $this->log('ERROR', "Unknown field: '{$key}'");
+ $this->log('INFO', 'Valid fields: ' . implode(', ', array_keys(FIELD_MAP)));
+ return 1;
+ }
+ }
+
+ // Legacy files are read-only
+ if (str_ends_with($metadataFile, '.mokoplatform')) {
+ $this->log('ERROR', 'Cannot set fields on legacy .mokoplatform format — migrate to metadata.xml first');
+ return 1;
+ }
+
+ // Load XML
+ $xml = @simplexml_load_file($metadataFile);
+ if ($xml === false) {
+ $this->log('ERROR', "Failed to parse XML: {$metadataFile}");
+ return 1;
+ }
+
+ // Set each field
+ foreach ($pairs as $key => $val) {
+ $xpath = FIELD_MAP[$key];
+ $parts = explode('/', $xpath);
+ $section = $parts[0];
+ $element = $parts[1];
+
+ if (!isset($xml->{$section})) {
+ $this->log('ERROR', "Section <{$section}> not found in XML — cannot set '{$key}'");
+ return 1;
+ }
+
+ if (!isset($xml->{$section}->{$element})) {
+ $this->log('ERROR', "Element <{$element}> not found in <{$section}> — cannot set '{$key}'");
+ return 1;
+ }
+
+ $old = (string)$xml->{$section}->{$element};
+ $xml->{$section}->{$element} = $val;
+ $this->log('INFO', "Set {$key}: '{$old}' → '{$val}'");
+ }
+
+ // Write back with preserved formatting
+ $dom = new \DOMDocument('1.0', 'UTF-8');
+ $dom->preserveWhiteSpace = false;
+ $dom->formatOutput = true;
+ $dom->loadXML($xml->asXML());
+ $dom->save($metadataFile);
+
+ $this->log('INFO', "Updated {$metadataFile}");
+ return 0;
+ }
+
+ private function outputFields(array $fields, string $field, $showAll, $ghOutput, $jsonMode): int
+ {
+ if ($ghOutput) {
+ $mode = 'github-output';
+ } elseif ($showAll) {
+ $mode = 'all';
+ } elseif ($jsonMode) {
+ $mode = 'json';
+ } else {
+ $mode = 'field';
+ }
+
+ switch ($mode) {
+ case 'field':
+ if ($field === '') {
+ $this->log('ERROR', "Usage: metadata_read.php --path --field ");
+ $this->log('ERROR', " metadata_read.php --path --all");
+ $this->log('ERROR', " metadata_read.php --path --json");
+ $this->log('ERROR', " metadata_read.php --path --set field=value");
+ return 2;
+ }
+ echo ($fields[$field] ?? '') . "\n";
+ break;
+
+ case 'all':
+ foreach ($fields as $k => $v) {
+ echo "{$k}={$v}\n";
+ }
+ break;
+
+ case 'json':
+ echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
+ break;
+
+ case 'github-output':
+ $outputFile = getenv('GITHUB_OUTPUT');
+ if ($outputFile === false || $outputFile === '') {
+ $this->log('ERROR', 'GITHUB_OUTPUT not set — printing to stdout instead');
+ foreach ($fields as $k => $v) {
+ $envKey = str_replace('-', '_', $k);
+ echo "{$envKey}={$v}\n";
+ }
+ } else {
+ $fh = fopen($outputFile, 'a');
+ foreach ($fields as $k => $v) {
+ $envKey = str_replace('-', '_', $k);
+ fwrite($fh, "{$envKey}={$v}\n");
+ }
+ fclose($fh);
+ $this->log('INFO', "Wrote " . count($fields) . " fields to GITHUB_OUTPUT");
+ }
+ break;
+ }
+
+ return 0;
+ }
+}
+
+$app = new MetadataReadCli();
+exit($app->execute());
diff --git a/cli/platform_detect.php b/cli/platform_detect.php
index b9e89ab..6ed7993 100644
--- a/cli/platform_detect.php
+++ b/cli/platform_detect.php
@@ -10,7 +10,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/platform_detect.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Auto-detect repository platform type and optionally update manifest
*/
diff --git a/cli/release_cascade.php b/cli/release_cascade.php
index f35bf87..a39a1d6 100644
--- a/cli/release_cascade.php
+++ b/cli/release_cascade.php
@@ -10,7 +10,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/release_cascade.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent.
*/
diff --git a/cli/release_publish.php b/cli/release_publish.php
index 3b0ec69..e12b203 100644
--- a/cli/release_publish.php
+++ b/cli/release_publish.php
@@ -10,7 +10,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/release_publish.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Publish a release and create copies for all lesser stability streams.
*/
diff --git a/cli/scaffold_client.php b/cli/scaffold_client.php
index 27af765..e87781b 100644
--- a/cli/scaffold_client.php
+++ b/cli/scaffold_client.php
@@ -12,7 +12,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/scaffold_client.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
*/
diff --git a/cli/updates_xml_sync.php b/cli/updates_xml_sync.php
index 5483355..dd40c10 100644
--- a/cli/updates_xml_sync.php
+++ b/cli/updates_xml_sync.php
@@ -10,7 +10,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/updates_xml_sync.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Sync updates.xml to target branches via Gitea API
* NOTE: Called by pre-release and auto-release workflows after updates.xml
* is modified on the current branch. Pushes the file to other branches
diff --git a/cli/version_auto_bump.php b/cli/version_auto_bump.php
index ef56670..b45f75b 100644
--- a/cli/version_auto_bump.php
+++ b/cli/version_auto_bump.php
@@ -10,7 +10,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/version_auto_bump.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
*/
diff --git a/cli/version_check.php b/cli/version_check.php
index fd1f8a9..8e93e9d 100644
--- a/cli/version_check.php
+++ b/cli/version_check.php
@@ -10,7 +10,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/version_check.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Validate version consistency across README, manifests, and sub-packages
*/
diff --git a/cli/wiki_sync.php b/cli/wiki_sync.php
index 6964772..5b8e3f3 100644
--- a/cli/wiki_sync.php
+++ b/cli/wiki_sync.php
@@ -10,7 +10,7 @@
* INGROUP: mokoplatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /cli/wiki_sync.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Sync select wiki pages from mokoplatform to all template repos
*/
diff --git a/cli/workflow_sync.php b/cli/workflow_sync.php
index e072fe5..db87e20 100644
--- a/cli/workflow_sync.php
+++ b/cli/workflow_sync.php
@@ -10,7 +10,7 @@
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/workflow_sync.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Sync workflows from Generic → platform templates → live repos based on manifest.platform
*/
diff --git a/deploy/backup-before-deploy.php b/deploy/backup-before-deploy.php
index c63358c..a39fa01 100644
--- a/deploy/backup-before-deploy.php
+++ b/deploy/backup-before-deploy.php
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /deploy/backup-before-deploy.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Snapshot Joomla directories before deployment for rollback capability
*/
diff --git a/deploy/deploy-dolibarr.php b/deploy/deploy-dolibarr.php
index 67c566b..dedeb9c 100644
--- a/deploy/deploy-dolibarr.php
+++ b/deploy/deploy-dolibarr.php
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /deploy/deploy-dolibarr.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
*/
diff --git a/deploy/health-check.php b/deploy/health-check.php
index 2da0d95..fb5dc96 100644
--- a/deploy/health-check.php
+++ b/deploy/health-check.php
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /deploy/health-check.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Post-deploy health check — verify a Joomla site is responding correctly
*/
diff --git a/deploy/rollback-joomla.php b/deploy/rollback-joomla.php
index 2e10c3f..2d77aee 100644
--- a/deploy/rollback-joomla.php
+++ b/deploy/rollback-joomla.php
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /deploy/rollback-joomla.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot
*/
diff --git a/deploy/sync-joomla.php b/deploy/sync-joomla.php
index 97f2583..53daaf2 100644
--- a/deploy/sync-joomla.php
+++ b/deploy/sync-joomla.php
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /deploy/sync-joomla.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Sync Joomla site directories between two servers via rsync over SSH
*/
diff --git a/mcp/servers/mokocrm_api/CONTRIBUTING.md b/mcp/servers/mokocrm_api/CONTRIBUTING.md
index 7b44822..4a0acd1 100644
--- a/mcp/servers/mokocrm_api/CONTRIBUTING.md
+++ b/mcp/servers/mokocrm_api/CONTRIBUTING.md
@@ -14,7 +14,7 @@
DEFGROUP: dolibarr-api-mcp.Documentation
INGROUP: dolibarr-api-mcp
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: ./CONTRIBUTING.md
BRIEF: Contribution guidelines for the project
-->
diff --git a/mcp/servers/mokocrm_api/SECURITY.md b/mcp/servers/mokocrm_api/SECURITY.md
index b15d3db..e1b6254 100644
--- a/mcp/servers/mokocrm_api/SECURITY.md
+++ b/mcp/servers/mokocrm_api/SECURITY.md
@@ -10,7 +10,7 @@ DEFGROUP: dolibarr-api-mcp.Documentation
INGROUP: dolibarr-api-mcp
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
PATH: /SECURITY.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: Security vulnerability reporting and handling policy
-->
diff --git a/mcp/servers/mokosuite_api/CONTRIBUTING.md b/mcp/servers/mokosuite_api/CONTRIBUTING.md
index 9785822..16ef2ea 100644
--- a/mcp/servers/mokosuite_api/CONTRIBUTING.md
+++ b/mcp/servers/mokosuite_api/CONTRIBUTING.md
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Generic
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: ./CONTRIBUTING.md
BRIEF: Contribution guidelines for the project
-->
diff --git a/mcp/servers/mokosuite_api/SECURITY.md b/mcp/servers/mokosuite_api/SECURITY.md
index 17f6e88..170bc96 100644
--- a/mcp/servers/mokosuite_api/SECURITY.md
+++ b/mcp/servers/mokosuite_api/SECURITY.md
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: Security vulnerability reporting and handling policy
-->
diff --git a/templates/repos/client-waas/.mokogitea/workflows/issue-branch.yml b/templates/repos/client-waas/.mokogitea/workflows/issue-branch.yml
index 087a3af..382f37c 100644
--- a/templates/repos/client-waas/.mokogitea/workflows/issue-branch.yml
+++ b/templates/repos/client-waas/.mokogitea/workflows/issue-branch.yml
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
-# VERSION: 09.26.00
+# VERSION: 09.26.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
diff --git a/templates/repos/client-waas/CODE_OF_CONDUCT.md b/templates/repos/client-waas/CODE_OF_CONDUCT.md
index ec21081..6e9cd20 100644
--- a/templates/repos/client-waas/CODE_OF_CONDUCT.md
+++ b/templates/repos/client-waas/CODE_OF_CONDUCT.md
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO: mokoconsulting-tech/MokoStandards-Template-Generic
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Contributor Covenant Code of Conduct version 1.3.0
-->
diff --git a/templates/repos/client-waas/CONTRIBUTING.md b/templates/repos/client-waas/CONTRIBUTING.md
index 9785822..16ef2ea 100644
--- a/templates/repos/client-waas/CONTRIBUTING.md
+++ b/templates/repos/client-waas/CONTRIBUTING.md
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Generic
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: ./CONTRIBUTING.md
BRIEF: Contribution guidelines for the project
-->
diff --git a/templates/repos/dolibarr/.mokogitea/workflows/issue-branch.yml b/templates/repos/dolibarr/.mokogitea/workflows/issue-branch.yml
index 087a3af..382f37c 100644
--- a/templates/repos/dolibarr/.mokogitea/workflows/issue-branch.yml
+++ b/templates/repos/dolibarr/.mokogitea/workflows/issue-branch.yml
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
-# VERSION: 09.26.00
+# VERSION: 09.26.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
diff --git a/templates/repos/dolibarr/GOVERNANCE.md b/templates/repos/dolibarr/GOVERNANCE.md
index a5f0ea6..1d004c0 100644
--- a/templates/repos/dolibarr/GOVERNANCE.md
+++ b/templates/repos/dolibarr/GOVERNANCE.md
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.MokoStandards-Template-Dolibarr
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/MokoStandards-Template-Dolibarr
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for MokoStandards-Template-Dolibarr
-->
diff --git a/templates/repos/dolibarr/docs/update-server.md b/templates/repos/dolibarr/docs/update-server.md
index 15e5028..818bb55 100644
--- a/templates/repos/dolibarr/docs/update-server.md
+++ b/templates/repos/dolibarr/docs/update-server.md
@@ -10,7 +10,7 @@ DEFGROUP: MokoStandards-Template-Dolibarr.Documentation
INGROUP: MokoStandards.Templates
REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-Template-Dolibarr
PATH: /docs/update-server.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: How this module's update server file (update.txt) is managed
-->
diff --git a/templates/repos/generic/.mokogitea/workflows/issue-branch.yml b/templates/repos/generic/.mokogitea/workflows/issue-branch.yml
index 087a3af..382f37c 100644
--- a/templates/repos/generic/.mokogitea/workflows/issue-branch.yml
+++ b/templates/repos/generic/.mokogitea/workflows/issue-branch.yml
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
-# VERSION: 09.26.00
+# VERSION: 09.26.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
diff --git a/templates/repos/generic/CODE_OF_CONDUCT.md b/templates/repos/generic/CODE_OF_CONDUCT.md
index ec21081..6e9cd20 100644
--- a/templates/repos/generic/CODE_OF_CONDUCT.md
+++ b/templates/repos/generic/CODE_OF_CONDUCT.md
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO: mokoconsulting-tech/MokoStandards-Template-Generic
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Contributor Covenant Code of Conduct version 1.3.0
-->
diff --git a/templates/repos/generic/SECURITY.md b/templates/repos/generic/SECURITY.md
index 17f6e88..170bc96 100644
--- a/templates/repos/generic/SECURITY.md
+++ b/templates/repos/generic/SECURITY.md
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
INGROUP: [PROJECT_NAME].Documentation
REPO: [REPOSITORY_URL]
PATH: /SECURITY.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: Security vulnerability reporting and handling policy
-->
diff --git a/templates/repos/generic/docs/INSTALLATION.md b/templates/repos/generic/docs/INSTALLATION.md
index dc100dd..dd2a8df 100644
--- a/templates/repos/generic/docs/INSTALLATION.md
+++ b/templates/repos/generic/docs/INSTALLATION.md
@@ -7,7 +7,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
PATH: /docs/INSTALLATION.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: Installation and setup instructions for [PROJECT_NAME]
-->
diff --git a/templates/repos/generic/docs/templates/README-template.md b/templates/repos/generic/docs/templates/README-template.md
index 3466b1d..38cfa64 100644
--- a/templates/repos/generic/docs/templates/README-template.md
+++ b/templates/repos/generic/docs/templates/README-template.md
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO:
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: ./README.md
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
-->
diff --git a/templates/repos/joomla/.mokogitea/workflows/issue-branch.yml b/templates/repos/joomla/.mokogitea/workflows/issue-branch.yml
index 087a3af..382f37c 100644
--- a/templates/repos/joomla/.mokogitea/workflows/issue-branch.yml
+++ b/templates/repos/joomla/.mokogitea/workflows/issue-branch.yml
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
-# VERSION: 09.26.00
+# VERSION: 09.26.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
diff --git a/templates/repos/joomla/CODE_OF_CONDUCT.md b/templates/repos/joomla/CODE_OF_CONDUCT.md
index ff4b6f2..917d462 100644
--- a/templates/repos/joomla/CODE_OF_CONDUCT.md
+++ b/templates/repos/joomla/CODE_OF_CONDUCT.md
@@ -14,7 +14,7 @@
DEFGROUP: MokoStandards-Template-Joomla-Plugin
INGROUP: MokoStandards-Template-Joomla-Plugin.Documentation
REPO: https://github.com/mokoconsulting-tech/MokoStandards-Template-Joomla-Plugin/
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Community expectations and enforcement guidelines
NOTE: Adapted with attribution from the Contributor Covenant v2.1
diff --git a/templates/repos/joomla/GOVERNANCE.md b/templates/repos/joomla/GOVERNANCE.md
index 96d9530..8a16157 100644
--- a/templates/repos/joomla/GOVERNANCE.md
+++ b/templates/repos/joomla/GOVERNANCE.md
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.MokoStandards-Template-Joomla-Plugin
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/MokoStandards-Template-Joomla-Plugin
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for MokoStandards-Template-Joomla-Plugin
-->
diff --git a/templates/repos/joomla/Makefile b/templates/repos/joomla/Makefile
index 15fbfde..c8b025b 100644
--- a/templates/repos/joomla/Makefile
+++ b/templates/repos/joomla/Makefile
@@ -2,14 +2,15 @@
# Copyright (C) 2026 Moko Consulting
# SPDX-License-Identifier: GPL-3.0-or-later
#
-# MokoJoomGallery — Photo gallery management for Joomla
+# MokoSuite — Joomla extension template
+# Replace EXTENSION_NAME with your extension's element name.
# ==============================================================================
# CONFIGURATION - Customize these for your extension
# ==============================================================================
# Extension Configuration
-EXTENSION_NAME := mokojoomgallery
+EXTENSION_NAME := mokosuite
EXTENSION_TYPE := package
# Options: module, plugin, component, package, template
EXTENSION_VERSION := 1.0.0
@@ -132,11 +133,11 @@ build: clean minify ## Build extension package
@# --- Build the outer package ZIP ---
@echo " Assembling pkg_$(EXTENSION_NAME)..."
- @cp $(SRC_DIR)/pkg_mokojoomgallery.xml $(BUILD_DIR)/pkg_mokojoomgallery.xml
+ @cp $(SRC_DIR)/pkg_$(EXTENSION_NAME).xml $(BUILD_DIR)/pkg_$(EXTENSION_NAME).xml
@cp $(SRC_DIR)/script.php $(BUILD_DIR)/script.php
@[ -d "$(SRC_DIR)/language" ] && cp -r $(SRC_DIR)/language $(BUILD_DIR)/language || true
@cd $(BUILD_DIR) && $(ZIP) -r "$(CURDIR)/$(DIST_DIR)/pkg_$(EXTENSION_NAME)-$(EXTENSION_VERSION).zip" \
- pkg_mokojoomgallery.xml script.php language/ packages/
+ pkg_$(EXTENSION_NAME).xml script.php language/ packages/
@echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/pkg_$(EXTENSION_NAME)-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
@echo " Contents:"
diff --git a/templates/repos/joomla/SECURITY.md b/templates/repos/joomla/SECURITY.md
index 3fdebcd..02ccac0 100644
--- a/templates/repos/joomla/SECURITY.md
+++ b/templates/repos/joomla/SECURITY.md
@@ -23,7 +23,7 @@ DEFGROUP: MokoStandards-Template-Joomla-Plugin
INGROUP: MokoStandards-Template-Joomla-Plugin.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin
PATH: /SECURITY.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: Security vulnerability reporting and handling policy
-->
diff --git a/templates/repos/joomla/composer.json b/templates/repos/joomla/composer.json
index 10afe3a..e47af58 100644
--- a/templates/repos/joomla/composer.json
+++ b/templates/repos/joomla/composer.json
@@ -1,6 +1,6 @@
{
- "name": "mokoconsulting/mokojoomgallery",
- "description": "Photo gallery management for Joomla — galleries, images, thumbnails, lightbox, and frontend display",
+ "name": "mokoconsulting/mokosuite",
+ "description": "Joomla extension — replace with your extension description",
"type": "joomla-package",
"version": "01.00.00",
"license": "GPL-3.0-or-later",
diff --git a/templates/repos/mcp/.mokogitea/workflows/issue-branch.yml b/templates/repos/mcp/.mokogitea/workflows/issue-branch.yml
index 087a3af..382f37c 100644
--- a/templates/repos/mcp/.mokogitea/workflows/issue-branch.yml
+++ b/templates/repos/mcp/.mokogitea/workflows/issue-branch.yml
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
-# VERSION: 09.26.00
+# VERSION: 09.26.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
diff --git a/templates/repos/mcp/CODE_OF_CONDUCT.md b/templates/repos/mcp/CODE_OF_CONDUCT.md
index ec21081..6e9cd20 100644
--- a/templates/repos/mcp/CODE_OF_CONDUCT.md
+++ b/templates/repos/mcp/CODE_OF_CONDUCT.md
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO: mokoconsulting-tech/MokoStandards-Template-Generic
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Contributor Covenant Code of Conduct version 1.3.0
-->
diff --git a/templates/repos/mcp/CONTRIBUTING.md b/templates/repos/mcp/CONTRIBUTING.md
index 9785822..16ef2ea 100644
--- a/templates/repos/mcp/CONTRIBUTING.md
+++ b/templates/repos/mcp/CONTRIBUTING.md
@@ -14,7 +14,7 @@
DEFGROUP:
INGROUP: Project.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Generic
- VERSION: 09.26.00
+ VERSION: 09.26.01
PATH: ./CONTRIBUTING.md
BRIEF: Contribution guidelines for the project
-->
diff --git a/templates/repos/mcp/docs/API.md b/templates/repos/mcp/docs/API.md
index a5d9ad9..d21ab64 100644
--- a/templates/repos/mcp/docs/API.md
+++ b/templates/repos/mcp/docs/API.md
@@ -5,7 +5,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
DEFGROUP: {{PROJECT_NAME}}.Documentation
PATH: /docs/API.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: MCP tool reference documentation
-->
diff --git a/templates/repos/mcp/docs/ARCHITECTURE.md b/templates/repos/mcp/docs/ARCHITECTURE.md
index 6abfa6c..74f6d4f 100644
--- a/templates/repos/mcp/docs/ARCHITECTURE.md
+++ b/templates/repos/mcp/docs/ARCHITECTURE.md
@@ -5,7 +5,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
DEFGROUP: {{PROJECT_NAME}}.Documentation
PATH: /docs/ARCHITECTURE.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: Architecture overview and design decisions
-->
diff --git a/templates/repos/mcp/docs/INSTALLATION.md b/templates/repos/mcp/docs/INSTALLATION.md
index 0fb7751..52f8d78 100644
--- a/templates/repos/mcp/docs/INSTALLATION.md
+++ b/templates/repos/mcp/docs/INSTALLATION.md
@@ -5,7 +5,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
DEFGROUP: {{PROJECT_NAME}}.Documentation
PATH: /docs/INSTALLATION.md
-VERSION: 09.26.00
+VERSION: 09.26.01
BRIEF: Installation and setup instructions
-->
diff --git a/tests/Unit/VersionBumpTest.php b/tests/Unit/VersionBumpTest.php
index 4962b7e..0eca056 100644
--- a/tests/Unit/VersionBumpTest.php
+++ b/tests/Unit/VersionBumpTest.php
@@ -63,7 +63,7 @@ class VersionBumpTest extends TestCase
{
file_put_contents(
"{$this->tmpDir}/README.md",
- "\nSome content\n"
+ "\nSome content\n"
);
$this->execute();
diff --git a/tests/Unit/VersionReadTest.php b/tests/Unit/VersionReadTest.php
index 95c15c6..b0a2d9e 100644
--- a/tests/Unit/VersionReadTest.php
+++ b/tests/Unit/VersionReadTest.php
@@ -34,7 +34,7 @@ class VersionReadTest extends TestCase
{
file_put_contents(
"{$this->tmpDir}/README.md",
- "# Test\n\n"
+ "# Test\n\n"
);
$this->assertSame('02.03.04', trim($this->runScript()));
@@ -68,7 +68,7 @@ class VersionReadTest extends TestCase
{
file_put_contents(
"{$this->tmpDir}/README.md",
- "\n"
+ "\n"
);
mkdir("{$this->tmpDir}/src", 0755, true);
file_put_contents(
diff --git a/validate/check_file_integrity.php b/validate/check_file_integrity.php
index 74efd1a..ff62df2 100644
--- a/validate/check_file_integrity.php
+++ b/validate/check_file_integrity.php
@@ -12,7 +12,7 @@
* INGROUP: MokoPlatform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /validate/check_file_integrity.php
- * VERSION: 09.26.00
+ * VERSION: 09.26.01
* BRIEF: Compare deployed files on a remote server against the local repository to detect drift
*/