From cb2a8a5caa7c91e7194f38d45b797d1c7e221293 Mon Sep 17 00:00:00 2001 From: jeefleyd Date: Sat, 11 Apr 2026 18:59:44 -0400 Subject: [PATCH] feat: add CakePHP HTTP request handler, method executor, and task runner --- .gitignore | 2 + src/frameworks/cakephp/http_request.php | 815 +++++++++++++++++++++ src/frameworks/cakephp/method_executor.php | 409 +++++++++++ src/frameworks/cakephp/task_runner.php | 553 ++++++++++++++ 4 files changed, 1779 insertions(+) create mode 100644 .gitignore create mode 100644 src/frameworks/cakephp/http_request.php create mode 100644 src/frameworks/cakephp/method_executor.php create mode 100644 src/frameworks/cakephp/task_runner.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28e25b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +.claude/ diff --git a/src/frameworks/cakephp/http_request.php b/src/frameworks/cakephp/http_request.php new file mode 100644 index 0000000..338aa02 --- /dev/null +++ b/src/frameworks/cakephp/http_request.php @@ -0,0 +1,815 @@ +position = 0; + $this->isInput = ($path === 'php://input'); + $this->buffer = ''; + return true; + } + + public function stream_read($count) + { + $dataSource = $this->isInput ? ($GLOBALS['__DDLESS_RAW_INPUT__'] ?? '') : $this->buffer; + $data = substr($dataSource, $this->position, $count); + $this->position += strlen($data); + return $data; + } + + public function stream_eof() + { + $dataSource = $this->isInput ? ($GLOBALS['__DDLESS_RAW_INPUT__'] ?? '') : $this->buffer; + return $this->position >= strlen($dataSource); + } + + public function stream_tell() + { + return $this->position; + } + + public function stream_seek($offset, $whence = SEEK_SET) + { + $dataSource = $this->isInput ? ($GLOBALS['__DDLESS_RAW_INPUT__'] ?? '') : $this->buffer; + $length = strlen($dataSource); + + switch ($whence) { + case SEEK_SET: + $target = $offset; + break; + case SEEK_CUR: + $target = $this->position + $offset; + break; + case SEEK_END: + $target = $length + $offset; + break; + default: + return false; + } + + if ($target < 0) { + return false; + } + + $this->position = $target; + return true; + } + + public function stream_stat() + { + $size = $this->isInput ? strlen($GLOBALS['__DDLESS_RAW_INPUT__'] ?? '') : strlen($this->buffer); + return ['size' => $size]; + } + + public function stream_write($data) + { + if ($this->isInput) { + return strlen($data); + } + + $len = strlen($data); + $before = substr($this->buffer, 0, $this->position); + $after = substr($this->buffer, $this->position + $len); + $this->buffer = $before . $data . $after; + $this->position += $len; + return $len; + } + + public function stream_truncate(int $newSize) + { + if ($this->isInput) { + return false; + } + if ($newSize < strlen($this->buffer)) { + $this->buffer = substr($this->buffer, 0, $newSize); + } else { + $this->buffer = str_pad($this->buffer, $newSize, "\0"); + } + if ($this->position > $newSize) { + $this->position = $newSize; + } + return true; + } + } +} + +function ddless_normalize_relative_path(string $path): string +{ + $normalized = str_replace('\\', '/', $path); + $root = defined('DDLESS_PROJECT_ROOT') ? str_replace('\\', '/', (string)DDLESS_PROJECT_ROOT) : null; + + if ($root && str_starts_with($normalized, $root)) { + $trimmed = substr($normalized, strlen($root)); + return ltrim($trimmed, '/'); + } + + return ltrim($normalized, '/'); +} + +function ddless_normalize_headers(array $headers): array +{ + $normalized = []; + foreach ($headers as $key => $value) { + if (is_array($value)) { + $normalized[$key] = array_map( + static fn($entry) => is_scalar($entry) ? (string)$entry : json_encode($entry), + $value, + ); + } elseif ($value !== null) { + $normalized[$key] = (string)$value; + } + } + return $normalized; +} + +if (!function_exists('ddless_normalize_value')) { + function ddless_normalize_value($value, int $depth = 0) + { + $maxDepth = $GLOBALS['__DDLESS_SERIALIZE_DEPTH__'] ?? 4; + if ($depth > $maxDepth) { + if (is_object($value)) { + return '[object ' . get_class($value) . ']'; + } + return is_scalar($value) ? $value : '[max-depth]'; + } + + if (is_null($value) || is_bool($value) || is_int($value) || is_float($value)) { + return $value; + } + + if (is_string($value)) { + if (strlen($value) > 10000) { + return substr($value, 0, 10000) . "\xe2\x80\xa6"; + } + return $value; + } + + if (is_array($value)) { + $result = []; + foreach ($value as $key => $item) { + $result[$key] = ddless_normalize_value($item, $depth + 1); + } + return $result; + } + + if ($value instanceof \JsonSerializable) { + try { + return ddless_normalize_value($value->jsonSerialize(), $depth + 1); + } catch (\Throwable $exception) { + return '[object ' . get_class($value) . ']'; + } + } + + if (method_exists($value, 'toArray')) { + try { + return ddless_normalize_value($value->toArray(), $depth + 1); + } catch (\Throwable $exception) { + // ignore fallthrough + } + } + + if ($value instanceof \DateTimeInterface) { + return $value->format(\DateTimeInterface::ATOM); + } + + if (is_object($value)) { + return '[object ' . get_class($value) . ']'; + } + + if (is_resource($value)) { + return '[resource ' . (get_resource_type($value) ?: 'resource') . ']'; + } + + return (string)$value; + } +} + +if (!function_exists('ddless_build_call_stack')) { + function ddless_build_call_stack(array $backtrace): array + { + $result = []; + $limit = 30; + + foreach ($backtrace as $index => $frame) { + if ($index >= $limit) { + $result[] = [ + 'label' => null, + 'file' => null, + 'line' => null, + 'truncated' => true, + ]; + break; + } + + if (!is_array($frame)) { + continue; + } + + $class = isset($frame['class']) && is_string($frame['class']) ? $frame['class'] : null; + $type = isset($frame['type']) && is_string($frame['type']) ? $frame['type'] : ''; + $function = isset($frame['function']) && is_string($frame['function']) ? $frame['function'] : null; + $file = isset($frame['file']) && is_string($frame['file']) ? ddless_normalize_relative_path($frame['file']) : null; + $line = isset($frame['line']) && (is_int($frame['line']) || ctype_digit((string)$frame['line'])) + ? (int)$frame['line'] + : null; + + $label = $function; + if ($class !== null && $function !== null) { + $label = $class . $type . $function; + } elseif ($function === null) { + $label = 'main'; + } + + $result[] = [ + 'label' => $label, + 'file' => $file, + 'line' => $line, + ]; + } + + return $result; + } +} + +function ddless_read_breakpoints(string $ddlessDir): array +{ + $path = $ddlessDir . DIRECTORY_SEPARATOR . 'breakpoints.json'; + if (!is_file($path)) { + return []; + } + + try { + $raw = file_get_contents($path); + if ($raw === false) { + return []; + } + $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + return []; + } + $result = []; + foreach ($decoded as $file => $lines) { + if (!is_array($lines)) { + continue; + } + $normalizedFile = ddless_normalize_relative_path((string)$file); + $normalizedLines = []; + foreach ($lines as $line) { + if (is_numeric($line)) { + $normalizedLines[] = (int)$line; + } + } + if ($normalizedFile !== '' && $normalizedLines) { + $result[$normalizedFile] = array_values(array_unique($normalizedLines)); + } + } + return $result; + } catch (\Throwable $exception) { + fwrite(STDERR, "[ddless] Failed to read breakpoints: {$exception->getMessage()}\n"); + return []; + } +} + +function ddless_collect_logs(string $logDir): array +{ + // CakePHP uses logs/error.log and logs/debug.log + $candidates = ['debug.log', 'error.log']; + $logFilePath = null; + + foreach ($candidates as $candidate) { + $path = $logDir . DIRECTORY_SEPARATOR . $candidate; + if (is_file($path)) { + $logFilePath = $path; + break; + } + } + + if ($logFilePath === null || !is_readable($logFilePath)) { + return []; + } + + $handle = fopen($logFilePath, 'rb'); + if ($handle === false) { + return []; + } + + $newSize = filesize($logFilePath); + $start = 0; + if ($newSize > 32768) { + $start = $newSize - 32768; + } + + if ($start > 0) { + fseek($handle, $start); + fgets($handle); + } + + $data = stream_get_contents($handle) ?: ''; + fclose($handle); + + $data = trim($data); + if ($data === '') { + return []; + } + + $lines = preg_split("/\r?\n/", $data); + if (!$lines) { + return []; + } + + return array_slice(array_map('strval', $lines), -200); +} + +function ddless_encode_json($value): string +{ + $encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($encoded === false) { + return "{}"; + } + return $encoded; +} + +function ddless_prepare_body_payload(?string $content, ?string $contentType): array +{ + $raw = $content ?? ''; + $encoding = 'utf8'; + $truncated = false; + + $isTextual = $contentType === null + || preg_match('/json|text|xml|javascript|css|html|csv|urlencoded/i', $contentType); + + if (!$isTextual) { + $encoding = 'base64'; + $prepared = base64_encode($raw); + } else { + if ($raw !== '' && function_exists('mb_check_encoding') && !mb_check_encoding($raw, 'UTF-8')) { + $encoding = 'base64'; + $prepared = base64_encode($raw); + } else { + if (strlen($raw) > 131072) { + $prepared = substr($raw, 0, 131072); + $truncated = true; + } else { + $prepared = $raw; + } + } + } + + return [ + 'content' => $prepared, + 'encoding' => $encoding, + 'truncated' => $truncated, + ]; +} + +function ddless_response_status_text(int $statusCode): ?string +{ + static $map = [ + 100 => 'Continue', 101 => 'Switching Protocols', 102 => 'Processing', + 200 => 'OK', 201 => 'Created', 202 => 'Accepted', + 203 => 'Non-Authoritative Information', 204 => 'No Content', + 205 => 'Reset Content', 206 => 'Partial Content', 207 => 'Multi-Status', + 300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', + 303 => 'See Other', 304 => 'Not Modified', + 307 => 'Temporary Redirect', 308 => 'Permanent Redirect', + 400 => 'Bad Request', 401 => 'Unauthorized', 402 => 'Payment Required', + 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', 409 => 'Conflict', 410 => 'Gone', + 411 => 'Length Required', 412 => 'Precondition Failed', + 413 => 'Payload Too Large', 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + 500 => 'Internal Server Error', 501 => 'Not Implemented', + 502 => 'Bad Gateway', 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', 505 => 'HTTP Version Not Supported', + ]; + + return $map[$statusCode] ?? null; +} + +// CakePHP Bootstrap +$projectRoot = !empty($GLOBALS['__DDLESS_PLAYGROUND__']) && defined('DDLESS_PROJECT_ROOT') + ? DDLESS_PROJECT_ROOT + : dirname(__DIR__, 3); +$ddlessDirectory = dirname(__DIR__, 2); + +if (!defined('DDLESS_PROJECT_ROOT')) { + define('DDLESS_PROJECT_ROOT', $projectRoot); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Proxy mode (php -S / cli-server): include webroot/index.php directly. +// CakePHP's own entry point handles bootstrap, routing, middleware, and response +// natively — trying to replicate it manually breaks routing. +// ═══════════════════════════════════════════════════════════════════════════════ + +if (php_sapi_name() === 'cli-server') { + $__ddless_cake_error = null; + + register_shutdown_function( + function () use ($projectRoot, $ddlessDirectory, &$__ddless_cake_error) { + $statusCode = http_response_code() ?: 200; + + $responseHeaders = []; + foreach (headers_list() as $headerLine) { + $colonPos = strpos($headerLine, ':'); + if ($colonPos !== false) { + $name = trim(substr($headerLine, 0, $colonPos)); + $value = trim(substr($headerLine, $colonPos + 1)); + $responseHeaders[$name] = $value; + } + } + + $snapshot = [ + 'timestamp' => date('c'), + 'sessionId' => null, + 'request' => [ + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'path' => parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/', + 'fullUrl' => ($_SERVER['REQUEST_SCHEME'] ?? 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . ($_SERVER['REQUEST_URI'] ?? '/'), + 'headers' => [], + 'query' => $_GET ?? [], + 'input' => $_POST ?? [], + 'cookies' => $_COOKIE ?? [], + ], + 'response' => [ + 'status' => $statusCode, + 'statusText' => null, + 'headers' => $responseHeaders, + 'body' => ['content' => '', 'encoding' => 'utf8', 'truncated' => false], + ], + 'context' => [ + 'route' => null, + 'controller' => null, + 'breakpoints' => [], + 'hitBreakpoints' => [], + 'variables' => [], + 'callStack' => [], + ], + 'metrics' => [ + 'durationMs' => 0, + 'memoryPeakBytes' => memory_get_peak_usage(true), + ], + 'logs' => [], + ]; + + if ($__ddless_cake_error !== null) { + $snapshot['context']['error'] = $__ddless_cake_error; + } + + $encoded = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + + if (function_exists('ddless_get_session_dir')) { + @file_put_contents(ddless_get_session_dir() . DIRECTORY_SEPARATOR . 'last_execution.json', $encoded); + } + @file_put_contents($ddlessDirectory . DIRECTORY_SEPARATOR . 'last_execution.json', $encoded); + + fwrite(STDERR, "__DDLESS_REQUEST_COMPLETE__\n"); + } + ); + + if (getenv('DDLESS_DEBUG_MODE') === 'true') { + $debugModule = dirname(__DIR__, 2) . '/debug.php'; + if (file_exists($debugModule)) { + require_once $debugModule; + if (function_exists('ddless_register_stream_wrapper')) { + ddless_register_stream_wrapper(); + } + } + } + + fwrite(STDERR, "[ddless] Starting CakePHP request handling...\n"); + + $indexFile = $projectRoot . '/webroot/index.php'; + if (!is_file($indexFile)) { + http_response_code(500); + echo "DDLess: CakePHP webroot/index.php not found."; + exit(1); + } + + require $indexFile; + exit; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CLI mode (DDLess direct request): manual bootstrap, capture response, snapshot. +// ═══════════════════════════════════════════════════════════════════════════════ + +require_once $projectRoot . '/vendor/autoload.php'; + +$bootstrapFile = $projectRoot . '/config/bootstrap.php'; +if (is_file($bootstrapFile)) { + require_once $bootstrapFile; +} + +if (!class_exists('App\Application')) { + fwrite(STDERR, "[ddless] CakePHP Application class (App\\Application) not found. Run composer install.\n"); + exit(1); +} + +$breakpoints = ddless_read_breakpoints($ddlessDirectory); +$executionStartedAt = microtime(true); +$logDir = defined('LOGS') ? LOGS : $projectRoot . DIRECTORY_SEPARATOR . 'logs'; + +$phpWrapperReplaced = false; +if (in_array('php', stream_get_wrappers(), true)) { + $phpWrapperReplaced = @stream_wrapper_unregister('php'); + if ($phpWrapperReplaced) { + if (!stream_wrapper_register('php', 'DDLessPhpInputStream')) { + stream_wrapper_restore('php'); + $phpWrapperReplaced = false; + } + } +} + +if (getenv('DDLESS_DEBUG_MODE') === 'true') { + $debugModule = dirname(__DIR__, 2) . '/debug.php'; + if (file_exists($debugModule)) { + require_once $debugModule; + if (function_exists('ddless_register_stream_wrapper')) { + ddless_register_stream_wrapper(); + } + } +} + +try { + fwrite(STDERR, "[ddless] Starting CakePHP request handling...\n"); + + $app = new \App\Application(defined('CONFIG') ? CONFIG : $projectRoot . '/config'); + $app->bootstrap(); + $app->pluginBootstrap(); + + $request = \Cake\Http\ServerRequestFactory::fromGlobals(); + + if (!empty($_FILES) && class_exists('Laminas\Diactoros\UploadedFile')) { + $uploadedFiles = []; + foreach ($_FILES as $key => $file) { + if (is_array($file['tmp_name'] ?? null)) { + $uploadedFiles[$key] = []; + foreach ($file['tmp_name'] as $i => $tmpName) { + if (is_string($tmpName) && is_file($tmpName)) { + $uploadedFiles[$key][$i] = new \Laminas\Diactoros\UploadedFile( + $tmpName, + $file['size'][$i] ?? 0, + $file['error'][$i] ?? UPLOAD_ERR_OK, + $file['name'][$i] ?? basename($tmpName), + $file['type'][$i] ?? 'application/octet-stream' + ); + } + } + } else { + $tmpName = $file['tmp_name'] ?? null; + if (is_string($tmpName) && is_file($tmpName)) { + $uploadedFiles[$key] = new \Laminas\Diactoros\UploadedFile( + $tmpName, + $file['size'] ?? 0, + $file['error'] ?? UPLOAD_ERR_OK, + $file['name'] ?? basename($tmpName), + $file['type'] ?? 'application/octet-stream' + ); + } + } + } + if (!empty($uploadedFiles)) { + $request = $request->withUploadedFiles($uploadedFiles); + } + } + + $server = new \Cake\Http\Server($app); + $response = $server->run($request); + + $statusCode = $response->getStatusCode(); + $responseContentType = $response->getHeaderLine('Content-Type'); + $responseBody = (string)$response->getBody(); + $statusText = $response->getReasonPhrase() ?: (ddless_response_status_text($statusCode) ?? 'OK'); + + echo sprintf("HTTP/1.1 %d %s\r\n", $statusCode, $statusText); + + foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + echo sprintf("%s: %s\r\n", $name, $value); + } + } + + echo "\r\n"; + echo $responseBody; + + fwrite(STDERR, "[ddless] Request handling completed, status: " . $statusCode . "\n"); + + // Snapshot Generation + $executionFinishedAt = microtime(true); + $durationMs = ($executionFinishedAt - $executionStartedAt) * 1000; + $memoryPeakBytes = memory_get_peak_usage(true); + + $rawInput = $GLOBALS['__DDLESS_RAW_INPUT__'] ?? ''; + $contentType = $_SERVER['CONTENT_TYPE'] ?? null; + + $requestHeaders = []; + foreach ($request->getHeaders() as $name => $values) { + $requestHeaders[$name] = implode(', ', $values); + } + + $requestSummary = [ + 'method' => $request->getMethod(), + 'path' => $request->getUri()->getPath(), + 'fullUrl' => (string)$request->getUri(), + 'headers' => ddless_normalize_headers($requestHeaders), + 'query' => ddless_normalize_value($request->getQueryParams()), + 'input' => ddless_normalize_value((array)$request->getParsedBody()), + 'cookies' => ddless_normalize_value($request->getCookieParams()), + 'rawBody' => ddless_prepare_body_payload($rawInput, $contentType), + ]; + + $snapshotResponseHeaders = []; + foreach ($response->getHeaders() as $name => $values) { + $snapshotResponseHeaders[$name] = implode(', ', $values); + } + + $responseBodyPayload = ddless_prepare_body_payload($responseBody, $responseContentType ?: null); + + $routeInfo = null; + $controllerInfo = null; + $hitBreakpoints = []; + $callStack = []; + + try { + $params = $request->getAttribute('params') ?? []; + $controllerName = $params['controller'] ?? null; + $actionName = $params['action'] ?? null; + $plugin = $params['plugin'] ?? null; + $prefix = $params['prefix'] ?? null; + $passedArgs = $params['pass'] ?? []; + + if ($controllerName && $actionName) { + $controllerNamespace = 'App\\Controller\\'; + if ($plugin) { + $controllerNamespace = $plugin . '\\Controller\\'; + } + if ($prefix) { + $controllerNamespace .= str_replace('/', '\\', $prefix) . '\\'; + } + $controllerFqcn = $controllerNamespace . $controllerName . 'Controller'; + + $routeInfo = [ + 'uri' => $requestSummary['path'], + 'name' => null, + 'methods' => [$request->getMethod()], + 'action' => $controllerFqcn . '::' . $actionName, + 'parameters' => ddless_normalize_value($passedArgs), + ]; + + $controllerFile = null; + $startLine = null; + $endLine = null; + + if (class_exists($controllerFqcn) && method_exists($controllerFqcn, $actionName)) { + $reflection = new \ReflectionMethod($controllerFqcn, $actionName); + $controllerFile = $reflection->getFileName() ?: null; + $startLine = $reflection->getStartLine() ?: null; + $endLine = $reflection->getEndLine() ?: null; + } + + if ($controllerFile) { + $relativeControllerPath = ddless_normalize_relative_path( + str_starts_with($controllerFile, $projectRoot) + ? substr($controllerFile, strlen($projectRoot) + 1) + : $controllerFile + ); + + if ($relativeControllerPath && isset($breakpoints[$relativeControllerPath])) { + foreach ($breakpoints[$relativeControllerPath] as $line) { + if ($startLine !== null && $endLine !== null && $line >= $startLine && $line <= $endLine) { + $hitBreakpoints[$relativeControllerPath][] = $line; + } + } + } + + $controllerInfo = [ + 'file' => $relativeControllerPath, + 'class' => $controllerFqcn, + 'method' => $actionName, + 'startLine' => $startLine, + 'endLine' => $endLine, + ]; + + if (!empty($hitBreakpoints[$relativeControllerPath])) { + sort($hitBreakpoints[$relativeControllerPath]); + } + } + + $callStack[] = [ + 'label' => $routeInfo['action'], + 'file' => $controllerInfo['file'] ?? null, + 'line' => $controllerInfo['startLine'] ?? null, + ]; + } + } catch (\Throwable $exception) { + } + + $variables = [ + 'routeParameters' => $routeInfo['parameters'] ?? [], + 'requestInput' => $requestSummary['input'], + 'query' => $requestSummary['query'], + ]; + + try { + $session = $request->getAttribute('session'); + if ($session && method_exists($session, 'read')) { + $variables['session'] = ddless_normalize_value($session->read() ?? []); + } + } catch (\Throwable $exception) { + } + + try { + $identity = $request->getAttribute('identity'); + if ($identity !== null) { + if (method_exists($identity, 'getOriginalData')) { + $variables['user'] = ddless_normalize_value($identity->getOriginalData()); + } elseif ($identity instanceof \ArrayAccess) { + $variables['user'] = ddless_normalize_value((array)$identity); + } + } + } catch (\Throwable $exception) { + } + + $logs = ddless_collect_logs($logDir); + + $snapshot = [ + 'timestamp' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), + 'sessionId' => null, + 'request' => $requestSummary, + 'response' => [ + 'status' => $statusCode, + 'statusText' => ddless_response_status_text($statusCode), + 'headers' => ddless_normalize_headers($snapshotResponseHeaders), + 'body' => $responseBodyPayload, + ], + 'context' => [ + 'route' => $routeInfo, + 'controller' => $controllerInfo, + 'breakpoints' => $breakpoints, + 'hitBreakpoints' => $hitBreakpoints, + 'variables' => $variables, + 'callStack' => $callStack, + ], + 'metrics' => [ + 'durationMs' => round($durationMs, 4), + 'memoryPeakBytes' => $memoryPeakBytes, + ], + 'logs' => $logs, + ]; + + $encodedSnapshot = ddless_encode_json($snapshot) . "\n"; + + if (function_exists('ddless_get_session_dir')) { + $sessionSnapshotPath = ddless_get_session_dir() . DIRECTORY_SEPARATOR . 'last_execution.json'; + @file_put_contents($sessionSnapshotPath, $encodedSnapshot); + } + + $rootSnapshotPath = $ddlessDirectory . DIRECTORY_SEPARATOR . 'last_execution.json'; + @file_put_contents($rootSnapshotPath, $encodedSnapshot); + + fwrite(STDERR, "[ddless] Request completed successfully\n"); +} finally { + if ($phpWrapperReplaced) { + @stream_wrapper_restore('php'); + } + fwrite(STDERR, "__DDLESS_REQUEST_COMPLETE__\n"); +} diff --git a/src/frameworks/cakephp/method_executor.php b/src/frameworks/cakephp/method_executor.php new file mode 100644 index 0000000..de6d620 --- /dev/null +++ b/src/frameworks/cakephp/method_executor.php @@ -0,0 +1,409 @@ + false, + 'error' => $message, + 'context' => $context, + 'timestamp' => date('c'), + ]; + fwrite(STDOUT, json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + exit(1); +} + +function ddless_method_success(array $data): void +{ + $output = array_merge([ + 'ok' => true, + 'timestamp' => date('c'), + ], $data); + fwrite(STDOUT, json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + exit(0); +} + +function ddless_clean_php_code(string $code): string +{ + $code = trim($code); + if (str_starts_with($code, '')) { + $code = substr($code, 0, -2); + } + return trim($code); +} + +function ddless_serialize_value($value, int $depth = 0, int $maxDepth = 10, int $maxArrayItems = 100) +{ + if ($depth > $maxDepth) { + if (is_object($value)) return ['__type' => 'object', '__class' => get_class($value), '__truncated' => true]; + if (is_array($value)) return ['__type' => 'array', '__truncated' => true, '__count' => count($value)]; + return $value; + } + + if (is_null($value) || is_bool($value) || is_int($value) || is_float($value)) return $value; + + if (is_string($value)) { + if (strlen($value) > 10000) return substr($value, 0, 10000) . "\xe2\x80\xa6 [truncated]"; + return $value; + } + + if (is_array($value)) { + $result = []; + $count = 0; + foreach ($value as $key => $item) { + if ($count >= $maxArrayItems) { + $result['__truncated'] = true; + $result['__totalItems'] = count($value); + break; + } + $result[$key] = ddless_serialize_value($item, $depth + 1, $maxDepth, $maxArrayItems); + $count++; + } + return $result; + } + + if ($value instanceof \DateTimeInterface) { + return [ + '__type' => 'datetime', + 'formatted' => $value->format('Y-m-d H:i:s'), + 'timezone' => $value->getTimezone()->getName(), + 'iso8601' => $value->format(\DateTimeInterface::ATOM), + ]; + } + + if ($value instanceof \JsonSerializable) { + try { + return ddless_serialize_value($value->jsonSerialize(), $depth + 1, $maxDepth, $maxArrayItems); + } catch (\Throwable $e) { + return ['__type' => 'object', '__class' => get_class($value), '__error' => $e->getMessage()]; + } + } + + if (method_exists($value, 'toArray')) { + try { + return ddless_serialize_value($value->toArray(), $depth + 1, $maxDepth, $maxArrayItems); + } catch (\Throwable $e) { + // fallthrough + } + } + + if (is_object($value)) { + $result = ['__type' => 'object', '__class' => get_class($value)]; + try { + $ref = new \ReflectionClass($value); + $props = $ref->getProperties(); + foreach ($props as $prop) { + $prop->setAccessible(true); + if ($prop->isInitialized($value)) { + $result[$prop->getName()] = ddless_serialize_value($prop->getValue($value), $depth + 1, $maxDepth, $maxArrayItems); + } + } + } catch (\Throwable $e) { + $result['__error'] = $e->getMessage(); + } + return $result; + } + + if (is_resource($value)) { + return ['__type' => 'resource', '__resourceType' => get_resource_type($value)]; + } + + return (string)$value; +} + +// Read input +$inputJson = ''; + +if (!empty($GLOBALS['__DDLESS_METHOD_INPUT__'])) { + $inputJson = $GLOBALS['__DDLESS_METHOD_INPUT__']; +} elseif (($inputFile = getenv('DDLESS_METHOD_INPUT_FILE')) && $inputFile !== '__ALREADY_LOADED__' && is_file($inputFile)) { + $inputJson = file_get_contents($inputFile); + @unlink($inputFile); +} else { + while (!feof(STDIN)) { + $line = fgets(STDIN); + if ($line === false) break; + $inputJson .= $line; + } +} + +$input = json_decode(trim($inputJson), true); +if (!is_array($input)) { + ddless_method_error('Invalid input JSON'); +} + +$targetClass = $input['class'] ?? ''; +$targetMethod = $input['method'] ?? null; +$parameterCode = $input['parameterCode'] ?? 'return [];'; +$constructorCode = $input['constructorCode'] ?? 'return [];'; + +if (empty($targetMethod)) { + ddless_method_error('Missing required field: method/function name'); +} + +$composerAutoload = DDLESS_PROJECT_ROOT . '/vendor/autoload.php'; +if (!file_exists($composerAutoload)) { + ddless_method_error('Composer autoload not found', ['path' => $composerAutoload]); +} + +// Register debug module before autoload +if (getenv('DDLESS_DEBUG_MODE') === 'true') { + $debugModule = dirname(__DIR__, 2) . '/debug.php'; + if (file_exists($debugModule)) { + require_once $debugModule; + + if (function_exists('ddless_register_stream_wrapper')) { + fwrite(STDERR, "[ddless] Registering stream wrapper BEFORE autoload...\n"); + ddless_register_stream_wrapper(); + } + } +} + +fwrite(STDERR, "[ddless] Loading Composer autoload...\n"); +require $composerAutoload; +fwrite(STDERR, "[ddless] Composer autoload loaded.\n"); + +$bootstrapFile = DDLESS_PROJECT_ROOT . '/config/bootstrap.php'; +if (is_file($bootstrapFile)) { + require_once $bootstrapFile; +} + +// Boot the CakePHP application to get the DI container +$container = null; +try { + fwrite(STDERR, "[ddless] Booting CakePHP application...\n"); + + if (class_exists('App\Application')) { + $app = new \App\Application(CONFIG); + $app->bootstrap(); + $app->pluginBootstrap(); + + if (method_exists($app, 'getContainer')) { + $container = $app->getContainer(); + } + } + + fwrite(STDERR, "[ddless] CakePHP application booted successfully.\n"); +} catch (\Throwable $e) { + ddless_method_error('Failed to boot CakePHP application: ' . $e->getMessage(), [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => array_slice($e->getTrace(), 0, 5), + ]); +} + +// Evaluate parameter/constructor code +$cleanedConstructorCode = ddless_clean_php_code($constructorCode); +$cleanedParameterCode = ddless_clean_php_code($parameterCode); + +$constructorParameters = []; +$methodParameters = []; + +try { + $constructorParameters = (function() use ($cleanedConstructorCode, $container) { + $result = eval($cleanedConstructorCode); + if (!is_array($result)) { + throw new \InvalidArgumentException('Constructor code must return an array'); + } + return $result; + })(); +} catch (\Throwable $e) { + ddless_method_error('Error executing constructor code: ' . $e->getMessage(), [ + 'code' => $cleanedConstructorCode, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); +} + +try { + $methodParameters = (function() use ($cleanedParameterCode, $container) { + $result = eval($cleanedParameterCode); + if (!is_array($result)) { + throw new \InvalidArgumentException('Parameter code must return an array'); + } + return $result; + })(); +} catch (\Throwable $e) { + ddless_method_error('Error executing parameter code: ' . $e->getMessage(), [ + 'code' => $cleanedParameterCode, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); +} + +// Execute the method/function +try { + $startTime = microtime(true); + $startMemory = memory_get_usage(true); + + $result = null; + $isFunction = false; + $isStatic = false; + + $isGlobalFunction = empty($targetClass) || strtolower($targetClass) === 'function' || strtolower($targetClass) === 'global'; + + if ($isGlobalFunction) { + $isFunction = true; + + if (!function_exists($targetMethod)) { + ddless_method_error("Function not found: {$targetMethod}"); + } + + $result = call_user_func_array($targetMethod, $methodParameters); + } else { + if (!class_exists($targetClass)) { + ddless_method_error("Class not found: {$targetClass}"); + } + + if (!method_exists($targetClass, $targetMethod)) { + ddless_method_error("Method not found: {$targetClass}::{$targetMethod}"); + } + + $reflection = new \ReflectionClass($targetClass); + $methodReflection = $reflection->getMethod($targetMethod); + $isStatic = $methodReflection->isStatic(); + + $instance = null; + if (!$isStatic) { + try { + if (!empty($constructorParameters)) { + $instance = $reflection->newInstanceArgs($constructorParameters); + } elseif ($container && $container->has($targetClass)) { + $instance = $container->get($targetClass); + } else { + // Try TableLocator for Table classes + if (class_exists('Cake\ORM\TableRegistry') && is_subclass_of($targetClass, 'Cake\ORM\Table')) { + $tableName = $reflection->getShortName(); + if (str_ends_with($tableName, 'Table')) { + $tableName = substr($tableName, 0, -5); + } + $instance = \Cake\ORM\TableRegistry::getTableLocator()->get($tableName); + } + + if ($instance === null) { + $constructor = $reflection->getConstructor(); + if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) { + $instance = $reflection->newInstance(); + } else { + ddless_method_error('Cannot instantiate class - constructor requires parameters.', [ + 'class' => $targetClass, + 'requiredParams' => $constructor->getNumberOfRequiredParameters(), + 'hint' => 'Provide constructor parameters or use a Table class (resolved automatically via TableRegistry).', + ]); + } + } + } + } catch (\Throwable $containerError) { + $constructor = $reflection->getConstructor(); + if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) { + try { + $instance = $reflection->newInstance(); + } catch (\Throwable $e) { + ddless_method_error('Failed to instantiate class: ' . $containerError->getMessage(), [ + 'class' => $targetClass, + 'fallbackError' => $e->getMessage(), + ]); + } + } else { + ddless_method_error('Cannot instantiate class: ' . $containerError->getMessage(), [ + 'class' => $targetClass, + 'requiredParams' => $constructor->getNumberOfRequiredParameters(), + ]); + } + } + } + + if ($isStatic) { + $result = $methodReflection->invokeArgs(null, $methodParameters); + } else { + $result = $methodReflection->invokeArgs($instance, $methodParameters); + } + } + + $endTime = microtime(true); + $endMemory = memory_get_usage(true); + + $serializedResult = ddless_serialize_value($result); + $serializedParams = ddless_serialize_value($methodParameters); + + ddless_method_success([ + 'class' => $isFunction ? null : $targetClass, + 'method' => $targetMethod, + 'type' => $isFunction ? 'function' : 'method', + 'static' => $isStatic, + 'parameters' => $serializedParams, + 'result' => $serializedResult, + 'metrics' => [ + 'durationMs' => round(($endTime - $startTime) * 1000, 2), + 'memoryUsedBytes' => $endMemory - $startMemory, + 'memoryPeakBytes' => memory_get_peak_usage(true), + ], + ]); +} catch (\Throwable $e) { + $endTime = microtime(true); + $endMemory = memory_get_usage(true); + + $exceptionInfo = [ + 'class' => get_class($e), + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]; + + if (method_exists($e, 'errors')) { + $exceptionInfo['errors'] = $e->errors(); + } + if (method_exists($e, 'getCode') && is_int($e->getCode()) && $e->getCode() >= 400 && $e->getCode() < 600) { + $exceptionInfo['statusCode'] = $e->getCode(); + } + + $appTrace = []; + foreach ($e->getTrace() as $frame) { + $file = $frame['file'] ?? ''; + if ($file && !str_contains($file, '/vendor/') && !str_contains($file, '\\vendor\\')) { + $appTrace[] = [ + 'file' => $frame['file'] ?? '', + 'line' => $frame['line'] ?? 0, + 'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''), + ]; + if (count($appTrace) >= 5) break; + } + } + if (!empty($appTrace)) { + $exceptionInfo['trace'] = $appTrace; + } + + ddless_method_success([ + 'class' => isset($isFunction) && $isFunction ? null : ($targetClass ?? null), + 'method' => $targetMethod ?? '', + 'type' => (isset($isFunction) && $isFunction) ? 'function' : 'method', + 'static' => $isStatic ?? false, + 'parameters' => isset($methodParameters) ? ddless_serialize_value($methodParameters) : [], + 'result' => null, + 'exception' => $exceptionInfo, + 'metrics' => [ + 'durationMs' => isset($startTime) ? round(($endTime - $startTime) * 1000, 2) : 0, + 'memoryUsedBytes' => isset($startMemory) ? ($endMemory - $startMemory) : 0, + 'memoryPeakBytes' => memory_get_peak_usage(true), + ], + ]); +} diff --git a/src/frameworks/cakephp/task_runner.php b/src/frameworks/cakephp/task_runner.php new file mode 100644 index 0000000..5cb1424 --- /dev/null +++ b/src/frameworks/cakephp/task_runner.php @@ -0,0 +1,553 @@ + $ok, + 'durationMs' => round((microtime(true) - $startTime) * 1000, 2), + ]; + if ($error !== null) { + $data['error'] = $error; + } + echo "__DDLESS_TASK_DONE__:" . json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; +} + +function ddless_clean_php_code(string $code): string { + $code = trim($code); + if (str_starts_with($code, '')) { + $code = substr($code, 0, -2); + } + return trim($code); +} + +function ddless_prompt_with_rule(string $promptType, string $message, array $extra, ?callable $rule): string { + $validationError = null; + + while (true) { + $data = array_merge([ + 'promptType' => $promptType, + 'message' => $message, + ], $extra); + + if ($validationError !== null) { + $data['validationError'] = $validationError; + } + + ddless_task_emit('prompt', $data); + $input = trim((string)fgets(STDIN)); + + if ($rule === null) { + return $input; + } + + $result = $rule($input); + if ($result === true || $result === null) { + return $input; + } + + $validationError = is_string($result) ? $result : 'Invalid value.'; + } +} + +class DdlessChart { + private string $chartType = 'line'; + private string $chartTitle = ''; + private array $labels = []; + private array $datasets = []; + + public function title(string $title): self { $this->chartTitle = $title; return $this; } + public function type(string $type): self { $this->chartType = $type; return $this; } + public function line(): self { $this->chartType = 'line'; return $this; } + public function bar(): self { $this->chartType = 'bar'; return $this; } + public function pie(): self { $this->chartType = 'pie'; return $this; } + public function doughnut(): self { $this->chartType = 'doughnut'; return $this; } + public function area(): self { $this->chartType = 'area'; return $this; } + + public function labels(array $labels): self { $this->labels = $labels; return $this; } + public function dataset(string $label, array $data): self { + $this->datasets[] = ['label' => $label, 'data' => $data]; + return $this; + } + + public function render(): void { + ddless_task_emit('chart', [ + 'title' => $this->chartTitle, + 'chartType' => $this->chartType, + 'labels' => $this->labels, + 'datasets' => $this->datasets, + ]); + } +} + +// Read input +$inputJson = $GLOBALS['__DDLESS_TASK_INPUT__'] ?? null; +if (!$inputJson) { + ddless_task_emit('error', ['message' => 'Task input not found.']); + ddless_task_done(false, $taskStartTime, 'Task input not found.'); + exit(1); +} + +$input = json_decode($inputJson, true); +if (!is_array($input)) { + ddless_task_emit('error', ['message' => 'Invalid task input JSON.']); + ddless_task_done(false, $taskStartTime, 'Invalid task input JSON.'); + exit(1); +} + +$userCode = $input['code'] ?? ''; +$imports = $input['imports'] ?? []; +$GLOBALS['__DDLESS_IMPORTS_PATH__'] = $input['importsPath'] ?? null; + +if (trim($userCode) === '') { + ddless_task_emit('warn', ['message' => 'No code to execute.']); + ddless_task_done(true, $taskStartTime); + exit(0); +} + +$composerAutoload = DDLESS_PROJECT_ROOT . '/vendor/autoload.php'; +if (!file_exists($composerAutoload)) { + ddless_task_emit('error', ['message' => 'Composer autoload not found at: ' . $composerAutoload]); + ddless_task_done(false, $taskStartTime, 'Composer autoload not found.'); + exit(1); +} + +require $composerAutoload; + +$bootstrapFile = DDLESS_PROJECT_ROOT . '/config/bootstrap.php'; +if (is_file($bootstrapFile)) { + require_once $bootstrapFile; +} + +// Boot the CakePHP application +$container = null; +try { + if (class_exists('App\Application')) { + $app = new \App\Application(CONFIG); + $app->bootstrap(); + $app->pluginBootstrap(); + + if (method_exists($app, 'getContainer')) { + $container = $app->getContainer(); + } + } +} catch (\Throwable $e) { + ddless_task_emit('error', ['message' => 'Failed to boot CakePHP: ' . $e->getMessage()]); + ddless_task_done(false, $taskStartTime, 'Failed to boot CakePHP: ' . $e->getMessage()); + exit(1); +} + +// Task Runner Command class +class DdlessCakePhpTaskRunnerCommand { + private $__container; + private $__progressId = null; + private $__progressCurrent = 0; + private $__progressTotal = 0; + + public function setContainer($container): void { + $this->__container = $container; + } + + public function getContainer() { + return $this->__container; + } + + public function getTableLocator() { + return \Cake\ORM\TableRegistry::getTableLocator(); + } + + public function loadModel(string $name) { + return $this->getTableLocator()->get($name); + } + + // Output methods + public function info($string) { ddless_task_emit('info', ['message' => (string)$string]); } + public function error($string) { ddless_task_emit('error', ['message' => (string)$string]); } + public function warn($string) { ddless_task_emit('warn', ['message' => (string)$string]); } + public function line($string) { ddless_task_emit('line', ['message' => (string)$string]); } + public function comment($string) { ddless_task_emit('comment', ['message' => (string)$string]); } + public function newLine($count = 1) { ddless_task_emit('newline', ['count' => $count]); return $this; } + public function alert($string) { ddless_task_emit('alert', ['message' => (string)$string]); } + public function listing(array $items) { ddless_task_emit('listing', ['items' => array_values(array_map('strval', $items))]); } + public function hr() { ddless_task_emit('hr', []); } + public function clipboard($text) { ddless_task_emit('clipboard', ['text' => (string)$text]); } + public function link($url, $label = '') { ddless_task_emit('link', ['url' => (string)$url, 'label' => $label ?: (string)$url]); } + + public function table($headers, $rows) { + $plainRows = []; + if ($rows instanceof \Traversable) { + $rows = iterator_to_array($rows); + } + foreach ($rows as $row) { + if ($row instanceof \Traversable) { + $row = iterator_to_array($row); + } + if (is_object($row)) { + $row = (array) $row; + } + $plainRows[] = array_values(array_map('strval', $row)); + } + $plainHeaders = array_values(array_map('strval', (array)$headers)); + ddless_task_emit('table', [ + 'headers' => $plainHeaders, + 'rows' => $plainRows, + ]); + } + + public function json($data) { ddless_task_emit('json', ['data' => $data]); } + + public function chart(): DdlessChart { return new DdlessChart(); } + + public function spin(callable $callback, string $label = 'Processing...') { + ddless_task_emit('spin', ['label' => $label, 'state' => 'start']); + try { + $result = $callback(); + ddless_task_emit('spin', ['label' => $label, 'state' => 'done']); + return $result; + } catch (\Throwable $e) { + ddless_task_emit('spin', ['label' => $label, 'state' => 'error', 'message' => $e->getMessage()]); + throw $e; + } + } + + public function time(string $label, callable $callback) { + $start = microtime(true); + $result = $callback(); + $elapsed = round((microtime(true) - $start) * 1000, 2); + ddless_task_emit('time', ['label' => $label, 'durationMs' => $elapsed]); + return $result; + } + + public function progress(int $total, string $label = ''): self { + $this->__progressId = 'prog_' . uniqid(); + $this->__progressCurrent = 0; + $this->__progressTotal = $total; + + ddless_task_emit('progress', [ + 'progressId' => $this->__progressId, + 'current' => 0, + 'total' => $total, + 'percent' => 0, + 'label' => $label, + ]); + + return $this; + } + + public function advance(int $step = 1, ?string $label = null): self { + if (!$this->__progressId) return $this; + + $this->__progressCurrent = min($this->__progressCurrent + $step, $this->__progressTotal); + $percent = $this->__progressTotal > 0 + ? round(($this->__progressCurrent / $this->__progressTotal) * 100, 1) + : 0; + + ddless_task_emit('progress', [ + 'progressId' => $this->__progressId, + 'current' => $this->__progressCurrent, + 'total' => $this->__progressTotal, + 'percent' => $percent, + 'label' => $label, + ]); + + return $this; + } + + // Interactive prompts + public function ask($question, $default = null, ?callable $rule = null) { + $input = ddless_prompt_with_rule('ask', (string)$question, [ + 'promptDefault' => (string)($default ?? ''), + ], $rule); + return $input !== '' ? $input : ($default ?? ''); + } + + public function secret($question, $fallback = true, ?callable $rule = null) { + return ddless_prompt_with_rule('secret', (string)$question, [], $rule); + } + + public function confirm($question, $default = false, ?callable $rule = null) { + $input = ddless_prompt_with_rule('confirm', (string)$question, [ + 'promptDefault' => $default ? 'true' : 'false', + ], $rule); + return $input === 'true'; + } + + public function choice($question, $choices, $default = null, $attempts = null, $multiple = false, ?callable $rule = null) { + if ($choices instanceof \Traversable) { + $choices = iterator_to_array($choices); + } + $input = ddless_prompt_with_rule('choice', (string)$question, [ + 'promptOptions' => array_values((array)$choices), + 'promptDefault' => $default ?? $choices[0] ?? '', + ], $rule); + return $input; + } + + public function anticipate($question, $suggestions, $default = null, ?callable $rule = null) { + if ($suggestions instanceof \Traversable) { + $suggestions = iterator_to_array($suggestions); + } + return ddless_prompt_with_rule('anticipate', (string)$question, [ + 'promptOptions' => array_values((array)$suggestions), + 'promptDefault' => (string)($default ?? ''), + ], $rule); + } + + public function date($question, ?string $default = null, ?callable $rule = null) { + return ddless_prompt_with_rule('date', (string)$question, [ + 'promptDefault' => $default ?? date('Y-m-d'), + ], $rule); + } + + public function datetime($question, ?string $default = null, ?callable $rule = null) { + return ddless_prompt_with_rule('datetime', (string)$question, [ + 'promptDefault' => $default ?? date('Y-m-d H:i'), + ], $rule); + } + + public function import(string $filename, string $delimiter = ',', bool $headers = true, string $encoding = 'auto'): \Generator { + $importsPath = $GLOBALS['__DDLESS_IMPORTS_PATH__'] ?? null; + if (!$importsPath) { + throw new \RuntimeException("No imports directory configured."); + } + + $filePath = rtrim($importsPath, '/\\') . DIRECTORY_SEPARATOR . basename($filename); + if (!is_file($filePath)) { + throw new \RuntimeException("Import file not found: {$filename}"); + } + + $handle = fopen($filePath, 'r'); + if ($handle === false) { + throw new \RuntimeException("Failed to open import file: {$filename}"); + } + + try { + $encodingDetected = null; + if ($encoding === 'auto') { + $sample = fread($handle, 8192); + if ($sample === false || $sample === '') { + return; + } + if (str_starts_with($sample, "\xEF\xBB\xBF")) { + $sample = substr($sample, 3); + $encodingDetected = 'UTF-8'; + } else { + $detected = mb_detect_encoding($sample, ['UTF-8', 'Windows-1252', 'ISO-8859-1'], true); + $encodingDetected = $detected ?: 'UTF-8'; + } + rewind($handle); + $bom = fread($handle, 3); + if ($bom !== "\xEF\xBB\xBF") { + rewind($handle); + } + } else { + $encodingDetected = $encoding; + } + + $needsConversion = $encodingDetected !== null + && $encodingDetected !== 'UTF-8' + && $encodingDetected !== 'utf-8'; + + $headerRow = null; + if ($headers) { + $firstLine = fgetcsv($handle, 0, $delimiter); + if ($firstLine === false || $firstLine === null) { + return; + } + if ($needsConversion) { + $firstLine = array_map(fn($v) => mb_convert_encoding((string) $v, 'UTF-8', $encodingDetected), $firstLine); + } + $headerRow = $firstLine; + } + + while (($row = fgetcsv($handle, 0, $delimiter)) !== false) { + if ($row === null) continue; + if ($needsConversion) { + $row = array_map(fn($v) => mb_convert_encoding((string) $v, 'UTF-8', $encodingDetected), $row); + } + if ($headerRow !== null) { + $row = array_pad($row, count($headerRow), ''); + yield array_combine($headerRow, array_slice($row, 0, count($headerRow))); + } else { + yield $row; + } + } + } finally { + fclose($handle); + } + } + + public function export(string $filename, callable $callback, string $delimiter = ';'): void + { + if (!str_ends_with(strtolower($filename), '.csv')) { + $filename .= '.csv'; + } + + $page = 0; + $totalRows = 0; + $headersSent = false; + $exportId = 'exp_' . uniqid(); + + ddless_task_emit('export_begin', [ + 'exportId' => $exportId, + 'filename' => $filename, + ]); + + try { + while (true) { + $rows = $callback($page); + + if ($rows === null || $rows === [] || $rows === false) { + break; + } + + if (!is_array($rows)) { + throw new \RuntimeException('Export callback must return an array of rows, null, or an empty array.'); + } + + $csvLines = []; + + if (!$headersSent) { + $firstRow = reset($rows); + if (is_array($firstRow) || is_object($firstRow)) { + $headers = array_keys((array) $firstRow); + $csvLines[] = ddless_csv_encode_row($headers, $delimiter); + } + $headersSent = true; + } + + foreach ($rows as $row) { + $csvLines[] = ddless_csv_encode_row(array_values((array) $row), $delimiter); + $totalRows++; + } + + ddless_task_emit('export_chunk', [ + 'exportId' => $exportId, + 'csv' => implode("\n", $csvLines) . "\n", + 'totalRows' => $totalRows, + ]); + + $page++; + } + + ddless_task_emit('export_end', [ + 'exportId' => $exportId, + 'filename' => $filename, + 'totalRows' => $totalRows, + ]); + } catch (\Throwable $e) { + ddless_task_emit('export_error', [ + 'exportId' => $exportId, + 'error' => $e->getMessage(), + 'totalRows' => $totalRows, + ]); + throw $e; + } + } +} + +// Execute user code +try { + $command = new DdlessCakePhpTaskRunnerCommand(); + if ($container) { + $command->setContainer($container); + } + + $useStatements = ''; + foreach ($imports as $import) { + $import = trim($import); + if ($import !== '') { + $useStatements .= "use {$import};\n"; + } + } + + $cleanedCode = ddless_clean_php_code($userCode); + $evalCode = $useStatements . "\n" . $cleanedCode; + + $closure = \Closure::bind(function () use ($evalCode) { + return eval($evalCode); + }, $command, DdlessCakePhpTaskRunnerCommand::class); + + $closure(); + + ddless_task_done(true, $taskStartTime); +} catch (\Throwable $e) { + $isEvalError = str_contains($e->getFile(), "eval()'d code"); + + if ($isEvalError) { + ddless_task_emit('error', [ + 'message' => $e->getMessage(), + ]); + ddless_task_done(false, $taskStartTime, $e->getMessage()); + exit(1); + } + + $exceptionData = [ + 'exceptionClass' => get_class($e), + 'message' => $e->getMessage(), + 'exceptionCode' => $e->getCode(), + 'exceptionFile' => $e->getFile(), + 'exceptionLine' => $e->getLine(), + ]; + + if (method_exists($e, 'errors')) { + $exceptionData['errors'] = $e->errors(); + } + + ddless_task_emit('exception', $exceptionData); + ddless_task_done(true, $taskStartTime); +}