Merge pull request 'feat/bunch-of-changes' (#29) from feat/bunch-of-changes into main
All checks were successful
Renovate / renovate (push) Successful in 1m22s
All checks were successful
Renovate / renovate (push) Successful in 1m22s
Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
101
bin/console
101
bin/console
@@ -3,21 +3,12 @@
|
||||
|
||||
/**
|
||||
* Console Entry Point
|
||||
*
|
||||
* Bootstraps the application container and registers console commands
|
||||
* from core and modules using lazy loading via Symfony Console.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use KTXC\Application;
|
||||
use KTXC\Kernel;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Command\LazyCommand;
|
||||
use KTXC\Server;
|
||||
|
||||
// Check dependencies
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
|
||||
exit(1);
|
||||
@@ -26,92 +17,12 @@ if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
try {
|
||||
// Bootstrap the application
|
||||
$projectRoot = dirname(__DIR__);
|
||||
$app = new Application($projectRoot);
|
||||
|
||||
// Boot kernel to initialize container and modules
|
||||
$app->kernel()->boot();
|
||||
|
||||
// Get the container
|
||||
$container = $app->container();
|
||||
|
||||
// Create Symfony Console Application
|
||||
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
|
||||
|
||||
// Collect all command classes
|
||||
$commandClasses = [];
|
||||
|
||||
// Collect commands from modules
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $container->get(ModuleManager::class);
|
||||
|
||||
foreach ($moduleManager->list() as $module) {
|
||||
$moduleInstance = $module->instance();
|
||||
|
||||
// Skip if module instance is not available
|
||||
if ($moduleInstance === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if module implements console command provider
|
||||
if ($moduleInstance instanceof ModuleConsoleInterface) {
|
||||
try {
|
||||
$commands = $moduleInstance->registerCI();
|
||||
|
||||
foreach ($commands as $commandClass) {
|
||||
if (!class_exists($commandClass)) {
|
||||
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
|
||||
continue;
|
||||
}
|
||||
$commandClasses[] = $commandClass;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register commands using lazy loading
|
||||
foreach ($commandClasses as $commandClass) {
|
||||
try {
|
||||
// Use reflection to read #[AsCommand] attribute without instantiation
|
||||
$reflection = new \ReflectionClass($commandClass);
|
||||
$attributes = $reflection->getAttributes(\Symfony\Component\Console\Attribute\AsCommand::class);
|
||||
|
||||
if (empty($attributes)) {
|
||||
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get attribute instance
|
||||
/** @var \Symfony\Component\Console\Attribute\AsCommand $commandAttr */
|
||||
$commandAttr = $attributes[0]->newInstance();
|
||||
|
||||
// Create lazy command wrapper that defers instantiation
|
||||
$lazyCommand = new LazyCommand(
|
||||
$commandAttr->name,
|
||||
[],
|
||||
$commandAttr->description ?? '',
|
||||
$commandAttr->hidden ?? false,
|
||||
fn() => $container->get($commandClass) // Only instantiate when executed
|
||||
);
|
||||
|
||||
$console->add($lazyCommand);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Run the console application
|
||||
$exitCode = $console->run();
|
||||
exit($exitCode);
|
||||
|
||||
$server = new Server(dirname(__DIR__));
|
||||
exit($server->runConsole());
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Fatal error: " . $e->getMessage() . "\n");
|
||||
if (isset($app) && $app->debug()) {
|
||||
fwrite(STDERR, $e->getTraceAsString() . "\n");
|
||||
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
|
||||
if (isset($server) && $server->debug()) {
|
||||
fwrite(STDERR, $e->getTraceAsString()."\n");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Application class - entry point for the framework
|
||||
* Handles configuration loading and kernel lifecycle
|
||||
*/
|
||||
class Application
|
||||
{
|
||||
private static $composerLoader = null;
|
||||
|
||||
private Kernel $kernel;
|
||||
private array $config;
|
||||
private string $rootDir;
|
||||
|
||||
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
|
||||
{
|
||||
$this->rootDir = $this->resolveProjectRoot($rootDir);
|
||||
|
||||
// Load configuration
|
||||
$this->config = $this->loadConfig();
|
||||
|
||||
// Determine environment and debug mode
|
||||
$environment = $environment ?? $this->config['environment'] ?? 'prod';
|
||||
$debug = $debug ?? $this->config['debug'] ?? false;
|
||||
|
||||
// Create kernel with configuration
|
||||
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the application - handle incoming request and send response
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
try {
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $this->handle($request);
|
||||
$response->send();
|
||||
$this->terminate();
|
||||
} catch (\Throwable $e) {
|
||||
// Last resort error handling for kernel initialization failures
|
||||
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
$content = $this->kernel->debug()
|
||||
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
|
||||
: 'An error occurred. Please try again later.';
|
||||
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
]);
|
||||
$response->send();
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return $this->kernel->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the application - process deferred events
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
$this->kernel->processEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the kernel instance
|
||||
*/
|
||||
public function kernel(): Kernel
|
||||
{
|
||||
return $this->kernel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container instance
|
||||
*/
|
||||
public function container(): ContainerInterface
|
||||
{
|
||||
return $this->kernel->container();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application root directory
|
||||
*/
|
||||
public function rootDir(): string
|
||||
{
|
||||
return $this->rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modules directory
|
||||
*/
|
||||
public function moduleDir(): string
|
||||
{
|
||||
return $this->rootDir . '/modules';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
*/
|
||||
public function config(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
// Support dot notation: 'database.uri'
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->config;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !array_key_exists($k, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment
|
||||
*/
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->kernel->environment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
*/
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->kernel->debug();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from config directory
|
||||
*/
|
||||
protected function loadConfig(): array
|
||||
{
|
||||
$configFile = $this->rootDir . '/config/system.php';
|
||||
|
||||
if (!file_exists($configFile)) {
|
||||
error_log('Configuration file not found: ' . $configFile);
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
|
||||
if (!is_array($config)) {
|
||||
throw new \RuntimeException('Configuration file must return an array');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project root directory.
|
||||
*
|
||||
* Some entrypoints may pass the public/ directory or another subdirectory.
|
||||
* We walk up the directory tree until we find composer.json.
|
||||
*/
|
||||
private function resolveProjectRoot(string $startDir): string
|
||||
{
|
||||
$dir = rtrim($startDir, '/');
|
||||
if ($dir === '') {
|
||||
return $startDir;
|
||||
}
|
||||
|
||||
// If startDir is a file path, use its directory.
|
||||
if (is_file($dir)) {
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
|
||||
$current = $dir;
|
||||
while (true) {
|
||||
if (is_file($current . '/composer.json')) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$parent = dirname($current);
|
||||
if ($parent === $current) {
|
||||
// Reached filesystem root
|
||||
return $dir;
|
||||
}
|
||||
$current = $parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Composer ClassLoader instance
|
||||
*/
|
||||
public static function setComposerLoader($loader): void
|
||||
{
|
||||
self::$composerLoader = $loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Composer ClassLoader instance
|
||||
*/
|
||||
public static function getComposerLoader()
|
||||
{
|
||||
return self::$composerLoader;
|
||||
}
|
||||
}
|
||||
76
core/lib/Http/Response/StreamedNdJsonResponse.php
Normal file
76
core/lib/Http/Response/StreamedNdJsonResponse.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace KTXC\Http\Response;
|
||||
|
||||
/**
|
||||
* StreamedNdJsonResponse streams an HTTP response as Newline Delimited JSON (NDJSON).
|
||||
*
|
||||
* Each item yielded by the provided iterable is serialized as a single JSON value
|
||||
* followed by a newline character (\n). The response is flushed to the client every
|
||||
* $flushInterval items so consumers can process records incrementally without waiting
|
||||
* for the full payload.
|
||||
*
|
||||
* Content-Type is set to `application/x-ndjson` and `X-Accel-Buffering: no` is added
|
||||
* by default to disable nginx proxy buffering.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* function records(): \Generator {
|
||||
* yield ['id' => 1, 'name' => 'Alice'];
|
||||
* yield ['id' => 2, 'name' => 'Bob'];
|
||||
* }
|
||||
*
|
||||
* return new StreamedNdJsonResponse(records());
|
||||
*/
|
||||
class StreamedNdJsonResponse extends StreamedResponse
|
||||
{
|
||||
/**
|
||||
* @param iterable<mixed> $items Items to serialize; each becomes one JSON line
|
||||
* @param int $flushInterval Flush to client after this many items (default 10)
|
||||
* @param int $status HTTP status code (default 200)
|
||||
* @param array<string, string|string[]> $headers Additional HTTP headers
|
||||
* @param int $encodingOptions Flags passed to json_encode()
|
||||
*/
|
||||
public function __construct(
|
||||
iterable $items,
|
||||
int $flushInterval = 10,
|
||||
int $status = 200,
|
||||
array $headers = [],
|
||||
private readonly int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
|
||||
) {
|
||||
parent::__construct(null, $status, $headers);
|
||||
|
||||
if (!$this->headers->get('Content-Type')) {
|
||||
$this->headers->set('Content-Type', 'application/x-ndjson');
|
||||
}
|
||||
|
||||
if (!$this->headers->has('X-Accel-Buffering')) {
|
||||
$this->headers->set('X-Accel-Buffering', 'no');
|
||||
}
|
||||
|
||||
$encodingOptions = $this->encodingOptions;
|
||||
|
||||
$this->setCallback(static function () use ($items, $flushInterval, $encodingOptions): void {
|
||||
$count = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
echo json_encode($item, \JSON_THROW_ON_ERROR | $encodingOptions) . "\n";
|
||||
$count++;
|
||||
|
||||
if ($count >= $flushInterval) {
|
||||
@ob_flush();
|
||||
flush();
|
||||
$count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// final flush for any remaining buffered items
|
||||
if ($count > 0) {
|
||||
@ob_flush();
|
||||
flush();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class FileLogger implements LoggerInterface
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0775, true);
|
||||
}
|
||||
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.log';
|
||||
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.jsonl';
|
||||
}
|
||||
|
||||
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
|
||||
|
||||
62
core/lib/Logger/PlainFileLogger.php
Normal file
62
core/lib/Logger/PlainFileLogger.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Simple file-based PSR-3 logger that writes plain-text lines.
|
||||
*
|
||||
* Each entry is formatted as:
|
||||
* 2026-02-20 12:34:56.123456 message text
|
||||
*/
|
||||
class PlainFileLogger implements LoggerInterface
|
||||
{
|
||||
private string $logFile;
|
||||
|
||||
/**
|
||||
* @param string $logDir Directory where log files are written
|
||||
* @param string $channel Logical channel name (used in filename)
|
||||
*/
|
||||
public function __construct(string $logDir, string $channel = 'app')
|
||||
{
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0775, true);
|
||||
}
|
||||
$this->logFile = rtrim($logDir, '/') . '/' . $channel . '.log';
|
||||
}
|
||||
|
||||
public function emergency($message, array $context = []): void { $this->log('emergency', $message, $context); }
|
||||
public function alert($message, array $context = []): void { $this->log('alert', $message, $context); }
|
||||
public function critical($message, array $context = []): void { $this->log('critical', $message, $context); }
|
||||
public function error($message, array $context = []): void { $this->log('error', $message, $context); }
|
||||
public function warning($message, array $context = []): void { $this->log('warning', $message, $context); }
|
||||
public function notice($message, array $context = []): void { $this->log('notice', $message, $context); }
|
||||
public function info($message, array $context = []): void { $this->log('info', $message, $context); }
|
||||
public function debug($message, array $context = []): void { $this->log('debug', $message, $context); }
|
||||
|
||||
public function log($level, $message, array $context = []): void
|
||||
{
|
||||
$dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));
|
||||
$timestamp = $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s');
|
||||
|
||||
$line = $timestamp . ' ' . $this->interpolate((string) $message, $context) . PHP_EOL;
|
||||
|
||||
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
private function interpolate(string $message, array $context): string
|
||||
{
|
||||
if (!str_contains($message, '{')) {
|
||||
return $message;
|
||||
}
|
||||
$replace = [];
|
||||
foreach ($context as $key => $val) {
|
||||
if (is_array($val) || (is_object($val) && !method_exists($val, '__toString'))) {
|
||||
continue;
|
||||
}
|
||||
$replace['{' . $key . '}'] = (string) $val;
|
||||
}
|
||||
return strtr($message, $replace);
|
||||
}
|
||||
}
|
||||
@@ -2,91 +2,310 @@
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Injection\Container;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Module\ModuleAutoloader;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\LazyCommand;
|
||||
|
||||
/**
|
||||
* Legacy Server class - now a facade to Application
|
||||
* @deprecated Use Application class directly
|
||||
* Server class - entry point for the framework
|
||||
* Handles configuration loading and kernel lifecycle
|
||||
*/
|
||||
class Server
|
||||
{
|
||||
public const ENVIRONMENT_DEV = 'dev';
|
||||
public const ENVIRONMENT_PROD = 'prod';
|
||||
private static $composerLoader = null;
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* @deprecated Use Application instead
|
||||
*/
|
||||
public static function run(): void {
|
||||
trigger_error('Server::run() is deprecated. Use Application class instead.', E_USER_DEPRECATED);
|
||||
private Kernel $kernel;
|
||||
private array $config;
|
||||
private string $rootDir;
|
||||
|
||||
$projectRoot = dirname(dirname(__DIR__));
|
||||
$app = new Application($projectRoot);
|
||||
$app->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->environment()
|
||||
*/
|
||||
public static function environment(): string {
|
||||
return self::app()->environment();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->debug()
|
||||
*/
|
||||
public static function debug(): bool {
|
||||
return self::app()->debug();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->kernel()
|
||||
*/
|
||||
public static function runtimeKernel(): Kernel {
|
||||
return self::app()->kernel();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->container()
|
||||
*/
|
||||
public static function runtimeContainer(): Container {
|
||||
return self::app()->container();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->rootDir()
|
||||
*/
|
||||
public static function runtimeRootLocation(): string {
|
||||
return self::app()->rootDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->moduleDir()
|
||||
*/
|
||||
public static function runtimeModuleLocation(): string {
|
||||
return self::app()->moduleDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::setComposerLoader()
|
||||
*/
|
||||
public static function setComposerLoader($loader): void {
|
||||
Application::setComposerLoader($loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getComposerLoader()
|
||||
*/
|
||||
public static function getComposerLoader() {
|
||||
return Application::getComposerLoader();
|
||||
}
|
||||
|
||||
private static function app(): Application
|
||||
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'Server class is deprecated and no longer functional. ' .
|
||||
'Update your code to use Application class with proper dependency injection. ' .
|
||||
'See the migration guide for details.'
|
||||
);
|
||||
self::$instance = $this;
|
||||
|
||||
$this->rootDir = $this->resolveProjectRoot($rootDir);
|
||||
|
||||
// Load configuration
|
||||
$this->config = $this->loadConfig();
|
||||
|
||||
// Determine environment and debug mode
|
||||
$environment = $environment ?? $this->config['environment'] ?? 'prod';
|
||||
$debug = $debug ?? $this->config['debug'] ?? false;
|
||||
|
||||
// Create kernel with configuration
|
||||
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
|
||||
|
||||
// Register module autoloader for both HTTP and CLI contexts
|
||||
$moduleAutoloader = new ModuleAutoloader($this->moduleDir());
|
||||
$moduleAutoloader->register();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the application - handle incoming request and send response
|
||||
*/
|
||||
public function runHttp(): void
|
||||
{
|
||||
try {
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $this->handle($request);
|
||||
$response->send();
|
||||
$this->terminate();
|
||||
} catch (\Throwable $e) {
|
||||
// Last resort error handling for kernel initialization failures
|
||||
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
$content = $this->kernel->debug()
|
||||
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
|
||||
: 'An error occurred. Please try again later.';
|
||||
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
]);
|
||||
$response->send();
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run as a console application (CLI runtime).
|
||||
*/
|
||||
public function runConsole(): int
|
||||
{
|
||||
$this->kernel()->boot();
|
||||
$container = $this->container();
|
||||
|
||||
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
|
||||
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $container->get(ModuleManager::class);
|
||||
|
||||
foreach ($moduleManager->list() as $module) {
|
||||
$instance = $module->instance();
|
||||
if (!$instance instanceof ModuleConsoleInterface) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
foreach ($instance->registerCI() as $commandClass) {
|
||||
if (!class_exists($commandClass)) {
|
||||
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
|
||||
continue;
|
||||
}
|
||||
$this->registerLazyCommand($console, $container, $commandClass);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
|
||||
return $console->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return $this->kernel->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the application - process deferred events
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
$this->kernel->processEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the kernel instance
|
||||
*/
|
||||
public function kernel(): Kernel
|
||||
{
|
||||
return $this->kernel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container instance
|
||||
*/
|
||||
public function container(): ContainerInterface
|
||||
{
|
||||
return $this->kernel->container();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application root directory
|
||||
*/
|
||||
public function rootDir(): string
|
||||
{
|
||||
return $this->rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modules directory
|
||||
*/
|
||||
public function moduleDir(): string
|
||||
{
|
||||
return $this->rootDir . '/modules';
|
||||
}
|
||||
|
||||
public function varDir(): string
|
||||
{
|
||||
return $this->rootDir . '/var';
|
||||
}
|
||||
|
||||
public function logDir(): string
|
||||
{
|
||||
return $this->varDir() . '/logs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
*/
|
||||
public function config(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
// Support dot notation: 'database.uri'
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->config;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !array_key_exists($k, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment
|
||||
*/
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->kernel->environment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
*/
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->kernel->debug();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from config directory
|
||||
*/
|
||||
protected function loadConfig(): array
|
||||
{
|
||||
$configFile = $this->rootDir . '/config/system.php';
|
||||
|
||||
if (!file_exists($configFile)) {
|
||||
error_log('Configuration file not found: ' . $configFile);
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
|
||||
if (!is_array($config)) {
|
||||
throw new \RuntimeException('Configuration file must return an array');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project root directory.
|
||||
*
|
||||
* Some entrypoints may pass the public/ directory or another subdirectory.
|
||||
* We walk up the directory tree until we find composer.json.
|
||||
*/
|
||||
private function resolveProjectRoot(string $startDir): string
|
||||
{
|
||||
$dir = rtrim($startDir, '/');
|
||||
if ($dir === '') {
|
||||
return $startDir;
|
||||
}
|
||||
|
||||
// If startDir is a file path, use its directory.
|
||||
if (is_file($dir)) {
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
|
||||
$current = $dir;
|
||||
while (true) {
|
||||
if (is_file($current . '/composer.json')) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$parent = dirname($current);
|
||||
if ($parent === $current) {
|
||||
// Reached filesystem root
|
||||
return $dir;
|
||||
}
|
||||
$current = $parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Composer ClassLoader instance
|
||||
*/
|
||||
public static function setComposerLoader($loader): void
|
||||
{
|
||||
self::$composerLoader = $loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Composer ClassLoader instance
|
||||
*/
|
||||
public static function getComposerLoader()
|
||||
{
|
||||
return self::$composerLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Application instance
|
||||
*/
|
||||
public static function getInstance(): ?self
|
||||
{
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a single command via lazy loading using its #[AsCommand] attribute.
|
||||
*/
|
||||
private function registerLazyCommand(
|
||||
ConsoleApplication $console,
|
||||
ContainerInterface $container,
|
||||
string $commandClass
|
||||
): void {
|
||||
try {
|
||||
$ref = new \ReflectionClass($commandClass);
|
||||
$attrs = $ref->getAttributes(AsCommand::class);
|
||||
|
||||
if (empty($attrs)) {
|
||||
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
|
||||
return;
|
||||
}
|
||||
|
||||
$attr = $attrs[0]->newInstance();
|
||||
$console->add(new LazyCommand(
|
||||
$attr->name,
|
||||
[],
|
||||
$attr->description ?? '',
|
||||
$attr->hidden ?? false,
|
||||
fn() => $container->get($commandClass)
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
<?php
|
||||
|
||||
use KTXC\Application;
|
||||
use KTXC\Module\ModuleAutoloader;
|
||||
use KTXC\Server;
|
||||
|
||||
// Capture Composer ClassLoader instance
|
||||
// Capture Composer ClassLoader instance for compatibility
|
||||
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// Determine project root (one level up from this file)
|
||||
$projectRoot = dirname(__DIR__);
|
||||
$server = new Server(dirname(__DIR__));
|
||||
Server::setComposerLoader($composerLoader);
|
||||
|
||||
// Create and run application
|
||||
$app = new Application($projectRoot);
|
||||
|
||||
// Store composer loader for compatibility
|
||||
Application::setComposerLoader($composerLoader);
|
||||
|
||||
// Register custom module autoloader for lazy loading
|
||||
$moduleAutoloader = new ModuleAutoloader($app->moduleDir());
|
||||
$moduleAutoloader->register();
|
||||
|
||||
$app->run();
|
||||
$server->runHttp();
|
||||
@@ -391,6 +391,9 @@ abstract class MessagePropertiesBaseAbstract extends NodePropertiesBaseAbstract
|
||||
if (!empty($this->data['attachments'])) {
|
||||
$data[self::JSON_PROPERTY_ATTACHMENTS] = $this->data['attachments'];
|
||||
}
|
||||
if (!empty($this->data['flags'])) {
|
||||
$data[self::JSON_PROPERTY_FLAGS] = $this->data['flags'];
|
||||
}
|
||||
|
||||
$data[self::JSON_PROPERTY_SUBJECT] = $this->data['subject'] ?? null;
|
||||
$data[self::JSON_PROPERTY_BODY] = $this->data['body'] ?? null;
|
||||
|
||||
@@ -37,6 +37,7 @@ interface MessagePropertiesBaseInterface extends NodePropertiesBaseInterface {
|
||||
public const JSON_PROPERTY_SNIPPET = 'snippet';
|
||||
public const JSON_PROPERTY_BODY = 'body';
|
||||
public const JSON_PROPERTY_ATTACHMENTS = 'attachments';
|
||||
public const JSON_PROPERTY_FLAGS = 'flags';
|
||||
public const JSON_PROPERTY_TAGS = 'tags';
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Mail\Service;
|
||||
|
||||
use Generator;
|
||||
use KTXF\Mail\Collection\CollectionBaseInterface;
|
||||
use KTXF\Mail\Object\AddressInterface;
|
||||
use KTXF\Resource\Delta\Delta;
|
||||
@@ -168,6 +169,21 @@ interface ServiceBaseInterface extends ResourceServiceBaseInterface {
|
||||
*/
|
||||
public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array;
|
||||
|
||||
/**
|
||||
* Lists messages in a collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string|int $collection Collection ID
|
||||
* @param IFilter|null $filter Optional filter criteria
|
||||
* @param ISort|null $sort Optional sort order
|
||||
* @param IRange|null $range Optional pagination
|
||||
* @param array|null $properties Optional message properties to fetch
|
||||
*
|
||||
* @return Generator Yields messages one by one as EntityBaseInterface
|
||||
*/
|
||||
public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator;
|
||||
|
||||
/**
|
||||
* Creates a filter builder for messages
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user