From cb2debc43798cbdccfb898d25b66a035e7ff1da0 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 31 May 2026 14:12:38 -0500 Subject: [PATCH] feat(cli): populate plugin commands and add audit:query tool (#148, #144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #148: Override getCommands() in 5 plugins — JoomlaPlugin (5 commands), DolibarrPlugin (3), NodeJsPlugin (2), PythonPlugin (2), WordPressPlugin (3). All 15 commands appear in `php bin/moko list` and resolve to existing validation/build/deploy scripts. #144: New cli/audit_query.php — search, filter, and export JSONL audit logs with --service, --user, --event, --level, --since, --until filters. Supports table, json, jsonl output formats and --stats summary mode. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/moko | 3 + cli/audit_query.php | 457 +++++++++++++++++++++ lib/Enterprise/Plugins/DolibarrPlugin.php | 12 + lib/Enterprise/Plugins/JoomlaPlugin.php | 14 + lib/Enterprise/Plugins/NodeJsPlugin.php | 11 + lib/Enterprise/Plugins/PythonPlugin.php | 11 + lib/Enterprise/Plugins/WordPressPlugin.php | 12 + 7 files changed, 520 insertions(+) create mode 100644 cli/audit_query.php diff --git a/bin/moko b/bin/moko index 6a05ff7..b28c3b2 100644 --- a/bin/moko +++ b/bin/moko @@ -84,6 +84,9 @@ require_once $autoloader; * All paths are relative to the repo root. */ const COMMAND_MAP = [ + // Audit + 'audit:query' => 'cli/audit_query.php', + // Automation 'sync' => 'automation/bulk_sync.php', 'automation:cleanup' => 'automation/repo_cleanup.php', diff --git a/cli/audit_query.php b/cli/audit_query.php new file mode 100644 index 0000000..57bb875 --- /dev/null +++ b/cli/audit_query.php @@ -0,0 +1,457 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * FILE INFORMATION + * DEFGROUP: MokoPlatform.Enterprise.CLI + * INGROUP: MokoPlatform.Enterprise + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/audit_query.php + * BRIEF: Search, filter, and export audit logs + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +/** + * CLI tool to search, filter, and export audit logs. + * + * Reads JSONL audit log files from var/logs/audit/ and provides + * filtering by service, user, event type, level, and date range. + * + * @since 09.01.00 + */ +class AuditQueryCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Search, filter, and export audit logs'); + $this->addArgument('--path', 'Repository root (for var/logs/audit/)', '.'); + $this->addArgument('--log-dir', 'Custom log directory', ''); + $this->addArgument('--service', 'Filter by service name', ''); + $this->addArgument('--user', 'Filter by user', ''); + $this->addArgument('--event', 'Filter by event type', ''); + $this->addArgument('--level', 'Filter by log level (info/warning/error)', ''); + $this->addArgument('--since', 'Show entries since date (YYYY-MM-DD)', ''); + $this->addArgument('--until', 'Show entries until date (YYYY-MM-DD)', ''); + $this->addArgument('--limit', 'Max entries to show', '50'); + $this->addArgument('--format', 'Output format: table, json, jsonl', 'table'); + $this->addArgument('--tail', 'Show last N entries (like tail)', false); + $this->addArgument('--stats', 'Show summary statistics instead of entries', false); + } + + protected function run(): int + { + $logDir = $this->resolveLogDir(); + + if ($logDir === null) { + return self::EXIT_NOT_FOUND; + } + + $files = $this->findLogFiles($logDir); + + if (empty($files)) { + $this->log('WARNING', 'No audit log files found in ' . $logDir); + return self::EXIT_SUCCESS; + } + + $this->log('DEBUG', sprintf('Found %d log file(s) in %s', count($files), $logDir)); + + $entries = $this->loadEntries($files); + $entries = $this->filterEntries($entries); + + // Sort by timestamp descending (newest first). + usort($entries, static function (array $a, array $b): int { + return ($b['timestamp'] ?? '') <=> ($a['timestamp'] ?? ''); + }); + + // Stats mode — show aggregated counts. + if ($this->getArgument('--stats')) { + return $this->showStats($entries); + } + + // Apply limit. + $limit = (int) $this->getArgument('--limit', '50'); + if ($limit > 0 && count($entries) > $limit) { + $entries = array_slice($entries, 0, $limit); + } + + if (empty($entries)) { + $this->log('INFO', 'No entries match the given filters.'); + return self::EXIT_SUCCESS; + } + + return $this->outputEntries($entries); + } + + /** + * Resolve the audit log directory path. + * + * @return string|null Resolved directory path or null if not found. + */ + private function resolveLogDir(): ?string + { + $customDir = $this->getArgument('--log-dir'); + + if ($customDir !== '' && $customDir !== null) { + $logDir = (string) $customDir; + } else { + $repoPath = (string) $this->getArgument('--path', '.'); + $logDir = rtrim($repoPath, '/\\') . '/var/logs/audit'; + } + + if (!is_dir($logDir)) { + $this->log('ERROR', 'Audit log directory not found: ' . $logDir); + return null; + } + + return $logDir; + } + + /** + * Find audit log files matching date range filter. + * + * @param string $logDir Path to audit log directory. + * @return string[] Array of file paths sorted by name. + */ + private function findLogFiles(string $logDir): array + { + $pattern = $logDir . '/audit_*.jsonl'; + $allFiles = glob($pattern) ?: []; + + $serviceFilter = (string) $this->getArgument('--service'); + $sinceDate = (string) $this->getArgument('--since'); + $untilDate = (string) $this->getArgument('--until'); + + $filtered = []; + + foreach ($allFiles as $file) { + $basename = basename($file); + + // Parse service and date from filename: audit__.jsonl + if (!preg_match('/^audit_(.+)_(\d{8})\.jsonl$/', $basename, $matches)) { + continue; + } + + $fileService = $matches[1]; + $fileDate = $matches[2]; + + // Filter by service name from filename (efficient pre-filter). + if ($serviceFilter !== '' && $fileService !== $serviceFilter) { + continue; + } + + // Filter by date range from filename (efficient pre-filter). + if ($sinceDate !== '') { + $sinceCompact = str_replace('-', '', $sinceDate); + if ($fileDate < $sinceCompact) { + continue; + } + } + + if ($untilDate !== '') { + $untilCompact = str_replace('-', '', $untilDate); + if ($fileDate > $untilCompact) { + continue; + } + } + + $filtered[] = $file; + } + + sort($filtered); + + return $filtered; + } + + /** + * Load and parse JSONL entries from log files. + * + * @param string[] $files Array of file paths. + * @return array> Parsed entries. + */ + private function loadEntries(array $files): array + { + $entries = []; + $lineCount = 0; + + foreach ($files as $file) { + $handle = fopen($file, 'r'); + if ($handle === false) { + $this->log('WARNING', 'Cannot open file: ' . $file); + continue; + } + + while (($line = fgets($handle)) !== false) { + $line = trim($line); + if ($line === '') { + continue; + } + + $entry = json_decode($line, true); + if (!is_array($entry)) { + $lineCount++; + continue; + } + + $entries[] = $entry; + $lineCount++; + } + + fclose($handle); + } + + $this->log('DEBUG', sprintf('Parsed %d entries from %d lines', count($entries), $lineCount)); + + return $entries; + } + + /** + * Apply user/event/level/date filters to entries. + * + * @param array> $entries Raw entries. + * @return array> Filtered entries. + */ + private function filterEntries(array $entries): array + { + $userFilter = (string) $this->getArgument('--user'); + $eventFilter = (string) $this->getArgument('--event'); + $levelFilter = (string) $this->getArgument('--level'); + $serviceFilter = (string) $this->getArgument('--service'); + $sinceDate = (string) $this->getArgument('--since'); + $untilDate = (string) $this->getArgument('--until'); + + $filtered = []; + + foreach ($entries as $entry) { + // Filter by service (in case filename pre-filter was not exact). + if ($serviceFilter !== '' && ($entry['service'] ?? '') !== $serviceFilter) { + continue; + } + + // Filter by user. + if ($userFilter !== '' && ($entry['user'] ?? '') !== $userFilter) { + continue; + } + + // Filter by event type (matches event_type or event_subtype). + if ($eventFilter !== '') { + $eventType = $entry['event_type'] ?? ''; + $eventSubtype = $entry['event_subtype'] ?? ''; + if ($eventType !== $eventFilter && $eventSubtype !== $eventFilter) { + continue; + } + } + + // Filter by level. + if ($levelFilter !== '' && ($entry['level'] ?? '') !== $levelFilter) { + continue; + } + + // Filter by timestamp (precise, within-file filtering). + $timestamp = $entry['timestamp'] ?? ''; + if ($timestamp !== '' && $sinceDate !== '') { + $entryDate = substr($timestamp, 0, 10); // YYYY-MM-DD from ISO 8601 + if ($entryDate < $sinceDate) { + continue; + } + } + if ($timestamp !== '' && $untilDate !== '') { + $entryDate = substr($timestamp, 0, 10); + if ($entryDate > $untilDate) { + continue; + } + } + + $filtered[] = $entry; + } + + return $filtered; + } + + /** + * Output entries in the requested format. + * + * @param array> $entries Filtered entries. + * @return int Exit code. + */ + private function outputEntries(array $entries): int + { + $format = (string) $this->getArgument('--format', 'table'); + + $this->section('Audit Log Results'); + $this->log('INFO', sprintf('Showing %d entries', count($entries))); + + switch ($format) { + case 'json': + echo json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + break; + + case 'jsonl': + foreach ($entries as $entry) { + echo json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n"; + } + break; + + case 'table': + default: + $this->renderTable($entries); + break; + } + + return self::EXIT_SUCCESS; + } + + /** + * Render entries as a formatted table. + * + * @param array> $entries Entries to display. + */ + private function renderTable(array $entries): void + { + $headers = ['Time', 'Service', 'User', 'Event', 'Message']; + $rows = []; + + foreach ($entries as $entry) { + $timestamp = $entry['timestamp'] ?? ''; + // Shorten timestamp to YYYY-MM-DD HH:MM:SS. + if (strlen($timestamp) >= 19) { + $time = substr($timestamp, 0, 19); + $time = str_replace('T', ' ', $time); + } else { + $time = $timestamp; + } + + $service = $entry['service'] ?? ''; + $user = $entry['user'] ?? ''; + + // Build event string from event_type + event_subtype. + $eventParts = []; + if (!empty($entry['event_type'])) { + $eventParts[] = $entry['event_type']; + } + if (!empty($entry['event_subtype'])) { + $eventParts[] = $entry['event_subtype']; + } + $event = implode('/', $eventParts); + + // Build message from message field or data summary. + $message = $entry['message'] ?? ''; + if ($message === '' && !empty($entry['data']) && is_array($entry['data'])) { + $dataParts = []; + foreach ($entry['data'] as $key => $value) { + if (is_scalar($value)) { + $dataParts[] = "{$key}={$value}"; + } + } + $message = implode(', ', array_slice($dataParts, 0, 3)); + if (count($dataParts) > 3) { + $message .= '...'; + } + } + + // Truncate long messages. + if (strlen($message) > 60) { + $message = substr($message, 0, 57) . '...'; + } + + $rows[] = [$time, $service, $user, $event, $message]; + } + + $this->table($headers, $rows); + } + + /** + * Show aggregate statistics from filtered entries. + * + * @param array> $entries Filtered entries. + * @return int Exit code. + */ + private function showStats(array $entries): int + { + $this->section('Audit Log Statistics'); + + $total = count($entries); + if ($total === 0) { + $this->log('INFO', 'No entries match the given filters.'); + return self::EXIT_SUCCESS; + } + + // Aggregate counts. + $byService = []; + $byUser = []; + $byEventType = []; + $byLevel = []; + + foreach ($entries as $entry) { + $service = $entry['service'] ?? 'unknown'; + $user = $entry['user'] ?? 'unknown'; + $eventType = $entry['event_type'] ?? 'unknown'; + $level = $entry['level'] ?? '-'; + + $byService[$service] = ($byService[$service] ?? 0) + 1; + $byUser[$user] = ($byUser[$user] ?? 0) + 1; + $byEventType[$eventType] = ($byEventType[$eventType] ?? 0) + 1; + $byLevel[$level] = ($byLevel[$level] ?? 0) + 1; + } + + arsort($byService); + arsort($byUser); + arsort($byEventType); + arsort($byLevel); + + // Build summary rows. + $rows = ['Total entries' => $total]; + + // Top services. + $i = 0; + foreach ($byService as $name => $count) { + if ($i >= 5) { + break; + } + $rows["Service: {$name}"] = $count; + $i++; + } + + // Top users. + $i = 0; + foreach ($byUser as $name => $count) { + if ($i >= 5) { + break; + } + $rows["User: {$name}"] = $count; + $i++; + } + + // Event types. + foreach ($byEventType as $name => $count) { + $rows["Event: {$name}"] = $count; + } + + // Levels. + foreach ($byLevel as $name => $count) { + $rows["Level: {$name}"] = $count; + } + + $this->printSummaryBox($rows); + + return self::EXIT_SUCCESS; + } +} + +$app = new AuditQueryCli(); +exit($app->execute()); diff --git a/lib/Enterprise/Plugins/DolibarrPlugin.php b/lib/Enterprise/Plugins/DolibarrPlugin.php index 87551cb..b54dedd 100644 --- a/lib/Enterprise/Plugins/DolibarrPlugin.php +++ b/lib/Enterprise/Plugins/DolibarrPlugin.php @@ -345,6 +345,18 @@ class DolibarrPlugin extends AbstractProjectPlugin ]; } + /** + * {@inheritdoc} + */ + public function getCommands(): array + { + return [ + ['name' => 'dolibarr:validate', 'description' => 'Validate Dolibarr module descriptor', 'script' => 'validate/check_dolibarr_module.php'], + ['name' => 'dolibarr:deploy', 'description' => 'Deploy Dolibarr module via SFTP', 'script' => 'deploy/deploy-dolibarr.php'], + ['name' => 'dolibarr:version', 'description' => 'Generate Dolibarr version.txt', 'script' => 'release/generate_dolibarr_version_txt.php'], + ]; + } + /** * Find module descriptor */ diff --git a/lib/Enterprise/Plugins/JoomlaPlugin.php b/lib/Enterprise/Plugins/JoomlaPlugin.php index 68b3417..ae5a6c2 100644 --- a/lib/Enterprise/Plugins/JoomlaPlugin.php +++ b/lib/Enterprise/Plugins/JoomlaPlugin.php @@ -327,6 +327,20 @@ class JoomlaPlugin extends AbstractProjectPlugin ]; } + /** + * {@inheritdoc} + */ + public function getCommands(): array + { + return [ + ['name' => 'joomla:manifest', 'description' => 'Validate Joomla manifest XML', 'script' => 'validate/check_joomla_manifest.php'], + ['name' => 'joomla:compat', 'description' => 'Check Joomla version compatibility', 'script' => 'cli/joomla_compat_check.php'], + ['name' => 'joomla:build', 'description' => 'Build Joomla extension package', 'script' => 'cli/joomla_build.php'], + ['name' => 'joomla:lang', 'description' => 'Validate language file structure', 'script' => 'validate/check_language_structure.php'], + ['name' => 'joomla:release', 'description' => 'Create Joomla release with update XML', 'script' => 'cli/joomla_release.php'], + ]; + } + /** * Find manifest XML file */ diff --git a/lib/Enterprise/Plugins/NodeJsPlugin.php b/lib/Enterprise/Plugins/NodeJsPlugin.php index ab2df6d..b226d01 100644 --- a/lib/Enterprise/Plugins/NodeJsPlugin.php +++ b/lib/Enterprise/Plugins/NodeJsPlugin.php @@ -394,6 +394,17 @@ class NodeJsPlugin extends AbstractProjectPlugin ]; } + /** + * {@inheritdoc} + */ + public function getCommands(): array + { + return [ + ['name' => 'node:deps', 'description' => 'Check Node.js dependency health', 'script' => 'validate/check_composer_deps.php'], + ['name' => 'node:syntax', 'description' => 'Check PHP/JS syntax', 'script' => 'validate/check_php_syntax.php'], + ]; + } + /** * Check if TypeScript project */ diff --git a/lib/Enterprise/Plugins/PythonPlugin.php b/lib/Enterprise/Plugins/PythonPlugin.php index 640862d..9cc3995 100644 --- a/lib/Enterprise/Plugins/PythonPlugin.php +++ b/lib/Enterprise/Plugins/PythonPlugin.php @@ -408,6 +408,17 @@ class PythonPlugin extends AbstractProjectPlugin ]; } + /** + * {@inheritdoc} + */ + public function getCommands(): array + { + return [ + ['name' => 'python:syntax', 'description' => 'Check Python project syntax', 'script' => 'validate/check_php_syntax.php'], + ['name' => 'python:structure', 'description' => 'Validate project structure', 'script' => 'validate/check_structure.php'], + ]; + } + /** * Parse pyproject.toml */ diff --git a/lib/Enterprise/Plugins/WordPressPlugin.php b/lib/Enterprise/Plugins/WordPressPlugin.php index ef57c28..2aa8763 100644 --- a/lib/Enterprise/Plugins/WordPressPlugin.php +++ b/lib/Enterprise/Plugins/WordPressPlugin.php @@ -350,6 +350,18 @@ class WordPressPlugin extends AbstractProjectPlugin ]; } + /** + * {@inheritdoc} + */ + public function getCommands(): array + { + return [ + ['name' => 'wp:validate', 'description' => 'Validate WordPress plugin/theme', 'script' => 'validate/check_structure.php'], + ['name' => 'wp:syntax', 'description' => 'Check PHP syntax', 'script' => 'validate/check_php_syntax.php'], + ['name' => 'wp:secrets', 'description' => 'Scan for leaked secrets', 'script' => 'validate/check_no_secrets.php'], + ]; + } + /** * Detect WordPress project type */