Public Access
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a194828ee | |||
| a00cbf7d92 | |||
| 14ffe53158 | |||
| e20423f323 | |||
| 5e25c6e77b |
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokoplatform.Automation
|
# INGROUP: mokoplatform.Automation
|
||||||
# VERSION: 09.29.00
|
# VERSION: 09.26.01
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
+27
-3
@@ -12,11 +12,19 @@ BRIEF: Release changelog
|
|||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [09.29.00] --- 2026-06-09
|
### Added
|
||||||
|
- `cli/manifest_integrity.php` — org-wide manifest validation tool (564 lines)
|
||||||
|
- `manifest_detect.php` — detect `display_name`, `target_version`, `php_minimum` fields
|
||||||
|
|
||||||
## [09.28.00] --- 2026-06-07
|
### 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
|
||||||
|
|
||||||
## [09.27.00] --- 2026-06-07
|
### Removed
|
||||||
|
- `mcp/servers/` directory — all MCP server source moved to `A:/MCP/mcp_*/`
|
||||||
|
|
||||||
## [09.26.00] --- 2026-06-07
|
## [09.26.00] --- 2026-06-07
|
||||||
|
|
||||||
@@ -41,3 +49,19 @@ BRIEF: Release changelog
|
|||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- `mcp/servers/mokowaas_api/` — consolidated into mcp-mokowaas-api repo
|
- `mcp/servers/mokowaas_api/` — consolidated into mcp-mokowaas-api repo
|
||||||
|
|
||||||
|
## [09.25.00] --- 2026-06-04
|
||||||
|
|
||||||
|
## [09.23] --- 2026-05-31
|
||||||
|
|
||||||
|
## [09.22] --- 2026-05-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **refactor(cli):** migrate 64 legacy scripts to CliFramework (#235) — all tools in cli/, automation/, maintenance/, deploy/, release/ now extend CliFramework with free --help, --verbose, --quiet, --dry-run, --json, banners, and coloured logging
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- fix: auto-detect org/repo in updates_xml_build from manifest and git remote
|
||||||
|
- fix: restore hyphen in version suffixes
|
||||||
|
- fix: release names use standardized format
|
||||||
|
- fix: remove lesser stream copies, each stream updates independently
|
||||||
|
- fix: sort updates.xml entries dev first, stable last
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ DEFGROUP: MokoPlatform.Root
|
|||||||
INGROUP: MokoPlatform
|
INGROUP: MokoPlatform
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
PATH: /README.md
|
PATH: /README.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: Project overview and documentation
|
BRIEF: Project overview and documentation
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/branch_rename.php
|
* PATH: /cli/branch_rename.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
|
* BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/bulk_workflow_push.php
|
* PATH: /cli/bulk_workflow_push.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
|
* BRIEF: Push a workflow file to all governed repos via the Gitea Contents API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/bulk_workflow_trigger.php
|
* PATH: /cli/bulk_workflow_trigger.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Trigger a workflow across multiple repos at once
|
* BRIEF: Trigger a workflow across multiple repos at once
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/client_dashboard.php
|
* PATH: /cli/client_dashboard.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Generate unified client dashboard HTML
|
* BRIEF: Generate unified client dashboard HTML
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/client_inventory.php
|
* PATH: /cli/client_inventory.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Discover and list all client-waas repos with their server configuration status
|
* BRIEF: Discover and list all client-waas repos with their server configuration status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/client_provision.php
|
* PATH: /cli/client_provision.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Provision a new client environment end-to-end
|
* BRIEF: Provision a new client environment end-to-end
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/grafana_dashboard.php
|
* PATH: /cli/grafana_dashboard.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Manage Grafana dashboards via API
|
* BRIEF: Manage Grafana dashboards via API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/joomla_build.php
|
* PATH: /cli/joomla_build.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
|
* BRIEF: Build a Joomla extension ZIP from manifest — all types supported
|
||||||
* NOTE: Called by pre-release and auto-release workflows.
|
* NOTE: Called by pre-release and auto-release workflows.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+2
-747
@@ -1,749 +1,4 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
// Backward-compatibility wrapper — manifest_* renamed to metadata_*
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
require __DIR__ . '/metadata_detect.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.29.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' => '',
|
|
||||||
'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, '<extension') !== false) {
|
|
||||||
return 'joomla';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dolibarr: mod*.class.php with DolibarrModules
|
|
||||||
$modFiles = array_merge(
|
|
||||||
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
|
|
||||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
|
||||||
);
|
|
||||||
foreach ($modFiles as $file) {
|
|
||||||
if (strpos(file_get_contents($file), 'DolibarrModules') !== false) {
|
|
||||||
return 'dolibarr';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go
|
|
||||||
if (file_exists("{$root}/go.mod")) {
|
|
||||||
return 'go';
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCP: package.json with mcp-related content
|
|
||||||
if (file_exists("{$root}/package.json")) {
|
|
||||||
$pkg = json_decode(file_get_contents("{$root}/package.json"), true) ?? [];
|
|
||||||
$deps = array_merge(
|
|
||||||
array_keys($pkg['dependencies'] ?? []),
|
|
||||||
array_keys($pkg['devDependencies'] ?? [])
|
|
||||||
);
|
|
||||||
foreach ($deps as $dep) {
|
|
||||||
if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') {
|
|
||||||
return 'mcp';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'node';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Python
|
|
||||||
if (file_exists("{$root}/pyproject.toml") || file_exists("{$root}/setup.py")) {
|
|
||||||
return 'python';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'generic';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Joomla ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private function detectJoomla(string $root, string $repoName, array &$fields): void
|
|
||||||
{
|
|
||||||
$fields['language'] = 'PHP';
|
|
||||||
|
|
||||||
// Find the primary extension manifest XML
|
|
||||||
$extManifest = $this->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>([^<]+)<\/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>([^<]+)<\/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>([^<]+)<\/name>/', $xml, $m)) {
|
|
||||||
$fields['name'] = trim($m[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version
|
|
||||||
if (preg_match('/<version>([^<]+)<\/version>/', $xml, $m)) {
|
|
||||||
$fields['version'] = trim($m[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description
|
|
||||||
if (preg_match('/<description>([^<]+)<\/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('/<targetplatform\s[^>]*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>([^<]+)<\/php_minimum>/', $xml, $m)) {
|
|
||||||
$fields['php_minimum'] = trim($m[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// License
|
|
||||||
if (preg_match('/<license>([^<]+)<\/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, '<extension') !== false) {
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Root level
|
|
||||||
foreach (glob("{$root}/*.xml") ?: [] as $file) {
|
|
||||||
$content = file_get_contents($file);
|
|
||||||
if (strpos($content, '<extension') !== false) {
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dolibarr ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private function detectDolibarr(string $root, string $repoName, array &$fields): void
|
|
||||||
{
|
|
||||||
$fields['language'] = 'PHP';
|
|
||||||
$fields['package_type'] = 'dolibarr-module';
|
|
||||||
|
|
||||||
$modFile = $this->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());
|
|
||||||
|
|||||||
+2
-189
@@ -1,191 +1,4 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
// Backward-compatibility wrapper — manifest_* renamed to metadata_*
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
require __DIR__ . '/metadata_element.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>([^<]+)<\/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, '<extension') !== false) {
|
|
||||||
$extManifest = $file;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$modFile = null;
|
|
||||||
$modFiles = array_merge(
|
|
||||||
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
|
|
||||||
glob("{$root}/core/modules/mod*.class.php") ?: []
|
|
||||||
);
|
|
||||||
foreach ($modFiles as $file) {
|
|
||||||
$c = file_get_contents($file);
|
|
||||||
if (strpos($c, 'extends DolibarrModules') !== false) {
|
|
||||||
$modFile = $file;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$extElement = '';
|
|
||||||
$extType = '';
|
|
||||||
$extFolder = '';
|
|
||||||
$extName = '';
|
|
||||||
switch (true) {
|
|
||||||
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
|
||||||
$xml = file_get_contents($extManifest);
|
|
||||||
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
|
||||||
$extType = $tm[1];
|
|
||||||
}
|
|
||||||
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
|
||||||
$extFolder = $gm[1];
|
|
||||||
}
|
|
||||||
if (preg_match('/<element>([^<]+)<\/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>([^<]+)<\/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>([^<]+)<\/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());
|
|
||||||
|
|||||||
+2
-562
@@ -1,564 +1,4 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
// Backward-compatibility wrapper — manifest_* renamed to metadata_*
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
require __DIR__ . '/metadata_integrity.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.29.00
|
|
||||||
* 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());
|
|
||||||
|
|||||||
+2
-278
@@ -1,280 +1,4 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
// Backward-compatibility wrapper — manifest_* renamed to metadata_*
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
require __DIR__ . '/metadata_licensing.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.29.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 <licensing> block from .mokogitea/manifest.xml and ensures that the
|
|
||||||
* Joomla extension manifest contains the correct <updateservers> and <dlid> tags.
|
|
||||||
*
|
|
||||||
* manifest.xml licensing block example:
|
|
||||||
*
|
|
||||||
* <licensing>
|
|
||||||
* <enabled>true</enabled>
|
|
||||||
* <dlid>true</dlid>
|
|
||||||
* <update-server>https://git.mokoconsulting.tech/{org}/{repo}/updates.xml</update-server>
|
|
||||||
* <update-server-name>MyExtension Updates</update-server-name>
|
|
||||||
* </licensing>
|
|
||||||
*
|
|
||||||
* Supports {org} and {repo} placeholders in update-server URL, resolved from
|
|
||||||
* the manifest's <identity> 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 <update-server> 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, '<extension')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the package manifest (type="package") or the main extension manifest
|
|
||||||
if (str_contains($content, 'type="package"')) {
|
|
||||||
$packageManifest = $file;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: first extension manifest found
|
|
||||||
if ($packageManifest === null) {
|
|
||||||
$packageManifest = $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($packageManifest === null) {
|
|
||||||
$this->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 <updateservers> block with correct URL ---
|
|
||||||
if (preg_match('#<updateservers>\s*</updateservers>#s', $content)) {
|
|
||||||
// Empty updateservers block — inject the server
|
|
||||||
$replacement = "<updateservers>\n"
|
|
||||||
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
|
|
||||||
. " </updateservers>";
|
|
||||||
$content = preg_replace('#<updateservers>\s*</updateservers>#s', $replacement, $content);
|
|
||||||
$changes[] = 'Added update server URL to empty <updateservers>';
|
|
||||||
} elseif (!str_contains($content, '<updateservers>')) {
|
|
||||||
// No updateservers at all — add before </extension>
|
|
||||||
$serverBlock = "\n <updateservers>\n"
|
|
||||||
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
|
|
||||||
. " </updateservers>\n";
|
|
||||||
$content = str_replace('</extension>', $serverBlock . '</extension>', $content);
|
|
||||||
$changes[] = 'Added <updateservers> block';
|
|
||||||
} else {
|
|
||||||
// updateservers exists — verify URL is correct
|
|
||||||
if (preg_match('#<server[^>]*>([^<]+)</server>#', $content, $m)) {
|
|
||||||
if ($m[1] !== $updateServerUrl) {
|
|
||||||
$content = preg_replace(
|
|
||||||
'#(<server[^>]*>)[^<]+(</server>)#',
|
|
||||||
"\${1}{$updateServerUrl}\${2}",
|
|
||||||
$content
|
|
||||||
);
|
|
||||||
$changes[] = "Updated server URL: {$m[1]} → {$updateServerUrl}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 5b. Ensure <dlid> tag if required ---
|
|
||||||
if ($dlidEnabled) {
|
|
||||||
if (!str_contains($content, '<dlid')) {
|
|
||||||
// Add before <updateservers> if present, otherwise before </extension>
|
|
||||||
$dlidTag = ' <dlid prefix="dlid=" suffix=""/>' . "\n";
|
|
||||||
|
|
||||||
if (str_contains($content, '<updateservers>')) {
|
|
||||||
$content = str_replace('<updateservers>', $dlidTag . "\n <updateservers>", $content);
|
|
||||||
} else {
|
|
||||||
$content = str_replace('</extension>', $dlidTag . '</extension>', $content);
|
|
||||||
}
|
|
||||||
|
|
||||||
$changes[] = 'Added <dlid> tag';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 5c. Ensure <blockChildUninstall> for packages ---
|
|
||||||
if (str_contains($content, 'type="package"') && !str_contains($content, '<blockChildUninstall>')) {
|
|
||||||
$blockTag = ' <blockChildUninstall>true</blockChildUninstall>' . "\n";
|
|
||||||
|
|
||||||
if (str_contains($content, '<dlid')) {
|
|
||||||
// Add after <dlid>
|
|
||||||
$content = preg_replace(
|
|
||||||
'#(<dlid[^/]*/>\s*\n)#',
|
|
||||||
"\${1}{$blockTag}",
|
|
||||||
$content
|
|
||||||
);
|
|
||||||
} elseif (str_contains($content, '<updateservers>')) {
|
|
||||||
$content = str_replace('<updateservers>', $blockTag . "\n <updateservers>", $content);
|
|
||||||
} else {
|
|
||||||
$content = str_replace('</extension>', $blockTag . '</extension>', $content);
|
|
||||||
}
|
|
||||||
|
|
||||||
$changes[] = 'Added <blockChildUninstall>true</blockChildUninstall>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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());
|
|
||||||
|
|||||||
+2
-168
@@ -1,170 +1,4 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
// Backward-compatibility wrapper — manifest_* renamed to metadata_*
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
require __DIR__ . '/metadata_read.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.29.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 <dir> --field <name>");
|
|
||||||
$this->log('ERROR', " manifest_read.php --path <dir> --all");
|
|
||||||
$this->log('ERROR', " manifest_read.php --path <dir> --json");
|
|
||||||
$this->log('ERROR', " manifest_read.php --path <dir> --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());
|
|
||||||
|
|||||||
@@ -0,0 +1,749 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* 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, '<extension') !== false) {
|
||||||
|
return 'joomla';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dolibarr: mod*.class.php with DolibarrModules
|
||||||
|
$modFiles = array_merge(
|
||||||
|
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
|
||||||
|
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||||
|
);
|
||||||
|
foreach ($modFiles as $file) {
|
||||||
|
if (strpos(file_get_contents($file), 'DolibarrModules') !== false) {
|
||||||
|
return 'dolibarr';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go
|
||||||
|
if (file_exists("{$root}/go.mod")) {
|
||||||
|
return 'go';
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP: package.json with mcp-related content
|
||||||
|
if (file_exists("{$root}/package.json")) {
|
||||||
|
$pkg = json_decode(file_get_contents("{$root}/package.json"), true) ?? [];
|
||||||
|
$deps = array_merge(
|
||||||
|
array_keys($pkg['dependencies'] ?? []),
|
||||||
|
array_keys($pkg['devDependencies'] ?? [])
|
||||||
|
);
|
||||||
|
foreach ($deps as $dep) {
|
||||||
|
if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') {
|
||||||
|
return 'mcp';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'node';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python
|
||||||
|
if (file_exists("{$root}/pyproject.toml") || file_exists("{$root}/setup.py")) {
|
||||||
|
return 'python';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'generic';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Joomla ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function detectJoomla(string $root, string $repoName, array &$fields): void
|
||||||
|
{
|
||||||
|
$fields['language'] = 'PHP';
|
||||||
|
|
||||||
|
// Find the primary extension manifest XML
|
||||||
|
$extManifest = $this->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>([^<]+)<\/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>([^<]+)<\/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>([^<]+)<\/name>/', $xml, $m)) {
|
||||||
|
$fields['name'] = trim($m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version
|
||||||
|
if (preg_match('/<version>([^<]+)<\/version>/', $xml, $m)) {
|
||||||
|
$fields['version'] = trim($m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if (preg_match('/<description>([^<]+)<\/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('/<targetplatform\s[^>]*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>([^<]+)<\/php_minimum>/', $xml, $m)) {
|
||||||
|
$fields['php_minimum'] = trim($m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// License
|
||||||
|
if (preg_match('/<license>([^<]+)<\/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, '<extension') !== false) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root level
|
||||||
|
foreach (glob("{$root}/*.xml") ?: [] as $file) {
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
if (strpos($content, '<extension') !== false) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dolibarr ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function detectDolibarr(string $root, string $repoName, array &$fields): void
|
||||||
|
{
|
||||||
|
$fields['language'] = 'PHP';
|
||||||
|
$fields['package_type'] = 'dolibarr-module';
|
||||||
|
|
||||||
|
$modFile = $this->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());
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* 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>([^<]+)<\/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, '<extension') !== false) {
|
||||||
|
$extManifest = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$modFile = null;
|
||||||
|
$modFiles = array_merge(
|
||||||
|
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
|
||||||
|
glob("{$root}/core/modules/mod*.class.php") ?: []
|
||||||
|
);
|
||||||
|
foreach ($modFiles as $file) {
|
||||||
|
$c = file_get_contents($file);
|
||||||
|
if (strpos($c, 'extends DolibarrModules') !== false) {
|
||||||
|
$modFile = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$extElement = '';
|
||||||
|
$extType = '';
|
||||||
|
$extFolder = '';
|
||||||
|
$extName = '';
|
||||||
|
switch (true) {
|
||||||
|
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
|
||||||
|
$xml = file_get_contents($extManifest);
|
||||||
|
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
|
||||||
|
$extType = $tm[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
|
||||||
|
$extFolder = $gm[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/<element>([^<]+)<\/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>([^<]+)<\/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>([^<]+)<\/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());
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* 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());
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* 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 <licensing> block from .mokogitea/manifest.xml and ensures that the
|
||||||
|
* Joomla extension manifest contains the correct <updateservers> and <dlid> tags.
|
||||||
|
*
|
||||||
|
* manifest.xml licensing block example:
|
||||||
|
*
|
||||||
|
* <licensing>
|
||||||
|
* <enabled>true</enabled>
|
||||||
|
* <dlid>true</dlid>
|
||||||
|
* <update-server>https://git.mokoconsulting.tech/{org}/{repo}/updates.xml</update-server>
|
||||||
|
* <update-server-name>MyExtension Updates</update-server-name>
|
||||||
|
* </licensing>
|
||||||
|
*
|
||||||
|
* Supports {org} and {repo} placeholders in update-server URL, resolved from
|
||||||
|
* the manifest's <identity> 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 <update-server> 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, '<extension')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the package manifest (type="package") or the main extension manifest
|
||||||
|
if (str_contains($content, 'type="package"')) {
|
||||||
|
$packageManifest = $file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: first extension manifest found
|
||||||
|
if ($packageManifest === null) {
|
||||||
|
$packageManifest = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($packageManifest === null) {
|
||||||
|
$this->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 <updateservers> block with correct URL ---
|
||||||
|
if (preg_match('#<updateservers>\s*</updateservers>#s', $content)) {
|
||||||
|
// Empty updateservers block — inject the server
|
||||||
|
$replacement = "<updateservers>\n"
|
||||||
|
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
|
||||||
|
. " </updateservers>";
|
||||||
|
$content = preg_replace('#<updateservers>\s*</updateservers>#s', $replacement, $content);
|
||||||
|
$changes[] = 'Added update server URL to empty <updateservers>';
|
||||||
|
} elseif (!str_contains($content, '<updateservers>')) {
|
||||||
|
// No updateservers at all — add before </extension>
|
||||||
|
$serverBlock = "\n <updateservers>\n"
|
||||||
|
. " <server type=\"extension\" name=\"{$updateServerName}\">{$updateServerUrl}</server>\n"
|
||||||
|
. " </updateservers>\n";
|
||||||
|
$content = str_replace('</extension>', $serverBlock . '</extension>', $content);
|
||||||
|
$changes[] = 'Added <updateservers> block';
|
||||||
|
} else {
|
||||||
|
// updateservers exists — verify URL is correct
|
||||||
|
if (preg_match('#<server[^>]*>([^<]+)</server>#', $content, $m)) {
|
||||||
|
if ($m[1] !== $updateServerUrl) {
|
||||||
|
$content = preg_replace(
|
||||||
|
'#(<server[^>]*>)[^<]+(</server>)#',
|
||||||
|
"\${1}{$updateServerUrl}\${2}",
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
$changes[] = "Updated server URL: {$m[1]} → {$updateServerUrl}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5b. Ensure <dlid> tag if required ---
|
||||||
|
if ($dlidEnabled) {
|
||||||
|
if (!str_contains($content, '<dlid')) {
|
||||||
|
// Add before <updateservers> if present, otherwise before </extension>
|
||||||
|
$dlidTag = ' <dlid prefix="dlid=" suffix=""/>' . "\n";
|
||||||
|
|
||||||
|
if (str_contains($content, '<updateservers>')) {
|
||||||
|
$content = str_replace('<updateservers>', $dlidTag . "\n <updateservers>", $content);
|
||||||
|
} else {
|
||||||
|
$content = str_replace('</extension>', $dlidTag . '</extension>', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[] = 'Added <dlid> tag';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5c. Ensure <blockChildUninstall> for packages ---
|
||||||
|
if (str_contains($content, 'type="package"') && !str_contains($content, '<blockChildUninstall>')) {
|
||||||
|
$blockTag = ' <blockChildUninstall>true</blockChildUninstall>' . "\n";
|
||||||
|
|
||||||
|
if (str_contains($content, '<dlid')) {
|
||||||
|
// Add after <dlid>
|
||||||
|
$content = preg_replace(
|
||||||
|
'#(<dlid[^/]*/>\s*\n)#',
|
||||||
|
"\${1}{$blockTag}",
|
||||||
|
$content
|
||||||
|
);
|
||||||
|
} elseif (str_contains($content, '<updateservers>')) {
|
||||||
|
$content = str_replace('<updateservers>', $blockTag . "\n <updateservers>", $content);
|
||||||
|
} else {
|
||||||
|
$content = str_replace('</extension>', $blockTag . '</extension>', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[] = 'Added <blockChildUninstall>true</blockChildUninstall>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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());
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
*
|
||||||
|
* 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 <dir> --field <name>");
|
||||||
|
$this->log('ERROR', " metadata_read.php --path <dir> --all");
|
||||||
|
$this->log('ERROR', " metadata_read.php --path <dir> --json");
|
||||||
|
$this->log('ERROR', " metadata_read.php --path <dir> --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());
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/platform_detect.php
|
* PATH: /cli/platform_detect.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Auto-detect repository platform type and optionally update manifest
|
* BRIEF: Auto-detect repository platform type and optionally update manifest
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/release_cascade.php
|
* PATH: /cli/release_cascade.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent.
|
* BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/release_publish.php
|
* PATH: /cli/release_publish.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Publish a release and create copies for all lesser stability streams.
|
* BRIEF: Publish a release and create copies for all lesser stability streams.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/scaffold_client.php
|
* PATH: /cli/scaffold_client.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
|
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/updates_xml_sync.php
|
* PATH: /cli/updates_xml_sync.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Sync updates.xml to target branches via Gitea API
|
* BRIEF: Sync updates.xml to target branches via Gitea API
|
||||||
* NOTE: Called by pre-release and auto-release workflows after updates.xml
|
* NOTE: Called by pre-release and auto-release workflows after updates.xml
|
||||||
* is modified on the current branch. Pushes the file to other branches
|
* is modified on the current branch. Pushes the file to other branches
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/version_auto_bump.php
|
* PATH: /cli/version_auto_bump.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
|
* BRIEF: Auto patch-bump, set stability suffix, and commit — single CLI replacing inline workflow bash
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/version_check.php
|
* PATH: /cli/version_check.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
* BRIEF: Validate version consistency across README, manifests, and sub-packages
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: mokoplatform
|
* INGROUP: mokoplatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /cli/wiki_sync.php
|
* PATH: /cli/wiki_sync.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Sync select wiki pages from mokoplatform to all template repos
|
* BRIEF: Sync select wiki pages from mokoplatform to all template repos
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* INGROUP: moko-platform
|
* INGROUP: moko-platform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
* PATH: /cli/workflow_sync.php
|
* PATH: /cli/workflow_sync.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Sync workflows from Generic → platform templates → live repos based on manifest.platform
|
* BRIEF: Sync workflows from Generic → platform templates → live repos based on manifest.platform
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: MokoPlatform
|
* INGROUP: MokoPlatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /deploy/backup-before-deploy.php
|
* PATH: /deploy/backup-before-deploy.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Snapshot Joomla directories before deployment for rollback capability
|
* BRIEF: Snapshot Joomla directories before deployment for rollback capability
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: MokoPlatform
|
* INGROUP: MokoPlatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /deploy/deploy-dolibarr.php
|
* PATH: /deploy/deploy-dolibarr.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
|
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: MokoPlatform
|
* INGROUP: MokoPlatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /deploy/health-check.php
|
* PATH: /deploy/health-check.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Post-deploy health check — verify a Joomla site is responding correctly
|
* BRIEF: Post-deploy health check — verify a Joomla site is responding correctly
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: MokoPlatform
|
* INGROUP: MokoPlatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /deploy/rollback-joomla.php
|
* PATH: /deploy/rollback-joomla.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot
|
* BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: MokoPlatform
|
* INGROUP: MokoPlatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /deploy/sync-joomla.php
|
* PATH: /deploy/sync-joomla.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Sync Joomla site directories between two servers via rsync over SSH
|
* BRIEF: Sync Joomla site directories between two servers via rsync over SSH
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ src/
|
|||||||
├── config.ts # Loads ~/.mcp_mokobackup.json, resolves targets
|
├── config.ts # Loads ~/.mcp_mokobackup.json, resolves targets
|
||||||
├── client.ts # Backup execution logic
|
├── client.ts # Backup execution logic
|
||||||
├── akeeba.ts # Akeeba Backup API integration (Joomla sites)
|
├── akeeba.ts # Akeeba Backup API integration (Joomla sites)
|
||||||
├── mokobackup.ts # MokoSuite Backup REST API integration
|
├── mokobackup.ts # MokoJoomBackup REST API integration
|
||||||
└── types.ts # BackupConfig, BackupTarget types
|
└── types.ts # BackupConfig, BackupTarget types
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { BackupTarget, BackupResult, AkeebaBackupRecord } from './types.js'
|
|||||||
const TIMEOUT_MS = 300_000; // 5 min for backup operations
|
const TIMEOUT_MS = 300_000; // 5 min for backup operations
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MokoSuite Backup client using Joomla Web Services API
|
* MokoJoomBackup client using Joomla Web Services API
|
||||||
* Endpoint: /api/index.php/v1/mokobackup/*
|
* Endpoint: /api/index.php/v1/mokobackup/*
|
||||||
* Auth: Bearer token (Joomla API token)
|
* Auth: Bearer token (Joomla API token)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP: dolibarr-api-mcp.Documentation
|
DEFGROUP: dolibarr-api-mcp.Documentation
|
||||||
INGROUP: dolibarr-api-mcp
|
INGROUP: dolibarr-api-mcp
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: ./CONTRIBUTING.md
|
PATH: ./CONTRIBUTING.md
|
||||||
BRIEF: Contribution guidelines for the project
|
BRIEF: Contribution guidelines for the project
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ DEFGROUP: dolibarr-api-mcp.Documentation
|
|||||||
INGROUP: dolibarr-api-mcp
|
INGROUP: dolibarr-api-mcp
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/dolibarr-api-mcp
|
||||||
PATH: /SECURITY.md
|
PATH: /SECURITY.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: Security vulnerability reporting and handling policy
|
BRIEF: Security vulnerability reporting and handling policy
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP:
|
DEFGROUP:
|
||||||
INGROUP: Project.Documentation
|
INGROUP: Project.Documentation
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Generic
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Generic
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: ./CONTRIBUTING.md
|
PATH: ./CONTRIBUTING.md
|
||||||
BRIEF: Contribution guidelines for the project
|
BRIEF: Contribution guidelines for the project
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
|||||||
INGROUP: [PROJECT_NAME].Documentation
|
INGROUP: [PROJECT_NAME].Documentation
|
||||||
REPO: [REPOSITORY_URL]
|
REPO: [REPOSITORY_URL]
|
||||||
PATH: /SECURITY.md
|
PATH: /SECURITY.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: Security vulnerability reporting and handling policy
|
BRIEF: Security vulnerability reporting and handling policy
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokoplatform.Automation
|
# INGROUP: mokoplatform.Automation
|
||||||
# VERSION: 09.29.00
|
# VERSION: 09.26.01
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP:
|
DEFGROUP:
|
||||||
INGROUP: Project.Documentation
|
INGROUP: Project.Documentation
|
||||||
REPO: mokoconsulting-tech/MokoStandards-Template-Generic
|
REPO: mokoconsulting-tech/MokoStandards-Template-Generic
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: ./CODE_OF_CONDUCT.md
|
PATH: ./CODE_OF_CONDUCT.md
|
||||||
BRIEF: Contributor Covenant Code of Conduct version 1.3.0
|
BRIEF: Contributor Covenant Code of Conduct version 1.3.0
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP:
|
DEFGROUP:
|
||||||
INGROUP: Project.Documentation
|
INGROUP: Project.Documentation
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Generic
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Generic
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: ./CONTRIBUTING.md
|
PATH: ./CONTRIBUTING.md
|
||||||
BRIEF: Contribution guidelines for the project
|
BRIEF: Contribution guidelines for the project
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokoplatform.Automation
|
# INGROUP: mokoplatform.Automation
|
||||||
# VERSION: 09.29.00
|
# VERSION: 09.26.01
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
DEFGROUP: mokoconsulting-tech.MokoStandards-Template-Dolibarr
|
DEFGROUP: mokoconsulting-tech.MokoStandards-Template-Dolibarr
|
||||||
INGROUP: MokoStandards.Governance
|
INGROUP: MokoStandards.Governance
|
||||||
REPO: https://github.com/mokoconsulting-tech/MokoStandards-Template-Dolibarr
|
REPO: https://github.com/mokoconsulting-tech/MokoStandards-Template-Dolibarr
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: /GOVERNANCE.md
|
PATH: /GOVERNANCE.md
|
||||||
BRIEF: Project governance rules, roles, and decision process for MokoStandards-Template-Dolibarr
|
BRIEF: Project governance rules, roles, and decision process for MokoStandards-Template-Dolibarr
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ DEFGROUP: MokoStandards-Template-Dolibarr.Documentation
|
|||||||
INGROUP: MokoStandards.Templates
|
INGROUP: MokoStandards.Templates
|
||||||
REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-Template-Dolibarr
|
REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-Template-Dolibarr
|
||||||
PATH: /docs/update-server.md
|
PATH: /docs/update-server.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: How this module's update server file (update.txt) is managed
|
BRIEF: How this module's update server file (update.txt) is managed
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokoplatform.Automation
|
# INGROUP: mokoplatform.Automation
|
||||||
# VERSION: 09.29.00
|
# VERSION: 09.26.01
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP:
|
DEFGROUP:
|
||||||
INGROUP: Project.Documentation
|
INGROUP: Project.Documentation
|
||||||
REPO: mokoconsulting-tech/MokoStandards-Template-Generic
|
REPO: mokoconsulting-tech/MokoStandards-Template-Generic
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: ./CODE_OF_CONDUCT.md
|
PATH: ./CODE_OF_CONDUCT.md
|
||||||
BRIEF: Contributor Covenant Code of Conduct version 1.3.0
|
BRIEF: Contributor Covenant Code of Conduct version 1.3.0
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ DEFGROUP: [PROJECT_NAME]
|
|||||||
INGROUP: [PROJECT_NAME].Documentation
|
INGROUP: [PROJECT_NAME].Documentation
|
||||||
REPO: [REPOSITORY_URL]
|
REPO: [REPOSITORY_URL]
|
||||||
PATH: /SECURITY.md
|
PATH: /SECURITY.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: Security vulnerability reporting and handling policy
|
BRIEF: Security vulnerability reporting and handling policy
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
|
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
PATH: /docs/INSTALLATION.md
|
PATH: /docs/INSTALLATION.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: Installation and setup instructions for [PROJECT_NAME]
|
BRIEF: Installation and setup instructions for [PROJECT_NAME]
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP:
|
DEFGROUP:
|
||||||
INGROUP: Project.Documentation
|
INGROUP: Project.Documentation
|
||||||
REPO:
|
REPO:
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: ./README.md
|
PATH: ./README.md
|
||||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokoplatform.Automation
|
# INGROUP: mokoplatform.Automation
|
||||||
# VERSION: 09.29.00
|
# VERSION: 09.26.01
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP: MokoStandards-Template-Joomla-Plugin
|
DEFGROUP: MokoStandards-Template-Joomla-Plugin
|
||||||
INGROUP: MokoStandards-Template-Joomla-Plugin.Documentation
|
INGROUP: MokoStandards-Template-Joomla-Plugin.Documentation
|
||||||
REPO: https://github.com/mokoconsulting-tech/MokoStandards-Template-Joomla-Plugin/
|
REPO: https://github.com/mokoconsulting-tech/MokoStandards-Template-Joomla-Plugin/
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: ./CODE_OF_CONDUCT.md
|
PATH: ./CODE_OF_CONDUCT.md
|
||||||
BRIEF: Community expectations and enforcement guidelines
|
BRIEF: Community expectations and enforcement guidelines
|
||||||
NOTE: Adapted with attribution from the Contributor Covenant v2.1
|
NOTE: Adapted with attribution from the Contributor Covenant v2.1
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
DEFGROUP: mokoconsulting-tech.MokoStandards-Template-Joomla-Plugin
|
DEFGROUP: mokoconsulting-tech.MokoStandards-Template-Joomla-Plugin
|
||||||
INGROUP: MokoStandards.Governance
|
INGROUP: MokoStandards.Governance
|
||||||
REPO: https://github.com/mokoconsulting-tech/MokoStandards-Template-Joomla-Plugin
|
REPO: https://github.com/mokoconsulting-tech/MokoStandards-Template-Joomla-Plugin
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: /GOVERNANCE.md
|
PATH: /GOVERNANCE.md
|
||||||
BRIEF: Project governance rules, roles, and decision process for MokoStandards-Template-Joomla-Plugin
|
BRIEF: Project governance rules, roles, and decision process for MokoStandards-Template-Joomla-Plugin
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ DEFGROUP: MokoStandards-Template-Joomla-Plugin
|
|||||||
INGROUP: MokoStandards-Template-Joomla-Plugin.Documentation
|
INGROUP: MokoStandards-Template-Joomla-Plugin.Documentation
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Joomla-Plugin
|
||||||
PATH: /SECURITY.md
|
PATH: /SECURITY.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: Security vulnerability reporting and handling policy
|
BRIEF: Security vulnerability reporting and handling policy
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokoplatform.Automation
|
# INGROUP: mokoplatform.Automation
|
||||||
# VERSION: 09.29.00
|
# VERSION: 09.26.01
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP:
|
DEFGROUP:
|
||||||
INGROUP: Project.Documentation
|
INGROUP: Project.Documentation
|
||||||
REPO: mokoconsulting-tech/MokoStandards-Template-Generic
|
REPO: mokoconsulting-tech/MokoStandards-Template-Generic
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: ./CODE_OF_CONDUCT.md
|
PATH: ./CODE_OF_CONDUCT.md
|
||||||
BRIEF: Contributor Covenant Code of Conduct version 1.3.0
|
BRIEF: Contributor Covenant Code of Conduct version 1.3.0
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
DEFGROUP:
|
DEFGROUP:
|
||||||
INGROUP: Project.Documentation
|
INGROUP: Project.Documentation
|
||||||
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Generic
|
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-Generic
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
PATH: ./CONTRIBUTING.md
|
PATH: ./CONTRIBUTING.md
|
||||||
BRIEF: Contribution guidelines for the project
|
BRIEF: Contribution guidelines for the project
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
DEFGROUP: {{PROJECT_NAME}}.Documentation
|
DEFGROUP: {{PROJECT_NAME}}.Documentation
|
||||||
PATH: /docs/API.md
|
PATH: /docs/API.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: MCP tool reference documentation
|
BRIEF: MCP tool reference documentation
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
DEFGROUP: {{PROJECT_NAME}}.Documentation
|
DEFGROUP: {{PROJECT_NAME}}.Documentation
|
||||||
PATH: /docs/ARCHITECTURE.md
|
PATH: /docs/ARCHITECTURE.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: Architecture overview and design decisions
|
BRIEF: Architecture overview and design decisions
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
DEFGROUP: {{PROJECT_NAME}}.Documentation
|
DEFGROUP: {{PROJECT_NAME}}.Documentation
|
||||||
PATH: /docs/INSTALLATION.md
|
PATH: /docs/INSTALLATION.md
|
||||||
VERSION: 09.29.00
|
VERSION: 09.26.01
|
||||||
BRIEF: Installation and setup instructions
|
BRIEF: Installation and setup instructions
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class VersionBumpTest extends TestCase
|
|||||||
{
|
{
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
"{$this->tmpDir}/README.md",
|
"{$this->tmpDir}/README.md",
|
||||||
"<!-- VERSION: 09.29.00 -->\nSome content\n"
|
"<!-- VERSION: 09.26.01 -->\nSome content\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->execute();
|
$this->execute();
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class VersionReadTest extends TestCase
|
|||||||
{
|
{
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
"{$this->tmpDir}/README.md",
|
"{$this->tmpDir}/README.md",
|
||||||
"# Test\n<!-- VERSION: 09.29.00 -->\n"
|
"# Test\n<!-- VERSION: 09.26.01 -->\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame('02.03.04', trim($this->runScript()));
|
$this->assertSame('02.03.04', trim($this->runScript()));
|
||||||
@@ -68,7 +68,7 @@ class VersionReadTest extends TestCase
|
|||||||
{
|
{
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
"{$this->tmpDir}/README.md",
|
"{$this->tmpDir}/README.md",
|
||||||
"<!-- VERSION: 09.29.00 -->\n"
|
"<!-- VERSION: 09.26.01 -->\n"
|
||||||
);
|
);
|
||||||
mkdir("{$this->tmpDir}/src", 0755, true);
|
mkdir("{$this->tmpDir}/src", 0755, true);
|
||||||
file_put_contents(
|
file_put_contents(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* INGROUP: MokoPlatform
|
* INGROUP: MokoPlatform
|
||||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
|
||||||
* PATH: /validate/check_file_integrity.php
|
* PATH: /validate/check_file_integrity.php
|
||||||
* VERSION: 09.29.00
|
* VERSION: 09.26.01
|
||||||
* BRIEF: Compare deployed files on a remote server against the local repository to detect drift
|
* BRIEF: Compare deployed files on a remote server against the local repository to detect drift
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user