diff --git a/README-es.md b/README-es.md index 7c71d1f..bae131a 100644 --- a/README-es.md +++ b/README-es.md @@ -14,7 +14,7 @@
-DLoad simplifica la descarga y gestión de artefactos binarios para tus proyectos. Perfecto para entornos de desarrollo que requieren herramientas específicas como RoadRunner, Temporal o binarios personalizados. +DLoad simplifica la descarga y gestión de artefactos binarios para tus proyectos. Es perfecto para entornos de desarrollo que necesitan herramientas específicas como RoadRunner, Temporal o binarios personalizados. [![English readme](https://img.shields.io/badge/README-English%20%F0%9F%87%BA%F0%9F%87%B8-moccasin?style=flat-square)](README.md) [![Chinese readme](https://img.shields.io/badge/README-%E4%B8%AD%E6%96%87%20%F0%9F%87%A8%F0%9F%87%B3-moccasin?style=flat-square)](README-zh.md) @@ -24,13 +24,50 @@ DLoad simplifica la descarga y gestión de artefactos binarios para tus proyecto ## ¿Por qué DLoad? DLoad resuelve un problema común en proyectos PHP: cómo distribuir e instalar herramientas binarias y recursos necesarios junto con tu código PHP. -Con DLoad, puedes: +Con DLoad puedes: + +- Descargar automáticamente las herramientas que necesitas durante la configuración inicial del proyecto +- Asegurar que todo el equipo use exactamente las mismas versiones de las herramientas +- Simplificar la incorporación de nuevos desarrolladores automatizando la configuración del entorno +- Manejar compatibilidad multiplataforma sin configuración manual +- Mantener binarios y recursos fuera de tu control de versiones + +### Tabla de Contenidos + +- [Instalación](#instalación) +- [Inicio Rápido](#inicio-rápido) +- [Uso desde Línea de Comandos](#uso-desde-línea-de-comandos) + - [Inicializar Configuración](#inicializar-configuración) + - [Descargar Software](#descargar-software) + - [Ver Software](#ver-software) + - [Construir Software Personalizado](#construir-software-personalizado) +- [Guía de Configuración](#guía-de-configuración) + - [Configuración Interactiva](#configuración-interactiva) + - [Configuración Manual](#configuración-manual) + - [Tipos de Descarga](#tipos-de-descarga) + - [Restricciones de Versión](#restricciones-de-versión) + - [Opciones de Configuración Avanzadas](#opciones-de-configuración-avanzadas) +- [Construir RoadRunner Personalizado](#construir-roadrunner-personalizado) + - [Configuración de Acción de Construcción](#configuración-de-acción-de-construcción) + - [Atributos de Acción Velox](#atributos-de-acción-velox) + - [Proceso de Construcción](#proceso-de-construcción) + - [Generación de Archivo de Configuración](#generación-de-archivo-de-configuración) + - [Usar Velox Descargado](#usar-velox-descargado) + - [Configuración DLoad](#configuración-dload) + - [Construir RoadRunner](#construir-roadrunner) +- [Registro de Software Personalizado](#registro-de-software-personalizado) + - [Definir Software](#definir-software) + - [Elementos de Software](#elementos-de-software) +- [Casos de Uso](#casos-de-uso) + - [Configurar Entorno de Desarrollo](#configurar-entorno-de-desarrollo) + - [Configurar Nuevo Proyecto](#configurar-nuevo-proyecto) + - [Integración CI/CD](#integración-cicd) + - [Equipos Multiplataforma](#equipos-multiplataforma) + - [Gestión de Herramientas PHAR](#gestión-de-herramientas-phar) + - [Distribución de Assets Frontend](#distribución-de-assets-frontend) +- [Límites de Rate de la API de GitHub](#límites-de-rate-de-la-api-de-github) +- [Contribuir](#contribuir) -- Descargar automáticamente herramientas requeridas durante la inicialización del proyecto -- Asegurar que todos los miembros del equipo usen las mismas versiones de herramientas -- Simplificar la incorporación automatizando la configuración del entorno -- Gestionar compatibilidad multiplataforma sin configuración manual -- Mantener binarios y recursos separados de tu control de versiones ## Instalación @@ -45,24 +82,27 @@ composer require internal/dload -W ## Inicio Rápido -1. **Instala DLoad vía Composer**: +1. **Instala DLoad usando Composer**: ```bash composer require internal/dload -W ``` -2. **Crea tu archivo de configuración interactivamente**: +También puedes descargar la versión más reciente desde [GitHub releases](https://github.com/php-internal/dload/releases). + +2. **Crea tu archivo de configuración de forma interactiva**: ```bash ./vendor/bin/dload init ``` - Este comando te guiará a través de la selección de paquetes de software y creará un archivo de configuración `dload.xml`. También puedes crearlo manualmente: + Este comando te ayudará a seleccionar paquetes de software y creará un archivo de configuración `dload.xml`. También puedes crearlo manualmente: ```xml + xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/php-internal/dload/refs/heads/1.x/dload.xsd" + > @@ -76,28 +116,28 @@ composer require internal/dload -W ./vendor/bin/dload get ``` -4. **Integración con Composer** (opcional): +4. **Integra con Composer** (opcional): ```json { "scripts": { - "post-update-cmd": "dload get --no-interaction -v || echo can't dload binaries" + "post-update-cmd": "dload get --no-interaction -v || \"echo can't dload binaries\"" } } ``` -## Uso de Línea de Comandos +## Uso desde Línea de Comandos ### Inicializar Configuración ```bash -# Crear archivo de configuración interactivamente +# Crear archivo de configuración de forma interactiva ./vendor/bin/dload init -# Crear configuración en ubicación específica +# Crear configuración en una ubicación específica ./vendor/bin/dload init --config=./custom-dload.xml -# Crear configuración mínima sin prompts +# Crear configuración mínima sin preguntas ./vendor/bin/dload init --no-interaction # Sobrescribir configuración existente sin confirmación @@ -107,26 +147,26 @@ composer require internal/dload -W ### Descargar Software ```bash -# Descargar desde archivo de configuración +# Descargar usando el archivo de configuración ./vendor/bin/dload get # Descargar paquetes específicos ./vendor/bin/dload get rr temporal -# Descargar con opciones +# Descargar con opciones adicionales ./vendor/bin/dload get rr --stability=beta --force ``` #### Opciones de Descarga -| Opción | Descripción | Por defecto | -|--------|-------------|---------| -| `--path` | Directorio para almacenar binarios | Directorio actual | -| `--arch` | Arquitectura objetivo (amd64, arm64) | Arquitectura del sistema | -| `--os` | SO objetivo (linux, darwin, windows) | SO actual | -| `--stability` | Estabilidad de lanzamiento (stable, beta) | stable | +| Opción | Descripción | Valor por defecto | +|--------|-------------|-------------------| +| `--path` | Directorio donde guardar los binarios | Directorio actual | +| `--arch` | Arquitectura de destino (amd64, arm64) | Arquitectura del sistema | +| `--os` | Sistema operativo de destino (linux, darwin, windows) | SO actual | +| `--stability` | Estabilidad del release (stable, beta) | stable | | `--config` | Ruta al archivo de configuración | ./dload.xml | -| `--force`, `-f` | Forzar descarga aunque el binario exista | false | +| `--force`, `-f` | Forzar descarga aunque el binario ya exista | false | ### Ver Software @@ -144,21 +184,40 @@ composer require internal/dload -W ./vendor/bin/dload show --all ``` +### Construir Software Personalizado + +```bash +# Construir software personalizado usando el archivo de configuración +./vendor/bin/dload build + +# Construir con un archivo de configuración específico +./vendor/bin/dload build --config=./custom-dload.xml +``` + +#### Opciones de Construcción + +| Opción | Descripción | Valor por defecto | +|--------|-------------|-------------------| +| `--config` | Ruta al archivo de configuración | ./dload.xml | + +El comando `build` ejecuta las acciones de construcción definidas en tu archivo de configuración, como crear binarios personalizados de RoadRunner con plugins específicos. +Para información detallada sobre cómo construir RoadRunner personalizado, consulta la sección [Construir RoadRunner Personalizado](#construir-roadrunner-personalizado). + ## Guía de Configuración ### Configuración Interactiva -La forma más fácil de crear un archivo de configuración es usando el comando interactivo `init`: +La forma más sencilla de crear un archivo de configuración es usando el comando interactivo `init`: ```bash ./vendor/bin/dload init ``` -Esto: +Esto hará lo siguiente: -- Te guiará a través de la selección de paquetes de software +- Te guiará en la selección de paquetes de software - Mostrará software disponible con descripciones y repositorios -- Generará un archivo `dload.xml` correctamente formateado con validación de esquema +- Generará un archivo `dload.xml` bien formateado con validación de esquema - Manejará archivos de configuración existentes de manera elegante ### Configuración Manual @@ -180,31 +239,31 @@ Crea `dload.xml` en la raíz de tu proyecto: ### Tipos de Descarga -DLoad soporta tres tipos de descarga que determinan cómo se procesan los recursos: +DLoad soporta tres tipos de descarga que determinan cómo se procesan los assets: -#### Atributo de Tipo +#### Atributo Type ```xml - + - + - + ``` -#### Comportamiento Por Defecto (Tipo No Especificado) +#### Comportamiento por Defecto (Sin Especificar Type) -Cuando `type` no se especifica, DLoad automáticamente usa todos los manejadores disponibles: +Cuando no se especifica `type`, DLoad automáticamente usa todos los manejadores disponibles: -- **Procesamiento de binarios**: Si el software tiene sección ``, realiza verificación de presencia y versión de binarios -- **Procesamiento de archivos**: Si el software tiene sección `` y el recurso se descarga, procesa archivos durante el desempaquetado -- **Descarga simple**: Si no existen secciones, descarga el recurso sin desempaquetar +- **Procesamiento de binarios**: Si el software tiene una sección ``, verifica la presencia y versión del binario +- **Procesamiento de archivos**: Si el software tiene una sección `` y el asset se descarga, procesa los archivos durante la extracción +- **Descarga simple**: Si no hay secciones, descarga el asset sin extraer ```xml - + @@ -218,14 +277,14 @@ Cuando `type` no se especifica, DLoad automáticamente usa todos los manejadores #### Comportamientos de Tipos Explícitos | Tipo | Comportamiento | Caso de Uso | -|-----------|--------------------------------------------------------------|--------------------------------| +|-----------|-------------------------------------------------------------------|----------------------------------| | `binary` | Verificación de binarios, validación de versión, permisos de ejecución | Herramientas CLI, ejecutables | -| `phar` | Descarga archivos `.phar` como ejecutables **sin desempaquetar** | Herramientas PHP como Psalm, PHPStan | -| `archive` | **Fuerza desempaquetado incluso para archivos .phar** | Cuando necesitas contenido de archivo | +| `phar` | Descarga archivos `.phar` como ejecutables **sin extraer** | Herramientas PHP como Psalm, PHPStan | +| `archive` | **Fuerza la extracción incluso para archivos .phar** | Cuando necesitas el contenido del archivo | > [!NOTE] -> Usa `type="phar"` para herramientas PHP que deben permanecer como archivos `.phar`. -> Usar `type="archive"` desempaquetará incluso archivos `.phar`. +> Usa `type="phar"` para herramientas PHP que deben mantenerse como archivos `.phar`. +> Usar `type="archive"` extraerá incluso archivos `.phar`. ### Restricciones de Versión @@ -243,7 +302,7 @@ Usa restricciones de versión estilo Composer: - + ``` @@ -257,16 +316,113 @@ Usa restricciones de versión estilo Composer: - + ``` +## Construir RoadRunner Personalizado + +DLoad soporta la construcción de binarios personalizados de RoadRunner usando la herramienta Velox. Esto es útil cuando necesitas RoadRunner con combinaciones específicas de plugins que no están disponibles en las versiones pre-construidas. + +### Configuración de Acción de Construcción + +```xml + + + + + + + +``` + +### Atributos de Acción Velox + +| Atributo | Descripción | +|-----------|-------------| +| `velox-version` | Restricción de versión para la herramienta de construcción Velox a utilizar | +| `golang-version` | Restricción de versión de Go requerida para construir RoadRunner | +| `roadrunner-ref` | Referencia Git de RoadRunner (tag, commit o rama) a usar como base para la construcción | +| `config-file` | Ruta al archivo de configuración base que puede fusionarse con respuestas de API remotas u otras fuentes | +| `binary-path` | Ruta de salida para el binario RoadRunner construido. La extensión del archivo se agrega automáticamente según el SO (`.exe` para Windows). Por defecto usa el directorio de trabajo actual | +| `debug` | Construir RoadRunner con símbolos de depuración para perfilarlo con pprof (booleano, por defecto `false`) | + +### Proceso de Construcción + +DLoad maneja automáticamente todo el proceso de construcción: + +1. **Verificación de Golang**: Verifica que Go esté instalado globalmente (dependencia requerida) +2. **Preparación de Velox**: Usa Velox desde instalación global, descarga local, o lo descarga automáticamente si es necesario +3. **Configuración**: Copia tu archivo velox.toml local al directorio de construcción +4. **Construcción**: Ejecuta el comando `vx build` con la configuración especificada +5. **Instalación**: Mueve el binario construido a la ubicación de destino y establece permisos de ejecución +6. **Limpieza**: Elimina archivos temporales de construcción + +> [!NOTE] +> DLoad requiere que Go (Golang) esté instalado globalmente en tu sistema. No descarga ni gestiona instalaciones de Go. + +### Generación de Archivo de Configuración + +Puedes generar un archivo de configuración `velox.toml` usando el constructor online en https://build.roadrunner.dev/ + +Para documentación detallada sobre opciones de configuración de Velox y ejemplos, visita https://docs.roadrunner.dev/docs/customization/build + +Esta interfaz web te ayuda a seleccionar plugins y genera la configuración apropiada para tu build personalizado de RoadRunner. + +### Usar Velox Descargado + +Puedes descargar Velox como parte de tu proceso de construcción en lugar de depender de una versión instalada globalmente: + +```xml + + + + +``` + +Esto asegura versiones consistentes de Velox entre diferentes entornos y miembros del equipo. + +### Configuración DLoad + +```xml + + + + + + +``` + +### Construir RoadRunner + +```bash +# Construir RoadRunner usando la configuración velox.toml +./vendor/bin/dload build + +# Construir con un archivo de configuración específico +./vendor/bin/dload build --config=custom-rr.xml +``` + +El binario de RoadRunner construido incluirá solo los plugins especificados en tu archivo `velox.toml`, reduciendo el tamaño del binario y mejorando el rendimiento para tu caso de uso específico. + ## Registro de Software Personalizado -### Definiendo Software +### Definir Software ```xml @@ -279,15 +435,15 @@ Usa restricciones de versión estilo Composer: - - + + - + @@ -305,27 +461,27 @@ Usa restricciones de versión estilo Composer: ### Elementos de Software -#### Configuración de Repositorio +#### Configuración de Repository - **type**: Actualmente soporta "github" - **uri**: Ruta del repositorio (ej., "username/repo") -- **asset-pattern**: Patrón de expresión regular para coincidir con recursos de lanzamiento +- **asset-pattern**: Patrón regex para hacer match con assets de release -#### Elementos Binarios +#### Elementos Binary - **name**: Nombre del binario para referencia -- **pattern**: Patrón de expresión regular para coincidir con binario en recursos -- Maneja automáticamente filtrado por SO/arquitectura +- **pattern**: Patrón regex para hacer match con el binario en los assets +- Maneja automáticamente el filtrado por SO/arquitectura -#### Elementos de Archivo +#### Elementos File -- **pattern**: Patrón de expresión regular para coincidir con archivos +- **pattern**: Patrón regex para hacer match con archivos - **extract-path**: Directorio de extracción opcional - Funciona en cualquier sistema (sin filtrado por SO/arquitectura) ## Casos de Uso -### Configuración de Entorno de Desarrollo +### Configurar Entorno de Desarrollo ```bash # Configuración única para nuevos desarrolladores @@ -334,10 +490,10 @@ composer install ./vendor/bin/dload get ``` -### Configuración de Nuevo Proyecto +### Configurar Nuevo Proyecto ```bash -# Iniciar un nuevo proyecto con DLoad +# Empezar un nuevo proyecto con DLoad composer init composer require internal/dload -W ./vendor/bin/dload init @@ -358,7 +514,7 @@ Cada desarrollador obtiene los binarios correctos para su sistema: ```xml - + ``` @@ -372,11 +528,11 @@ Cada desarrollador obtiene los binarios correctos para su sistema: - + ``` -### Distribución de Recursos Frontend +### Distribución de Assets Frontend ```xml @@ -389,7 +545,7 @@ Cada desarrollador obtiene los binarios correctos para su sistema: ``` -## Límites de Rate de API de GitHub +## Límites de Rate de la API de GitHub Usa un token de acceso personal para evitar límites de rate: @@ -397,12 +553,12 @@ Usa un token de acceso personal para evitar límites de rate: GITHUB_TOKEN=your_token_here ./vendor/bin/dload get ``` -Añádelo a variables de entorno CI/CD para descargas automatizadas. +Agrégalo a las variables de entorno CI/CD para descargas automatizadas. -## Contribuciones +## Contribuir ¡Las contribuciones son bienvenidas! Envía Pull Requests para: -- Añadir nuevo software al registro predefinido -- Mejorar la funcionalidad de DLoad +- Agregar nuevo software al registro predefinido +- Mejorar la funcionalidad de DLoad - Mejorar la documentación y traducirla a [otros idiomas](docs/guidelines/how-to-translate-readme-docs.md) diff --git a/README-ru.md b/README-ru.md index b4868aa..85f4f9a 100644 --- a/README-ru.md +++ b/README-ru.md @@ -4,7 +4,7 @@ -

Легкая загрузка артефактов

+

Скачивай артефакты на раз

@@ -14,23 +14,61 @@
-DLoad упрощает загрузку и управление бинарными артефактами для ваших проектов. Идеально подходит для сред разработки, которые требуют специфических инструментов, таких как RoadRunner, Temporal или пользовательские бинарные файлы. +DLoad упрощает загрузку и управление бинарными артефактами в ваших проектах. Отлично подходит для dev-окружений, которым нужны специфические инструменты вроде RoadRunner, Temporal или собственные бинарники. [![English readme](https://img.shields.io/badge/README-English%20%F0%9F%87%BA%F0%9F%87%B8-moccasin?style=flat-square)](README.md) [![Chinese readme](https://img.shields.io/badge/README-%E4%B8%AD%E6%96%87%20%F0%9F%87%A8%F0%9F%87%B3-moccasin?style=flat-square)](README-zh.md) [![Russian readme](https://img.shields.io/badge/README-Русский%20%F0%9F%87%B7%F0%9F%87%BA-moccasin?style=flat-square)](README-ru.md) [![Spanish readme](https://img.shields.io/badge/README-Español%20%F0%9F%87%AA%F0%9F%87%B8-moccasin?style=flat-square)](README-es.md) -## Почему DLoad? +## Зачем нужен DLoad? + +DLoad решает распространённую проблему в PHP-проектах: как распространять и устанавливать нужные бинарные инструменты и ресурсы вместе с PHP-кодом. -DLoad решает общую проблему в PHP-проектах: как распространять и устанавливать необходимые бинарные инструменты и ресурсы вместе с PHP-кодом. С DLoad вы можете: -- Автоматически загружать необходимые инструменты во время инициализации проекта -- Обеспечить использование одинаковых версий инструментов всеми участниками команды -- Упростить адаптацию через автоматизацию настройки окружения -- Управлять кроссплатформенной совместимостью без ручной конфигурации -- Хранить бинарные файлы и ресурсы отдельно от системы контроля версий +- Автоматически скачивать необходимые инструменты при инициализации проекта +- Обеспечить использование одинаковых версий инструментов всей командой +- Упростить онбординг через автоматизацию настройки окружения +- Управлять кроссплатформенной совместимостью без ручной настройки +- Хранить бинарники и ресурсы отдельно от системы контроля версий + +### Содержание + +- [Установка](#установка) +- [Быстрый старт](#быстрый-старт) +- [Использование в командной строке](#использование-в-командной-строке) + - [Инициализация конфигурации](#инициализация-конфигурации) + - [Загрузка ПО](#загрузка-по) + - [Просмотр ПО](#просмотр-по) + - [Сборка кастомного ПО](#сборка-кастомного-по) +- [Руководство по конфигурации](#руководство-по-конфигурации) + - [Интерактивная конфигурация](#интерактивная-конфигурация) + - [Ручная конфигурация](#ручная-конфигурация) + - [Типы загрузки](#типы-загрузки) + - [Ограничения версий](#ограничения-версий) + - [Расширенные настройки](#расширенные-настройки) +- [Сборка кастомного RoadRunner](#сборка-кастомного-roadrunner) + - [Настройка действия сборки](#настройка-действия-сборки) + - [Атрибуты Velox-действия](#атрибуты-velox-действия) + - [Процесс сборки](#процесс-сборки) + - [Генерация конфигурационного файла](#генерация-конфигурационного-файла) + - [Использование скачанного Velox](#использование-скачанного-velox) + - [Конфигурация DLoad](#конфигурация-dload) + - [Сборка RoadRunner](#сборка-roadrunner) +- [Пользовательский реестр ПО](#пользовательский-реестр-по) + - [Определение ПО](#определение-по) + - [Элементы ПО](#элементы-по) +- [Сценарии использования](#сценарии-использования) + - [Настройка среды разработки](#настройка-среды-разработки) + - [Настройка нового проекта](#настройка-нового-проекта) + - [Интеграция с CI/CD](#интеграция-с-cicd) + - [Кроссплатформенные команды](#кроссплатформенные-команды) + - [Управление PHAR-инструментами](#управление-phar-инструментами) + - [Распространение фронтенд-ресурсов](#распространение-фронтенд-ресурсов) +- [Ограничения GitHub API](#ограничения-github-api) +- [Участие в разработке](#участие-в-разработке) + ## Установка @@ -51,18 +89,21 @@ composer require internal/dload -W composer require internal/dload -W ``` -2. **Создайте файл конфигурации интерактивно**: +Альтернативно можно скачать последний релиз с [GitHub releases](https://github.com/php-internal/dload/releases). + +2. **Создайте конфигурационный файл интерактивно**: ```bash ./vendor/bin/dload init ``` - Эта команда проведет вас через выбор пакетов ПО и создаст файл конфигурации `dload.xml`. Вы также можете создать его вручную: + Эта команда проведёт вас через выбор пакетов ПО и создаст файл конфигурации `dload.xml`. Можно также создать его вручную: ```xml + xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/php-internal/dload/refs/heads/1.x/dload.xsd" + > @@ -70,34 +111,34 @@ composer require internal/dload -W ``` -3. **Загрузите настроенное ПО**: +3. **Скачайте настроенное ПО**: ```bash ./vendor/bin/dload get ``` -4. **Интеграция с Composer** (опционально): +4. **Интегрируйте с Composer** (опционально): ```json { "scripts": { - "post-update-cmd": "dload get --no-interaction -v || echo can't dload binaries" + "post-update-cmd": "dload get --no-interaction -v || \"echo can't dload binaries\"" } } ``` -## Использование командной строки +## Использование в командной строке ### Инициализация конфигурации ```bash -# Создать файл конфигурации интерактивно +# Создать конфигурационный файл интерактивно ./vendor/bin/dload init -# Создать конфигурацию в определенном месте +# Создать конфигурацию в определённом месте ./vendor/bin/dload init --config=./custom-dload.xml -# Создать минимальную конфигурацию без подсказок +# Создать минимальную конфигурацию без запросов ./vendor/bin/dload init --no-interaction # Перезаписать существующую конфигурацию без подтверждения @@ -107,63 +148,82 @@ composer require internal/dload -W ### Загрузка ПО ```bash -# Загрузить из файла конфигурации +# Загрузить из конфигурационного файла ./vendor/bin/dload get -# Загрузить определенные пакеты +# Загрузить конкретные пакеты ./vendor/bin/dload get rr temporal -# Загрузить с опциями +# Загрузить с дополнительными опциями ./vendor/bin/dload get rr --stability=beta --force ``` #### Опции загрузки | Опция | Описание | По умолчанию | -|--------|-------------|---------| -| `--path` | Директория для хранения бинарных файлов | Текущая директория | +|-------|----------|--------------| +| `--path` | Папка для хранения бинарников | Текущая папка | | `--arch` | Целевая архитектура (amd64, arm64) | Архитектура системы | | `--os` | Целевая ОС (linux, darwin, windows) | Текущая ОС | | `--stability` | Стабильность релиза (stable, beta) | stable | -| `--config` | Путь к файлу конфигурации | ./dload.xml | -| `--force`, `-f` | Принудительная загрузка даже если бинарный файл существует | false | +| `--config` | Путь к конфигурационному файлу | ./dload.xml | +| `--force`, `-f` | Принудительная загрузка даже если бинарник уже есть | false | ### Просмотр ПО ```bash -# Вывести список доступных пакетов ПО +# Показать доступные пакеты ПО ./vendor/bin/dload software # Показать загруженное ПО ./vendor/bin/dload show -# Показать детали определенного ПО +# Показать детали конкретного ПО ./vendor/bin/dload show rr -# Показать все ПО (загруженное и доступное) +# Показать всё ПО (загруженное и доступное) ./vendor/bin/dload show --all ``` +### Сборка кастомного ПО + +```bash +# Собрать кастомное ПО используя конфигурационный файл +./vendor/bin/dload build + +# Собрать с определённым конфигурационным файлом +./vendor/bin/dload build --config=./custom-dload.xml +``` + +#### Опции сборки + +| Опция | Описание | По умолчанию | +|-------|----------|--------------| +| `--config` | Путь к конфигурационному файлу | ./dload.xml | + +Команда `build` выполняет действия сборки, определённые в вашем конфигурационном файле, например создание кастомных бинарников RoadRunner с определёнными плагинами. +Подробную информацию о сборке кастомного RoadRunner смотрите в разделе [Сборка кастомного RoadRunner](#сборка-кастомного-roadrunner). + ## Руководство по конфигурации ### Интерактивная конфигурация -Самый простой способ создать файл конфигурации - использовать интерактивную команду `init`: +Простейший способ создать конфигурационный файл — использовать интерактивную команду `init`: ```bash ./vendor/bin/dload init ``` -Это: +Она: -- Проведет вас через выбор пакетов ПО +- Проведёт вас через выбор пакетов ПО - Покажет доступное ПО с описаниями и репозиториями -- Создаст правильно отформатированный файл `dload.xml` с валидацией схемы -- Корректно обработает существующие файлы конфигурации +- Сгенерирует правильно отформатированный файл `dload.xml` с валидацией схемы +- Аккуратно обработает существующие конфигурационные файлы ### Ручная конфигурация -Создайте `dload.xml` в корне вашего проекта: +Создайте `dload.xml` в корне проекта: ```xml @@ -180,51 +240,51 @@ composer require internal/dload -W ### Типы загрузки -DLoad поддерживает три типа загрузки, которые определяют, как обрабатываются ресурсы: +DLoad поддерживает три типа загрузки, которые определяют способ обработки ресурсов: #### Атрибут типа ```xml - - - + + + - + ``` #### Поведение по умолчанию (тип не указан) -Когда `type` не указан, DLoad автоматически использует все доступные обработчики: +Когда `type` не указан, DLoad автоматически применяет все доступные обработчики: -- **Обработка бинарных файлов**: Если у ПО есть секция ``, выполняет проверку наличия и версии бинарного файла +- **Обработка бинарников**: Если у ПО есть секция ``, выполняет проверку наличия и версии бинарника - **Обработка файлов**: Если у ПО есть секция `` и ресурс загружен, обрабатывает файлы во время распаковки - **Простая загрузка**: Если секций нет, загружает ресурс без распаковки ```xml - + - - + + ``` -#### Поведение явных типов +#### Поведение при явном указании типа -| Тип | Поведение | Случай использования | -|-----------|--------------------------------------------------------------|--------------------------------| -| `binary` | Проверка бинарных файлов, валидация версии, права на выполнение | CLI инструменты, исполняемые файлы | -| `phar` | Загружает `.phar` файлы как исполняемые **без распаковки** | PHP инструменты как Psalm, PHPStan | -| `archive` | **Принудительная распаковка даже для .phar файлов** | Когда нужно содержимое архива | +| Тип | Поведение | Случаи использования | +|-----------|----------------------------------------------------------------|--------------------------------| +| `binary` | Проверка бинарника, валидация версии, права на выполнение | CLI-инструменты, исполняемые файлы | +| `phar` | Загружает `.phar` файлы как исполняемые **без распаковки** | PHP-инструменты вроде Psalm, PHPStan | +| `archive` | **Принудительно распаковывает даже .phar файлы** | Когда нужно содержимое архива | > [!NOTE] -> Используйте `type="phar"` для PHP инструментов, которые должны остаться `.phar` файлами. +> Используйте `type="phar"` для PHP-инструментов, которые должны остаться как `.phar` файлы. > Использование `type="archive"` распакует даже `.phar` архивы. ### Ограничения версий @@ -236,34 +296,131 @@ DLoad поддерживает три типа загрузки, которые - + - + ``` -### Расширенные опции конфигурации +### Расширенные настройки ```xml - + - + ``` +## Сборка кастомного RoadRunner + +DLoad поддерживает сборку кастомных бинарников RoadRunner с помощью инструмента сборки Velox. Это полезно когда нужен RoadRunner с определёнными комбинациями плагинов, которые недоступны в готовых релизах. + +### Настройка действия сборки + +```xml + + + + + + + +``` + +### Атрибуты Velox-действия + +| Атрибут | Описание | +|---------|----------| +| `velox-version` | Ограничение версии для используемого инструмента сборки Velox | +| `golang-version` | Ограничение версии Go, необходимой для сборки RoadRunner | +| `roadrunner-ref` | Git-ссылка RoadRunner (тег, коммит или ветка), используемая как основа для сборки | +| `config-file` | Путь к базовому конфигурационному файлу, который может объединяться с ответами удаленного API или другими источниками | +| `binary-path` | Путь вывода для собранного бинарника RoadRunner. Расширение файла автоматически добавляется в зависимости от ОС (`.exe` для Windows). По умолчанию используется текущий рабочий каталог | +| `debug` | Собрать RoadRunner с отладочными символами для профилирования с помощью pprof (логическое значение, по умолчанию `false`) | + +### Процесс сборки + +DLoad автоматически управляет процессом сборки: + +1. **Проверка Golang**: Проверяет что Go установлен глобально (обязательная зависимость) +2. **Подготовка Velox**: Использует Velox из глобальной установки, локальной загрузки или автоматически скачивает при необходимости +3. **Конфигурация**: Копирует ваш локальный velox.toml в папку сборки +4. **Сборка**: Выполняет команду `vx build` с указанной конфигурацией +5. **Установка**: Перемещает собранный бинарник в целевое расположение и устанавливает права на выполнение +6. **Очистка**: Удаляет временные файлы сборки + +> [!NOTE] +> DLoad требует чтобы Go (Golang) был установлен глобально в вашей системе. Он не скачивает и не управляет установками Go. + +### Генерация конфигурационного файла + +Можно сгенерировать файл конфигурации `velox.toml` с помощью онлайн-билдера на https://build.roadrunner.dev/ + +Подробную документацию по опциям конфигурации Velox и примерам смотрите на https://docs.roadrunner.dev/docs/customization/build + +Этот веб-интерфейс помогает выбрать плагины и генерирует подходящую конфигурацию для вашей кастомной сборки RoadRunner. + +### Использование скачанного Velox + +Можно скачать Velox как часть процесса сборки вместо использования глобально установленной версии: + +```xml + + + + +``` + +Это обеспечивает консистентные версии Velox в разных окружениях и между участниками команды. + +### Конфигурация DLoad + +```xml + + + + + + +``` + +### Сборка RoadRunner + +```bash +# Собрать RoadRunner используя конфигурацию velox.toml +./vendor/bin/dload build + +# Собрать с определённым конфигурационным файлом +./vendor/bin/dload build --config=custom-rr.xml +``` + +Собранный бинарник RoadRunner будет включать только плагины, указанные в вашем файле `velox.toml`, что уменьшает размер бинарника и улучшает производительность для вашего конкретного случая использования. + ## Пользовательский реестр ПО ### Определение ПО @@ -271,7 +428,7 @@ DLoad поддерживает три типа загрузки, которые ```xml - + @@ -280,13 +437,13 @@ DLoad поддерживает три типа загрузки, которые - + - + @@ -294,7 +451,7 @@ DLoad поддерживает три типа загрузки, которые - + @@ -309,21 +466,21 @@ DLoad поддерживает три типа загрузки, которые - **type**: В настоящее время поддерживает "github" - **uri**: Путь репозитория (например, "username/repo") -- **asset-pattern**: Шаблон регулярного выражения для сопоставления ресурсов релиза +- **asset-pattern**: Regex-паттерн для соответствия ресурсам релиза -#### Элементы бинарных файлов +#### Элементы Binary -- **name**: Имя бинарного файла для ссылки -- **pattern**: Шаблон регулярного выражения для сопоставления бинарного файла в ресурсах +- **name**: Имя бинарника для ссылки +- **pattern**: Regex-паттерн для соответствия бинарнику в ресурсах - Автоматически обрабатывает фильтрацию по ОС/архитектуре -#### Элементы файлов +#### Элементы File -- **pattern**: Шаблон регулярного выражения для сопоставления файлов -- **extract-path**: Необязательная директория извлечения +- **pattern**: Regex-паттерн для соответствия файлам +- **extract-path**: Опциональная папка извлечения - Работает на любой системе (без фильтрации по ОС/архитектуре) -## Случаи использования +## Сценарии использования ### Настройка среды разработки @@ -344,7 +501,7 @@ composer require internal/dload -W ./vendor/bin/dload get ``` -### Интеграция CI/CD +### Интеграция с CI/CD ```yaml # GitHub Actions @@ -354,29 +511,29 @@ composer require internal/dload -W ### Кроссплатформенные команды -Каждый разработчик получает правильные бинарные файлы для своей системы: +Каждый разработчик получает правильные бинарники для своей системы: ```xml - - + + ``` -### Управление PHAR инструментами +### Управление PHAR-инструментами ```xml - + - + ``` -### Распространение фронтенд ресурсов +### Распространение фронтенд-ресурсов ```xml @@ -389,9 +546,9 @@ composer require internal/dload -W ``` -## Ограничения API GitHub +## Ограничения GitHub API -Используйте персональный токен доступа, чтобы избежать ограничений скорости: +Используйте персональный токен доступа чтобы избежать ограничений: ```bash GITHUB_TOKEN=your_token_here ./vendor/bin/dload get @@ -399,10 +556,10 @@ GITHUB_TOKEN=your_token_here ./vendor/bin/dload get Добавьте в переменные окружения CI/CD для автоматических загрузок. -## Вклад в проект +## Участие в разработке -Вклады приветствуются! Отправляйте Pull Request для: +Участие приветствуется! Отправляйте Pull Request'ы для: -- Добавления нового ПО в предопределенный реестр -- Улучшения функциональности DLoad -- Улучшения документации и ее перевода на [другие языки](docs/guidelines/how-to-translate-readme-docs.md) +- Добавления нового ПО в предопределённый реестр +- Улучшения функциональности DLoad +- Улучшения документации и перевода на [другие языки](docs/guidelines/how-to-translate-readme-docs.md) diff --git a/README-zh.md b/README-zh.md index e27cfb8..668279b 100644 --- a/README-zh.md +++ b/README-zh.md @@ -14,7 +14,7 @@
-DLoad 简化了为您的项目下载和管理二进制工件的过程。非常适合需要特定工具(如 RoadRunner、Temporal 或自定义二进制文件)的开发环境。 +DLoad 让项目中的二进制工件下载和管理变得简单轻松。它特别适合那些需要特定工具的开发环境,比如 RoadRunner、Temporal 或者自定义二进制文件。 [![English readme](https://img.shields.io/badge/README-English%20%F0%9F%87%BA%F0%9F%87%B8-moccasin?style=flat-square)](README.md) [![Chinese readme](https://img.shields.io/badge/README-%E4%B8%AD%E6%96%87%20%F0%9F%87%A8%F0%9F%87%B3-moccasin?style=flat-square)](README-zh.md) @@ -23,14 +23,51 @@ DLoad 简化了为您的项目下载和管理二进制工件的过程。非常 ## 为什么选择 DLoad? -DLoad 解决了 PHP 项目中的一个常见问题:如何在 PHP 代码的同时分发和安装必要的二进制工具和资产。 -使用 DLoad,您可以: +DLoad 解决了 PHP 项目中的一个实际问题:如何在分发 PHP 代码的同时,有效地分发和安装必要的二进制工具和资产。 +通过 DLoad,你可以: + +- 在项目初始化时自动下载所需工具 +- 确保团队所有成员都用相同版本的工具 +- 通过自动化环境配置简化新人入职流程 +- 无需手动配置就能管理跨平台兼容性 +- 让二进制文件和资产与版本控制保持分离 + +### 目录 + +- [安装](#安装) +- [快速上手](#快速上手) +- [命令行使用](#命令行使用) + - [初始化配置](#初始化配置) + - [下载软件](#下载软件) + - [查看软件](#查看软件) + - [构建自定义软件](#构建自定义软件) +- [配置指南](#配置指南) + - [交互式配置](#交互式配置) + - [手动配置](#手动配置) + - [下载类型](#下载类型) + - [版本约束](#版本约束) + - [高级配置选项](#高级配置选项) +- [构建自定义 RoadRunner](#构建自定义-roadrunner) + - [构建动作配置](#构建动作配置) + - [Velox 动作属性](#velox-动作属性) + - [构建流程](#构建流程) + - [配置文件生成](#配置文件生成) + - [使用下载的 Velox](#使用下载的-velox) + - [DLoad 配置](#dload-配置) + - [构建 RoadRunner](#构建-roadrunner) +- [自定义软件注册表](#自定义软件注册表) + - [定义软件](#定义软件) + - [软件要素](#软件要素) +- [使用场景](#使用场景) + - [开发环境配置](#开发环境配置) + - [新项目创建](#新项目创建) + - [CI/CD 集成](#cicd-集成) + - [跨平台团队协作](#跨平台团队协作) + - [PHAR 工具管理](#phar-工具管理) + - [前端资源分发](#前端资源分发) +- [GitHub API 速率限制](#github-api-速率限制) +- [参与贡献](#参与贡献) -- 在项目初始化期间自动下载所需工具 -- 确保所有团队成员使用相同版本的工具 -- 通过自动化环境设置简化新人入职 -- 管理跨平台兼容性,无需手动配置 -- 将二进制文件和资产与版本控制分开 ## 安装 @@ -43,7 +80,7 @@ composer require internal/dload -W [![License](https://img.shields.io/packagist/l/internal/dload.svg?style=flat-square)](LICENSE.md) [![Total DLoads](https://img.shields.io/packagist/dt/internal/dload.svg?style=flat-square)](https://packagist.org/packages/internal/dload/stats) -## 快速开始 +## 快速上手 1. **通过 Composer 安装 DLoad**: @@ -51,18 +88,21 @@ composer require internal/dload -W composer require internal/dload -W ``` + 你也可以从 [GitHub 发布页面](https://github.com/php-internal/dload/releases) 下载最新版本。 + 2. **交互式创建配置文件**: ```bash ./vendor/bin/dload init ``` - 此命令将指导您选择软件包并创建 `dload.xml` 配置文件。您也可以手动创建: + 这个命令会引导你选择软件包,并创建一个 `dload.xml` 配置文件。当然,你也可以手动创建: ```xml + xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/php-internal/dload/refs/heads/1.x/dload.xsd" + > @@ -70,7 +110,7 @@ composer require internal/dload -W ``` -3. **下载配置的软件**: +3. **下载配置好的软件**: ```bash ./vendor/bin/dload get @@ -81,7 +121,7 @@ composer require internal/dload -W ```json { "scripts": { - "post-update-cmd": "dload get --no-interaction -v || echo can't dload binaries" + "post-update-cmd": "dload get --no-interaction -v || \"echo can't dload binaries\"" } } ``` @@ -97,41 +137,41 @@ composer require internal/dload -W # 在指定位置创建配置 ./vendor/bin/dload init --config=./custom-dload.xml -# 创建最小配置,无提示 +# 无提示创建最简配置 ./vendor/bin/dload init --no-interaction -# 覆盖现有配置而不确认 +# 不经确认就覆盖现有配置 ./vendor/bin/dload init --overwrite ``` ### 下载软件 ```bash -# 从配置文件下载 +# 根据配置文件下载 ./vendor/bin/dload get -# 下载特定包 +# 下载指定软件包 ./vendor/bin/dload get rr temporal -# 使用选项下载 +# 带选项下载 ./vendor/bin/dload get rr --stability=beta --force ``` #### 下载选项 -| 选项 | 描述 | 默认值 | +| 选项 | 说明 | 默认值 | |--------|-------------|---------| -| `--path` | 存储二进制文件的目录 | 当前目录 | +| `--path` | 二进制文件存储目录 | 当前目录 | | `--arch` | 目标架构 (amd64, arm64) | 系统架构 | | `--os` | 目标操作系统 (linux, darwin, windows) | 当前操作系统 | | `--stability` | 发布稳定性 (stable, beta) | stable | | `--config` | 配置文件路径 | ./dload.xml | -| `--force`, `-f` | 即使二进制文件存在也强制下载 | false | +| `--force`, `-f` | 即使二进制文件已存在也强制下载 | false | ### 查看软件 ```bash -# 列出可用的软件包 +# 列出可用软件包 ./vendor/bin/dload software # 显示已下载的软件 @@ -144,21 +184,40 @@ composer require internal/dload -W ./vendor/bin/dload show --all ``` +### 构建自定义软件 + +```bash +# 使用配置文件构建自定义软件 +./vendor/bin/dload build + +# 使用特定配置文件构建 +./vendor/bin/dload build --config=./custom-dload.xml +``` + +#### 构建选项 + +| 选项 | 说明 | 默认值 | +|--------|-------------|---------| +| `--config` | 配置文件路径 | ./dload.xml | + +`build` 命令会执行配置文件中定义的构建动作,比如创建带有特定插件的自定义 RoadRunner 二进制文件。 +想了解构建自定义 RoadRunner 的详细信息,请查看 [构建自定义 RoadRunner](#构建自定义-roadrunner) 部分。 + ## 配置指南 ### 交互式配置 -创建配置文件的最简单方法是使用交互式 `init` 命令: +创建配置文件最简单的方式就是使用交互式 `init` 命令: ```bash ./vendor/bin/dload init ``` -这将: +这个命令会: -- 指导您选择软件包 -- 显示可用软件及其描述和仓库 -- 生成格式正确的 `dload.xml` 文件并进行模式验证 +- 引导你选择软件包 +- 显示可用软件及其说明和仓库信息 +- 生成格式正确并带有模式验证的 `dload.xml` 文件 - 优雅地处理现有配置文件 ### 手动配置 @@ -180,28 +239,28 @@ composer require internal/dload -W ### 下载类型 -DLoad 支持三种下载类型,决定资产的处理方式: +DLoad 支持三种下载类型,它们决定了资源的处理方式: #### 类型属性 ```xml - + - - + + - + ``` #### 默认行为(未指定类型) -当未指定 `type` 时,DLoad 自动使用所有可用的处理器: +当没有指定 `type` 时,DLoad 会自动使用所有可用的处理器: -- **二进制处理**:如果软件有 `` 部分,执行二进制存在性和版本检查 -- **文件处理**:如果软件有 `` 部分且资产已下载,在解包期间处理文件 -- **简单下载**:如果没有部分存在,下载资产而不解包 +- **二进制处理**:如果软件有 `` 部分,会进行二进制存在性和版本检查 +- **文件处理**:如果软件有 `` 部分且资源已下载,会在解包时处理文件 +- **简单下载**:如果没有任何部分,则下载资源但不解包 ```xml @@ -210,26 +269,26 @@ DLoad 支持三种下载类型,决定资产的处理方式:
- - + + ``` -#### 显式类型行为 +#### 明确类型的行为 -| 类型 | 行为 | 用例 | +| 类型 | 行为 | 适用场景 | |-----------|--------------------------------------------------------------|--------------------------------| | `binary` | 二进制检查、版本验证、可执行权限 | CLI 工具、可执行文件 | -| `phar` | 下载 `.phar` 文件作为可执行文件**而不解包** | PHP 工具如 Psalm、PHPStan | -| `archive` | **强制解包即使是 .phar 文件** | 当您需要归档内容时 | +| `phar` | 下载 `.phar` 文件作为可执行文件**但不解包** | PHP 工具如 Psalm、PHPStan | +| `archive` | **强制解包即使是 .phar 文件** | 当你需要压缩包内容时 | > [!NOTE] -> 对于应保持为 `.phar` 文件的 PHP 工具,使用 `type="phar"`。 -> 使用 `type="archive"` 将解包甚至 `.phar` 归档。 +> 对于应该保持为 `.phar` 文件的 PHP 工具,使用 `type="phar"`。 +> 使用 `type="archive"` 会解包甚至 `.phar` 压缩包。 ### 版本约束 -使用 Composer 风格的版本约束: +使用类似 Composer 的版本约束: ```xml @@ -243,7 +302,7 @@ DLoad 支持三种下载类型,决定资产的处理方式: - + ``` @@ -253,7 +312,7 @@ DLoad 支持三种下载类型,决定资产的处理方式: ```xml - + @@ -264,6 +323,103 @@ DLoad 支持三种下载类型,决定资产的处理方式: ``` +## 构建自定义 RoadRunner + +DLoad 支持使用 Velox 构建工具来构建自定义 RoadRunner 二进制文件。当你需要包含特定插件组合的 RoadRunner,而这些组合在预构建版本中不可用时,这功能就很有用了。 + +### 构建动作配置 + +```xml + + + + + + + +``` + +### Velox 动作属性 + +| 属性 | 说明 | +|-----------|-------------| +| `velox-version` | 使用的 Velox 构建工具的版本约束 | +| `golang-version` | 构建 RoadRunner 所需的 Go 版本约束 | +| `roadrunner-ref` | 用作构建基础的 RoadRunner Git 引用(标签、提交或分支) | +| `config-file` | 基础配置文件路径,可能与远程 API 响应或其他源合并 | +| `binary-path` | 构建的 RoadRunner 二进制文件输出路径。文件扩展名根据操作系统自动添加(Windows 下为 `.exe`)。默认为当前工作目录 | +| `debug` | 使用调试符号构建 RoadRunner,以便使用 pprof 进行性能分析(布尔值,默认为 `false`) | + +### 构建流程 + +DLoad 会自动处理构建过程: + +1. **Golang 检查**:验证 Go 是否全局安装(必需依赖) +2. **Velox 准备**:使用全局安装的 Velox、本地下载版本,或者在需要时自动下载 +3. **配置**:将你的本地 velox.toml 复制到构建目录 +4. **构建**:使用指定配置执行 `vx build` 命令 +5. **安装**:将构建好的二进制文件移动到目标位置并设置可执行权限 +6. **清理**:删除临时构建文件 + +> [!NOTE] +> DLoad 需要在你的系统上全局安装 Go (Golang)。它不会下载或管理 Go 的安装。 + +### 配置文件生成 + +你可以使用 https://build.roadrunner.dev/ 上的在线构建器来生成 `velox.toml` 配置文件。 + +关于 Velox 配置选项和示例的详细文档,请访问 https://docs.roadrunner.dev/docs/customization/build + +这个网页界面帮助你选择插件,并为你的自定义 RoadRunner 构建生成合适的配置。 + +### 使用下载的 Velox + +你可以将 Velox 作为构建过程的一部分下载,而不是依赖全局安装的版本: + +```xml + + + + +``` + +这样可以确保在不同环境和团队成员之间使用一致的 Velox 版本。 + +### DLoad 配置 + +```xml + + + + + + +``` + +### 构建 RoadRunner + +```bash +# 使用 velox.toml 配置构建 RoadRunner +./vendor/bin/dload build + +# 使用特定配置文件构建 +./vendor/bin/dload build --config=custom-rr.xml +``` + +构建好的 RoadRunner 二进制文件只会包含你在 `velox.toml` 文件中指定的插件,这样能减少二进制文件大小并提升特定用例的性能。 + ## 自定义软件注册表 ### 定义软件 @@ -279,15 +435,15 @@ DLoad 支持三种下载类型,决定资产的处理方式:
- - + + - - + + @@ -303,41 +459,41 @@ DLoad 支持三种下载类型,决定资产的处理方式:
``` -### 软件元素 +### 软件要素 #### 仓库配置 - **type**:目前支持 "github" -- **uri**:仓库路径(例如,"username/repo") -- **asset-pattern**:匹配发布资产的正则表达式模式 +- **uri**:仓库路径(例如 "username/repo") +- **asset-pattern**:匹配发布资源的正则表达式模式 -#### 二进制元素 +#### 二进制要素 -- **name**:用于引用的二进制名称 -- **pattern**:匹配资产中二进制文件的正则表达式模式 +- **name**:用于引用的二进制文件名 +- **pattern**:匹配资源中二进制文件的正则表达式模式 - 自动处理操作系统/架构过滤 -#### 文件元素 +#### 文件要素 - **pattern**:匹配文件的正则表达式模式 -- **extract-path**:可选的提取目录 -- 在任何系统上工作(无操作系统/架构过滤) +- **extract-path**:可选的解压目录 +- 在任何系统上都能工作(无操作系统/架构过滤) -## 用例 +## 使用场景 -### 开发环境设置 +### 开发环境配置 ```bash -# 新开发者的一次性设置 +# 新开发者的一次性配置 composer install -./vendor/bin/dload init # 仅第一次 +./vendor/bin/dload init # 仅第一次需要 ./vendor/bin/dload get ``` -### 新项目设置 +### 新项目创建 ```bash -# 使用 DLoad 启动新项目 +# 使用 DLoad 开始新项目 composer init composer require internal/dload -W ./vendor/bin/dload init @@ -352,14 +508,14 @@ composer require internal/dload -W run: GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} ./vendor/bin/dload get ``` -### 跨平台团队 +### 跨平台团队协作 -每个开发者获得适合其系统的正确二进制文件: +每个开发者都能获得适合其系统的正确二进制文件: ```xml - - + + ``` @@ -371,12 +527,12 @@ composer require internal/dload -W - + ``` -### 前端资产分发 +### 前端资源分发 ```xml @@ -391,18 +547,18 @@ composer require internal/dload -W ## GitHub API 速率限制 -使用个人访问令牌以避免速率限制: +使用个人访问令牌来避免速率限制: ```bash GITHUB_TOKEN=your_token_here ./vendor/bin/dload get ``` -将其添加到 CI/CD 环境变量中以进行自动下载。 +在 CI/CD 环境变量中添加此配置,以便自动下载。 -## 贡献 +## 参与贡献 -欢迎贡献!提交拉取请求以: +欢迎贡献!你可以提交拉取请求来: - 向预定义注册表添加新软件 -- 改进 DLoad 功能 +- 改进 DLoad 功能 - 增强文档并将其翻译为[其他语言](docs/guidelines/how-to-translate-readme-docs.md) diff --git a/README.md b/README.md index 6a428c0..f3ae368 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,43 @@ With DLoad, you can: - Manage cross-platform compatibility without manual configuration - Keep binaries and assets separate from your version control +### Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Command Line Usage](#command-line-usage) + - [Initialize Configuration](#initialize-configuration) + - [Download Software](#download-software) + - [View Software](#view-software) + - [Build Custom Software](#build-custom-software) +- [Configuration Guide](#configuration-guide) + - [Interactive Configuration](#interactive-configuration) + - [Manual Configuration](#manual-configuration) + - [Download Types](#download-types) + - [Version Constraints](#version-constraints) + - [Advanced Configuration Options](#advanced-configuration-options) +- [Building Custom RoadRunner](#building-custom-roadrunner) + - [Build Action Configuration](#build-action-configuration) + - [Velox Action Attributes](#velox-action-attributes) + - [Build Process](#build-process) + - [Configuration File Generation](#configuration-file-generation) + - [Using Downloaded Velox](#using-downloaded-velox) + - [DLoad Configuration](#dload-configuration) + - [Building RoadRunner](#building-roadrunner) +- [Custom Software Registry](#custom-software-registry) + - [Defining Software](#defining-software) + - [Software Elements](#software-elements) +- [Use Cases](#use-cases) + - [Development Environment Setup](#development-environment-setup) + - [New Project Setup](#new-project-setup) + - [CI/CD Integration](#cicd-integration) + - [Cross-Platform Teams](#cross-platform-teams) + - [PHAR Tools Management](#phar-tools-management) + - [Frontend Asset Distribution](#frontend-asset-distribution) +- [GitHub API Rate Limits](#github-api-rate-limits) +- [Contributing](#contributing) + + ## Installation ```bash @@ -147,6 +184,25 @@ Alternatively, you can download the latest release from [GitHub releases](https: ./vendor/bin/dload show --all ``` +### Build Custom Software + +```bash +# Build custom software using configuration file +./vendor/bin/dload build + +# Build with specific configuration file +./vendor/bin/dload build --config=./custom-dload.xml +``` + +#### Build Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--config` | Path to configuration file | ./dload.xml | + +The `build` command executes build actions defined in your configuration file, such as creating custom RoadRunner binaries with specific plugins. +For detailed information about building custom RoadRunner, see the [Building Custom RoadRunner](#building-custom-roadrunner) section. + ## Configuration Guide ### Interactive Configuration @@ -267,6 +323,107 @@ Use Composer-style version constraints: ``` +## Building Custom RoadRunner + +DLoad supports building custom RoadRunner binaries using the Velox build tool. This is useful when you need RoadRunner with custom plugin combinations that aren't available in pre-built releases. + +### Build Action Configuration + +```xml + + + + + + + + + + + + + +``` + +### Velox Action Attributes + +| Attribute | Description | +|-----------|-------------| +| `velox-version` | Version constraint for the Velox build tool to use | +| `golang-version` | Go version constraint required for building RoadRunner | +| `roadrunner-ref` | RoadRunner Git reference (tag, commit, or branch) to use as the base for building | +| `config-file` | Path to base configuration file that may be merged with remote API responses or other sources | +| `binary-path` | Output path for the built RoadRunner binary. File extension is automatically added based on OS (`.exe` for Windows). Defaults to current working directory | +| `debug` | Build RoadRunner with debug symbols to profile it with pprof (boolean, defaults to `false`) | + +### Build Process + +DLoad automatically handles the build process: + +1. **Golang Check**: Verifies Go is installed globally (required dependency) +2. **Velox Preparation**: Uses Velox from global installation, local download, or downloads automatically if needed +3. **Configuration**: Copies your local velox.toml to build directory +4. **Building**: Executes `vx build` command with specified configuration +5. **Installation**: Moves built binary to target location and sets executable permissions +6. **Cleanup**: Removes temporary build files + +> [!NOTE] +> DLoad requires Go (Golang) to be installed globally on your system. It does not download or manage Go installations. + +### Configuration File Generation + +You can generate a `velox.toml` configuration file using the online builder at https://build.roadrunner.dev/ + +For detailed documentation on Velox configuration options and examples, visit https://docs.roadrunner.dev/docs/customization/build + +This web interface helps you select plugins and generates the appropriate configuration for your custom RoadRunner build. + +### Using Downloaded Velox + +You can download Velox as part of your build process instead of relying on a globally installed version: + +```xml + + + + +``` + +This ensures consistent Velox versions across different environments and team members. + +### DLoad Configuration + +```xml + + + + + + +``` + +### Building RoadRunner + +```bash +# Build RoadRunner using velox.toml configuration +./vendor/bin/dload build + +# Build with specific configuration file +./vendor/bin/dload build --config=custom-rr.xml +``` + ## Custom Software Registry ### Defining Software diff --git a/bin/dload b/bin/dload index 419f400..9eca91d 100644 --- a/bin/dload +++ b/bin/dload @@ -50,6 +50,7 @@ use Symfony\Component\Console\CommandLoader\FactoryCommandLoader; Command\ListSoftware::getDefaultName() => static fn() => new Command\ListSoftware(), Command\Show::getDefaultName() => static fn() => new Command\Show(), Command\Init::getDefaultName() => static fn() => new Command\Init(), + Command\Build::getDefaultName() => static fn() => new Command\Build(), ]), ); $application->setDefaultCommand(Command\Get::getDefaultName(), false); diff --git a/composer.lock b/composer.lock index 347c577..1154bbd 100644 --- a/composer.lock +++ b/composer.lock @@ -650,16 +650,16 @@ }, { "name": "symfony/console", - "version": "v6.4.22", + "version": "v6.4.23", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "7d29659bc3c9d8e9a34e2c3414ef9e9e003e6cf3" + "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/7d29659bc3c9d8e9a34e2c3414ef9e9e003e6cf3", - "reference": "7d29659bc3c9d8e9a34e2c3414ef9e9e003e6cf3", + "url": "https://api.github.com/repos/symfony/console/zipball/9056771b8eca08d026cd3280deeec3cfd99c4d93", + "reference": "9056771b8eca08d026cd3280deeec3cfd99c4d93", "shasum": "" }, "require": { @@ -724,7 +724,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.22" + "source": "https://github.com/symfony/console/tree/v6.4.23" }, "funding": [ { @@ -740,7 +740,7 @@ "type": "tidelift" } ], - "time": "2025-05-07T07:05:04+00:00" + "time": "2025-06-27T19:37:22+00:00" }, { "name": "symfony/deprecation-contracts", @@ -811,16 +811,16 @@ }, { "name": "symfony/http-client", - "version": "v6.4.19", + "version": "v6.4.23", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "3294a433fc9d12ae58128174896b5b1822c28dad" + "reference": "19f11e742b94dcfd968a54f5381bb9082a88cb57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/3294a433fc9d12ae58128174896b5b1822c28dad", - "reference": "3294a433fc9d12ae58128174896b5b1822c28dad", + "url": "https://api.github.com/repos/symfony/http-client/zipball/19f11e742b94dcfd968a54f5381bb9082a88cb57", + "reference": "19f11e742b94dcfd968a54f5381bb9082a88cb57", "shasum": "" }, "require": { @@ -884,7 +884,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.19" + "source": "https://github.com/symfony/http-client/tree/v6.4.23" }, "funding": [ { @@ -900,7 +900,7 @@ "type": "tidelift" } ], - "time": "2025-02-13T09:55:13+00:00" + "time": "2025-06-27T20:02:31+00:00" }, { "name": "symfony/http-client-contracts", @@ -3121,58 +3121,59 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.75.0", + "version": "v3.82.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "399a128ff2fdaf4281e4e79b755693286cdf325c" + "reference": "684ed3ab41008a2a4848de8bde17eb168c596247" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/399a128ff2fdaf4281e4e79b755693286cdf325c", - "reference": "399a128ff2fdaf4281e4e79b755693286cdf325c", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/684ed3ab41008a2a4848de8bde17eb168c596247", + "reference": "684ed3ab41008a2a4848de8bde17eb168c596247", "shasum": "" }, "require": { "clue/ndjson-react": "^1.0", "composer/semver": "^3.4", - "composer/xdebug-handler": "^3.0.3", + "composer/xdebug-handler": "^3.0.5", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", "ext-tokenizer": "*", "fidry/cpu-core-counter": "^1.2", "php": "^7.4 || ^8.0", - "react/child-process": "^0.6.5", + "react/child-process": "^0.6.6", "react/event-loop": "^1.0", - "react/promise": "^2.0 || ^3.0", + "react/promise": "^2.11 || ^3.0", "react/socket": "^1.0", "react/stream": "^1.0", - "sebastian/diff": "^4.0 || ^5.1 || ^6.0 || ^7.0", - "symfony/console": "^5.4 || ^6.4 || ^7.0", - "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", - "symfony/finder": "^5.4 || ^6.4 || ^7.0", - "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", - "symfony/polyfill-mbstring": "^1.31", - "symfony/polyfill-php80": "^1.31", - "symfony/polyfill-php81": "^1.31", - "symfony/process": "^5.4 || ^6.4 || ^7.2", - "symfony/stopwatch": "^5.4 || ^6.4 || ^7.0" + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", + "symfony/console": "^5.4.45 || ^6.4.13 || ^7.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.13 || ^7.0", + "symfony/filesystem": "^5.4.45 || ^6.4.13 || ^7.0", + "symfony/finder": "^5.4.45 || ^6.4.17 || ^7.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.16 || ^7.0", + "symfony/polyfill-mbstring": "^1.32", + "symfony/polyfill-php80": "^1.32", + "symfony/polyfill-php81": "^1.32", + "symfony/process": "^5.4.47 || ^6.4.20 || ^7.2", + "symfony/stopwatch": "^5.4.45 || ^6.4.19 || ^7.0" }, "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.6", "infection/infection": "^0.29.14", - "justinrainbow/json-schema": "^5.3 || ^6.2", - "keradus/cli-executor": "^2.1", + "justinrainbow/json-schema": "^5.3 || ^6.4", + "keradus/cli-executor": "^2.2", "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.7", + "php-coveralls/php-coveralls": "^2.8", "php-cs-fixer/accessible-object": "^1.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", - "phpunit/phpunit": "^9.6.22 || ^10.5.45 || ^11.5.12", - "symfony/var-dumper": "^5.4.48 || ^6.4.18 || ^7.2.3", - "symfony/yaml": "^5.4.45 || ^6.4.18 || ^7.2.3" + "phpunit/phpunit": "^9.6.23 || ^10.5.47 || ^11.5.25", + "symfony/polyfill-php84": "^1.32", + "symfony/var-dumper": "^5.4.48 || ^6.4.23 || ^7.3.1", + "symfony/yaml": "^5.4.45 || ^6.4.23 || ^7.3.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -3213,7 +3214,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.75.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.82.2" }, "funding": [ { @@ -3221,7 +3222,7 @@ "type": "github" } ], - "time": "2025-03-31T18:40:42+00:00" + "time": "2025-07-08T21:13:15+00:00" }, { "name": "kelunik/certificate", @@ -3457,16 +3458,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.1", + "version": "1.13.3", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", "shasum": "" }, "require": { @@ -3505,7 +3506,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" }, "funding": [ { @@ -3513,7 +3514,7 @@ "type": "tidelift" } ], - "time": "2025-04-29T12:36:36+00:00" + "time": "2025-07-05T12:25:42+00:00" }, { "name": "netresearch/jsonmapper", @@ -4441,16 +4442,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.47", + "version": "10.5.48", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3" + "reference": "6e0a2bc39f6fae7617989d690d76c48e6d2eb541" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3", - "reference": "3637b3e50d32ab3a0d1a33b3b6177169ec3d95a3", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6e0a2bc39f6fae7617989d690d76c48e6d2eb541", + "reference": "6e0a2bc39f6fae7617989d690d76c48e6d2eb541", "shasum": "" }, "require": { @@ -4460,7 +4461,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.1", + "myclabs/deep-copy": "^1.13.3", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -4522,7 +4523,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.47" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.48" }, "funding": [ { @@ -4546,7 +4547,7 @@ "type": "tidelift" } ], - "time": "2025-06-20T11:29:11+00:00" + "time": "2025-07-11T04:07:17+00:00" }, { "name": "psr/event-dispatcher", @@ -6801,16 +6802,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.21", + "version": "v6.4.23", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "22560f80c0c5cd58cc0bcaf73455ffd81eb380d5" + "reference": "d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/22560f80c0c5cd58cc0bcaf73455ffd81eb380d5", - "reference": "22560f80c0c5cd58cc0bcaf73455ffd81eb380d5", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600", + "reference": "d55b1834cdbfcc31bc2cd7e095ba5ed9a88f6600", "shasum": "" }, "require": { @@ -6866,7 +6867,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.21" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.23" }, "funding": [ { @@ -6882,7 +6883,7 @@ "type": "tidelift" } ], - "time": "2025-04-09T07:34:50+00:00" + "time": "2025-06-27T15:05:27+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -6995,16 +6996,16 @@ }, { "name": "vimeo/psalm", - "version": "6.12.0", + "version": "6.12.1", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "cf420941d061a57050b6c468ef2c778faf40aee2" + "reference": "e71404b0465be25cf7f8a631b298c01c5ddd864f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/cf420941d061a57050b6c468ef2c778faf40aee2", - "reference": "cf420941d061a57050b6c468ef2c778faf40aee2", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/e71404b0465be25cf7f8a631b298c01c5ddd864f", + "reference": "e71404b0465be25cf7f8a631b298c01c5ddd864f", "shasum": "" }, "require": { @@ -7109,7 +7110,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2025-05-28T12:52:06+00:00" + "time": "2025-07-04T09:56:28+00:00" }, { "name": "webmozart/assert", diff --git a/dload.xsd b/dload.xsd index 5a69122..a73eac3 100644 --- a/dload.xsd +++ b/dload.xsd @@ -58,11 +58,78 @@ + + + + Velox build action for creating custom RoadRunner binaries + + + + + + Plugin to include in the RoadRunner build + + + + + Plugin name (required) + + + + + Plugin version constraint + + + + + Repository owner/organization + + + + + Repository name + + + + + + + + Version constraint for velox build tool + + + + + Required Go version constraint + + + + + RoadRunner Git reference (tag, commit, or branch) to use for building + + + + + Path to local velox.toml file + + + + + Path to the RoadRunner binary to build + + + + + Build RoadRunner with debug symbols to profile it with pprof + + + + @@ -185,4 +252,4 @@ - \ No newline at end of file + diff --git a/docs/guidelines/how-to-translate-readme-docs.md b/docs/guidelines/how-to-translate-readme-docs.md index 5d4de57..d92874a 100644 --- a/docs/guidelines/how-to-translate-readme-docs.md +++ b/docs/guidelines/how-to-translate-readme-docs.md @@ -28,6 +28,7 @@ I need a single message with all the md file content. - Command examples and their arguments: Verify technical accuracy and formatting. - Technical terminology consistency throughout the document. - Cultural adaptations that make sense in target language context. + - **Language naturalness**: Ensure the translation uses natural, living language that doesn't sound synthetic or machine-generated, while maintaining technical precision. Find the golden mean between conversational flow and technical accuracy. **Step 5: Finalize and Save the Translated Document 💾** @@ -91,6 +92,7 @@ CONTRIBUTING-de.md - Use clear and specific prompts to minimize errors. - Always double-check technical content, as LLMs may mistranslate code or markup. +- **Prioritize natural language flow**: Avoid overly literal translations that sound robotic. The text should read as if written by a native speaker while preserving technical accuracy. - Consider using frameworks like MAPS (Multi-Aspect Prompting and Selection) for complex translations, which guide the LLM through keywords, topics, and relevant examples to improve accuracy and reduce errors. - Remember: Human review is essential for catching subtle mistakes and ensuring the translation meets your quality standards. diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 05d441f..0f1aded 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -79,9 +79,11 @@ - - - + + + + binary]]> + @@ -125,15 +127,28 @@ - + + + + + ]]> + - - - - + + + + + + + + + + + + @@ -149,9 +164,15 @@ + + + + + + factory]]> @@ -162,6 +183,9 @@ cache]]> + + injector->invoke([$binding, 'create'])]]> + @@ -172,26 +196,6 @@ - - - - - - - - - - - - - - - - - - - - @@ -315,11 +319,6 @@ && \is_string($decoded[1])]]> - - - - - @@ -361,15 +360,6 @@ - - - - - getRepository()->getName(), - $this->getVersion(), - ]]]> - assets as $assetDTO) { @@ -393,18 +383,49 @@ - - - - - - isSatisfiedBy($this->version)]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/psalm.xml b/psalm.xml index 212781f..9885caa 100644 --- a/psalm.xml +++ b/psalm.xml @@ -12,6 +12,11 @@ + + + + + diff --git a/resources/software.json b/resources/software.json index 7cba844..a95149c 100644 --- a/resources/software.json +++ b/resources/software.json @@ -19,6 +19,22 @@ } ] }, + { + "name": "Velox", + "alias": "velox", + "binary": { + "name": "vx", + "version-command": "--version" + }, + "homepage": "https://roadrunner.dev", + "description": "Automated build system for the RoadRunner with custom plugins", + "repositories": [ + { + "type": "github", + "uri": "roadrunner-server/velox" + } + ] + }, { "name": "Temporal", "alias": "temporal", diff --git a/src/Bootstrap.php b/src/Bootstrap.php index a3a80ef..a85759c 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -15,6 +15,10 @@ use Internal\DLoad\Module\HttpClient\Internal\NyholmFactoryImpl; use Internal\DLoad\Module\Repository\Internal\GitHub\Factory as GithubRepositoryFactory; use Internal\DLoad\Module\Repository\RepositoryProvider; +use Internal\DLoad\Module\Velox\ApiClient; +use Internal\DLoad\Module\Velox\Builder; +use Internal\DLoad\Module\Velox\Internal\Client\BuildRoadRunner; +use Internal\DLoad\Module\Velox\Internal\VeloxBuilder; use Internal\DLoad\Service\Container; /** @@ -104,14 +108,10 @@ public function withConfig( static fn(Container $container): RepositoryProvider => (new RepositoryProvider()) ->addRepositoryFactory($container->get(GithubRepositoryFactory::class)), ); - $this->container->bind( - BinaryProvider::class, - static fn(Container $c): BinaryProvider => $c->get(BinaryProviderImpl::class), - ); - $this->container->bind( - Factory::class, - static fn(Container $c): Factory => $c->get(NyholmFactoryImpl::class), - ); + $this->container->bind(BinaryProvider::class, BinaryProviderImpl::class); + $this->container->bind(Factory::class, NyholmFactoryImpl::class); + $this->container->bind(Builder::class, VeloxBuilder::class); + $this->container->bind(ApiClient::class, BuildRoadRunner::class); return $this; } diff --git a/src/Command/Build.php b/src/Command/Build.php new file mode 100644 index 0000000..3d72474 --- /dev/null +++ b/src/Command/Build.php @@ -0,0 +1,133 @@ +container->get(StyleInterface::class); + + /** @var Actions $actionsConfig */ + $actionsConfig = $this->container->get(Actions::class); + + if ($actionsConfig->veloxBuilds === []) { + $style->warning('No build actions found in configuration file.'); + $style->text('Add actions to your dload.xml to build custom binaries.'); + return Command::SUCCESS; + } + + /** @var Builder $builder */ + $builder = $this->container->get(Builder::class); + + /** @var list $actions */ + $actions = []; + foreach ($actionsConfig->veloxBuilds as $veloxAction) { + $actions[] = $this->prepareBuildAction($builder, $veloxAction, static fn(Progress $progress) => null); + } + + await(all($actions)); + + \count($actions) > 1 and $this->logger->info('All build actions completed.'); + return Command::SUCCESS; + } + + /** + * Executes a single Velox build action. + * + * @param \Closure(Progress): void $onProgress Callback to report progress + * @return PromiseInterface Promise that resolves to the build result or null if no binary was built + */ + private function prepareBuildAction( + Builder $builder, + VeloxAction $veloxAction, + \Closure $onProgress, + ): PromiseInterface { + $task = $builder->build($veloxAction, $onProgress); + + $this->logger->info('Starting build: %s', $task->name); + + // Execute the build + return $task->execute()->then(onRejected: fn(\Throwable $e) => $this->processException($e, $task)); + } + + /** + * Processes exceptions that occur during the build process. + * + * This method can be overridden to handle specific exceptions + * or perform additional logging. + * + * @param \Throwable $e The exception that occurred + * @param Task $task The task that was being executed when the exception occurred + */ + private function processException(\Throwable $e, Task $task): void + { + $this->logger->error('Build task failed: %s', $task->name); + + if ($e instanceof ConfigException) { + $this->logger->error('Configuration error: %s', $e->getMessage()); + return; + } + + if ($e instanceof DependencyException) { + $this->logger->error('Dependency error: %s', $e->getMessage()); + return; + } + + if ($e instanceof BuildException) { + $this->logger->error('Build error: %s', $e->getMessage()); + if ($e->buildOutput !== null) { + $this->logger->info('Build Output:'); + $this->logger->print($e->buildOutput); + } + + return; + } + + $this->logger->exception($e); + } +} diff --git a/src/Command/Show.php b/src/Command/Show.php index 4e4829f..3e9bf93 100644 --- a/src/Command/Show.php +++ b/src/Command/Show.php @@ -103,7 +103,7 @@ private function listAllSoftware( continue; } - $binary = $binaryProvider->getBinary($destinationPath, $software->binary); + $binary = $binaryProvider->getLocalBinary($destinationPath, $software->binary, $software->name); if ($binary === null) { continue; } @@ -146,7 +146,7 @@ private function listAllSoftware( continue; } - $binary = $binaryProvider->getBinary($destinationPath, $software->binary); + $binary = $binaryProvider->getLocalBinary($destinationPath, $software->binary, $software->name); if ($binary === null) { continue; } @@ -224,8 +224,7 @@ private function showSoftwareDetails( return Command::FAILURE; } - $destinationPath = \getcwd(); - + $destinationPath = Path::create(\getcwd() ?: '.'); // Check if software is in project config $inConfig = false; @@ -278,7 +277,7 @@ private function showSoftwareDetails( } // Binary information - $binary = $binaryProvider->getBinary($destinationPath, $software->binary); + $binary = $binaryProvider->getLocalBinary($destinationPath, $software->binary, $software->name); $this->displayBinaryDetails($binary, $output); diff --git a/src/DLoad.php b/src/DLoad.php index c8fc892..2fa4229 100644 --- a/src/DLoad.php +++ b/src/DLoad.php @@ -6,24 +6,28 @@ use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Binary\BinaryProvider; +use Internal\DLoad\Module\Common\DloadResult; +use Internal\DLoad\Module\Common\FileSystem\FS; use Internal\DLoad\Module\Common\FileSystem\Path; use Internal\DLoad\Module\Common\Input\Destination; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Config\Schema\Action\Download as DownloadConfig; use Internal\DLoad\Module\Config\Schema\Action\Type; +use Internal\DLoad\Module\Config\Schema\Embed\Binary as BinaryConfig; use Internal\DLoad\Module\Config\Schema\Embed\File; use Internal\DLoad\Module\Config\Schema\Embed\Software; use Internal\DLoad\Module\Downloader\Downloader; use Internal\DLoad\Module\Downloader\SoftwareCollection; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Downloader\Task\DownloadTask; -use Internal\DLoad\Module\Downloader\TaskManager; +use Internal\DLoad\Module\Task\Manager; use Internal\DLoad\Module\Version\Constraint; use Internal\DLoad\Module\Version\Version; use Internal\DLoad\Service\Logger; use React\Promise\PromiseInterface; use Symfony\Component\Console\Output\OutputInterface; +use function React\Async\await; use function React\Promise\resolve; /** @@ -33,9 +37,9 @@ * based on configuration actions. * * ```php - * $dload = $container->get(DLoad::class); - * $dload->addTask(new DownloadConfig('rr', '^2.12.0')); - * $dload->run(); + * $dload = $container->get(DLoad::class); + * $dload->addTask(new DownloadConfig('rr', '^2.12.0')); + * $dload->run(); * ``` * * @internal @@ -47,7 +51,7 @@ final class DLoad public function __construct( private readonly Logger $logger, - private readonly TaskManager $taskManager, + private readonly Manager $taskManager, private readonly SoftwareCollection $softwareCollection, private readonly Downloader $downloader, private readonly ArchiveFactory $archiveFactory, @@ -65,9 +69,12 @@ public function __construct( * * @param DownloadConfig $action Download configuration action * @param bool $force Whether to force download even if binary exists + * + * @return PromiseInterface Resolves after the download task is finished. + * * @throws \RuntimeException When software package is not found */ - public function addTask(DownloadConfig $action, bool $force = false): void + public function addTask(DownloadConfig $action, bool $force = false): PromiseInterface { // Find Software $software = $this->softwareCollection->findSoftware($action->software) ?? throw new \RuntimeException( @@ -80,7 +87,7 @@ public function addTask(DownloadConfig $action, bool $force = false): void if (!$force && ($type === null || $type === Type::Binary) && $software->binary !== null) { // Check different constraints - $binary = $this->binaryProvider->getBinary($destinationPath, $software->binary); + $binary = $this->binaryProvider->getLocalBinary($destinationPath, $software->binary, $software->name); if ($binary === null) { goto add_task; @@ -97,7 +104,7 @@ public function addTask(DownloadConfig $action, bool $force = false): void $this->logger->info('Use flag `--force` to force download.'); // Skip task creation entirely - return; + return resolve(DloadResult::fromBinary($binary)); } // Create VersionConstraint DTO for enhanced constraint checking @@ -115,7 +122,7 @@ public function addTask(DownloadConfig $action, bool $force = false): void $this->logger->info('Use flag `--force` to force download.'); // Skip task creation entirely - return; + return resolve(DloadResult::fromBinary($binary)); } // Download a newer version only if the version is specified @@ -126,12 +133,16 @@ public function addTask(DownloadConfig $action, bool $force = false): void add_task: - $this->taskManager->addTask(function () use ($software, $action): void { + return $this->taskManager->addTask(function () use ($software, $action): DloadResult { // Create a Download task $task = $this->prepareDownloadTask($software, $action); // Extract files - ($task->handler)()->then($this->prepareExtractTask($software, $action)); + $extraction = ($task->handler)()->then( + fn(DownloadResult $result): DloadResult => $this->prepareExtractTask($result, $software, $action), + ); + + return await($extraction); }); } @@ -175,85 +186,111 @@ private function prepareDownloadTask(Software $software, DownloadConfig $action) * * @param Software $software Software package configuration * @param DownloadConfig $action Download action configuration - * @return \Closure(DownloadResult): void Function that extracts files from the downloaded archive + * @return DloadResult Result of the extraction process containing extracted files and binary */ - private function prepareExtractTask(Software $software, DownloadConfig $action): \Closure - { - return function (DownloadResult $downloadResult) use ($software, $action): void { - $fileInfo = $downloadResult->file; - $tempFilePath = $fileInfo->getRealPath() ?: $fileInfo->getPathname(); - - try { - // Create a copy of the files list with binary included if necessary - $files = $this->filesToExtract($software, $action); - - // Create destination directory if it doesn't exist - $path = $this->getDestinationPath($action); - if (!\is_dir((string) $path)) { - $this->logger->info('Creating directory %s', (string) $path); - @\mkdir((string) $path, 0755, true); - } + private function prepareExtractTask( + DownloadResult $downloadResult, + Software $software, + DownloadConfig $action, + ): DloadResult { + $fileInfo = $downloadResult->file; + $tempFilePath = Path::create($fileInfo->getRealPath() ?: $fileInfo->getPathname()); + $resultFiles = []; + $resultBinary = null; + + try { + # Create destination directory if it doesn't exist + $destination = $this->getDestinationPath($action); + FS::mkdir($destination); + + # In PHAR actions, we do not extract files, just copy the downloaded file + if ($action->type === Type::Phar) { + $this->logger->debug( + 'Copying downloaded file `%s` to destination as a PHAR archive.', + $fileInfo->getFilename(), + ); + $toFile = $destination->join($fileInfo->getFilename()); + FS::moveFile($tempFilePath, $toFile); + \chmod((string) $toFile, 0o755); - // If no extraction rules are defined, do not extract anything - // and just copy the file to the destination - if ($files === []) { - $this->logger->debug( - 'No files to extract for `%s`, copying the downloaded file to the destination.', - $fileInfo->getFilename(), - ); - $toFile = (string) $path->join($fileInfo->getFilename()); - \copy($tempFilePath, $toFile); - - $action->type === Type::Phar and \chmod($toFile, 0o755); - return; + # todo: add PHAR binary to result + return new DloadResult([$toFile]); + } + + # If no extraction rules are defined, do not extract anything + # and just copy the file to the destination + if ($software->files === [] && $software->binary === null) { + $this->logger->debug( + 'No files to extract for `%s`, copying the downloaded file to the destination.', + $fileInfo->getFilename(), + ); + $toFile = $destination->join($fileInfo->getFilename()); + FS::moveFile($tempFilePath, $toFile); + + return new DloadResult([$toFile]); + } + + $archive = $this->archiveFactory->create($fileInfo); + $extractor = $archive->extract(); + $this->logger->info('Extracting %s', $fileInfo->getFilename()); + $binaryPattern = $this->generateBinaryExtractionConfig($software->binary); + + while ($extractor->valid()) { + $to = $rule = null; + $file = $extractor->current(); + \assert($file instanceof \SplFileInfo); + + # Check if it's binary and should be extracted + $isBinary = false; + if ($binaryPattern !== null) { + [$to, $rule] = $this->shouldBeExtracted($file, [$binaryPattern], $destination); + $isBinary = $to !== null; } - $archive = $this->archiveFactory->create($fileInfo); - $extractor = $archive->extract(); - $this->logger->info('Extracting %s', $fileInfo->getFilename()); - - while ($extractor->valid()) { - $file = $extractor->current(); - \assert($file instanceof \SplFileInfo); - - [$to, $rule] = $this->shouldBeExtracted($file, $files, $action); - $this->logger->debug( - $to === null ? 'Skipping %s%s' : 'Extracting %s to %s', - $file->getFilename(), - (string) $to?->getPathname(), - ); - - if ($to === null) { - $extractor->next(); - continue; - } - - $isOverwriting = $to->isFile(); - $extractor->send($to); - - // Success - $path = $to->getRealPath() ?: $to->getPathname(); - $this->output->writeln( - \sprintf( - '%s (%s) has been %sinstalled into %s', - $to->getFilename(), - $downloadResult->version, - $isOverwriting ? 're' : '', - $path, - ), - ); - - \assert($rule !== null); - $rule->chmod === null or @\chmod($path, $rule->chmod); + isset($to) or [$to, $rule] = $this->shouldBeExtracted($file, $software->files, $destination); + if ($to === null) { + $this->logger->debug('Skipping file `%s`.', $file->getFilename()); + $extractor->next(); + continue; } - } finally { - // Cleanup: Delete the temporary downloaded file - if (!$this->useMock && \file_exists($tempFilePath)) { - $this->logger->debug('Cleaning up temporary file: %s', $tempFilePath); - @\unlink($tempFilePath); + + $this->logger->debug('Extracting %s to %s...', $file->getFilename(), $to->getPathname()); + + $isOverwriting = $to->isFile(); + $extractor->send($to); + + // Success + $path = $to->getRealPath() ?: $to->getPathname(); + $this->output->writeln( + \sprintf( + '%s (%s) has been %sinstalled into %s', + $to->getFilename(), + $downloadResult->version, + $isOverwriting ? 're' : '', + $path, + ), + ); + + \assert(isset($rule)); + $rule->chmod === null or @\chmod($path, $rule->chmod); + + # Add files and binary to result + $path = Path::create($path); + $resultFiles[] = $path; + if ($isBinary) { + $resultBinary = $this->binaryProvider->getLocalBinary($path->parent(), $software->binary); + $binaryPattern = null; } } - }; + + return new DloadResult($resultFiles, $resultBinary); + } finally { + // Cleanup: Delete the temporary downloaded file + if (!$this->useMock && $tempFilePath->exists()) { + $this->logger->debug('Cleaning up temporary file: %s', $tempFilePath->__toString()); + FS::remove($tempFilePath); + } + } } /** @@ -261,15 +298,13 @@ private function prepareExtractTask(Software $software, DownloadConfig $action): * * @param \SplFileInfo $source Source file from the archive * @param list $mapping File mapping configurations - * @param DownloadConfig $action Download action configuration - * @return array{\SplFileInfo|null, null|File} Array containing: + * @param Path $path Destination path where files should be extracted + * @return array{\SplFileInfo, File}|array{null, null} Array containing: * - Target file path or null if file should not be extracted * - File configuration that matched the source file, or null if no match found */ - private function shouldBeExtracted(\SplFileInfo $source, array $mapping, DownloadConfig $action): array + private function shouldBeExtracted(\SplFileInfo $source, array $mapping, Path $path): array { - $path = $this->getDestinationPath($action); - foreach ($mapping as $conf) { if (\preg_match($conf->pattern, $source->getFilename())) { $newName = match (true) { @@ -296,25 +331,23 @@ private function getDestinationPath(DownloadConfig $action): Path } /** - * @return list + * Generates a binary extraction configuration based on the provided binary configuration. + * + * @param BinaryConfig|null $binary Binary configuration object + * @return File|null File extraction configuration or null if no binary is provided */ - private function filesToExtract(Software $software, DownloadConfig $action): array + private function generateBinaryExtractionConfig(?BinaryConfig $binary): ?File { - // Don't extract files for Phar actions - if ($action->type === Type::Phar) { - return []; + if ($binary === null) { + return null; } - $files = $software->files; - if ($software->binary !== null) { - $binary = new File(); - $binary->pattern = $software->binary->pattern - ?? "/^{$software->binary->name}{$this->os->getBinaryExtension()}$/"; - $binary->rename = $software->binary->name; - $binary->chmod = 0o755; // Default permissions for binaries - $files[] = $binary; - } + $result = new File(); + $result->pattern = $binary->pattern + ?? "/^{$binary->name}{$this->os->getBinaryExtension()}$/"; + $result->rename = $binary->name; + $result->chmod = 0o755; // Default permissions for binaries - return $files; + return $result; } } diff --git a/src/Module/Binary/Binary.php b/src/Module/Binary/Binary.php index dff2afa..ce4f54c 100644 --- a/src/Module/Binary/Binary.php +++ b/src/Module/Binary/Binary.php @@ -4,6 +4,7 @@ namespace Internal\DLoad\Module\Binary; +use Internal\DLoad\Module\Binary\Exception\BinaryExecutionException; use Internal\DLoad\Module\Common\FileSystem\Path; /** @@ -52,4 +53,14 @@ public function getSize(): ?int; * @return \DateTimeImmutable|null Modification time or null if the binary doesn't exist */ public function getMTime(): ?\DateTimeImmutable; + + /** + * Executes the binary with the given string input. + * + * @param string ...$args Arguments to pass to the binary + * @return list Output from the binary execution + * + * @throws BinaryExecutionException If the binary execution returns a non-zero exit code + */ + public function execute(string ...$args): array; } diff --git a/src/Module/Binary/BinaryProvider.php b/src/Module/Binary/BinaryProvider.php index 4850036..35fd975 100644 --- a/src/Module/Binary/BinaryProvider.php +++ b/src/Module/Binary/BinaryProvider.php @@ -17,7 +17,21 @@ interface BinaryProvider * * @param Path|non-empty-string $destinationPath Directory path where binary should exist * @param BinaryConfig $config Binary configuration + * @param non-empty-string|null $name Software name to use in the binary instance. + * It's not required to match the binary name. + * + * @return Binary|null Binary instance or null if it doesn't exist + */ + public function getLocalBinary(Path|string $destinationPath, BinaryConfig $config, ?string $name = null): ?Binary; + + /** + * Gets a globally available binary by its configuration. + * + * @param BinaryConfig $config Binary configuration + * @param non-empty-string|null $name Software name to use in the binary instance. + * It's not required to match the binary name. + * * @return Binary|null Binary instance or null if it doesn't exist */ - public function getBinary(Path|string $destinationPath, BinaryConfig $config): ?Binary; + public function getGlobalBinary(BinaryConfig $config, ?string $name = null): ?Binary; } diff --git a/src/Module/Binary/BinaryVersion.php b/src/Module/Binary/BinaryVersion.php index 75e80db..e4e44e3 100644 --- a/src/Module/Binary/BinaryVersion.php +++ b/src/Module/Binary/BinaryVersion.php @@ -21,7 +21,7 @@ final class BinaryVersion extends Version /** * Resolves the version from binary command output. * - * @param non-empty-string $output Output from binary execution + * @param string $output Output from binary execution * @return BinaryVersion Extracted version */ public static function fromBinaryOutput(string $output): static diff --git a/src/Module/Binary/Internal/BinaryHandle.php b/src/Module/Binary/Internal/AbstractBinary.php similarity index 70% rename from src/Module/Binary/Internal/BinaryHandle.php rename to src/Module/Binary/Internal/AbstractBinary.php index 86e5b7e..70c6063 100644 --- a/src/Module/Binary/Internal/BinaryHandle.php +++ b/src/Module/Binary/Internal/AbstractBinary.php @@ -9,26 +9,19 @@ use Internal\DLoad\Module\Common\FileSystem\Path; use Internal\DLoad\Module\Config\Schema\Embed\Binary as BinaryConfig; -/** - * Internal implementation of Binary interface. - * - * @internal - */ -final class BinaryHandle implements Binary +abstract class AbstractBinary implements Binary { private ?BinaryVersion $versionOutput = null; /** * @param non-empty-string $name Binary name - * @param Path $path Path to binary * @param BinaryConfig $config Original configuration * @param BinaryExecutor $executor Binary execution service */ public function __construct( - private readonly string $name, - private readonly Path $path, - private readonly BinaryConfig $config, - private readonly BinaryExecutor $executor, + protected readonly string $name, + protected readonly BinaryConfig $config, + protected readonly BinaryExecutor $executor, ) {} public function getName(): string @@ -36,37 +29,16 @@ public function getName(): string return $this->name; } - public function getPath(): Path + public function execute(string ...$args): array { - return $this->path; - } - - public function exists(): bool - { - return $this->path->exists(); - } - - public function getVersion(): ?BinaryVersion - { - if ($this->versionOutput !== null) { - return $this->versionOutput; - } + $args = \array_map(static fn(string $arg): string => \escapeshellarg($arg), $args); - if (!$this->exists() || $this->config->versionCommand === null) { - return null; - } - - try { - $output = $this->executor->execute($this->path, $this->config->versionCommand); - return $this->versionOutput = BinaryVersion::fromBinaryOutput($output); - } catch (\Throwable) { - return $this->versionOutput = BinaryVersion::empty(); - } + return $this->executor->execute($this->getPath(), \implode(' ', $args)); } - public function getVersionString(): ?string + public function exists(): bool { - return $this->getVersion()?->number; + return $this->getPath()->exists(); } public function getSize(): ?int @@ -75,7 +47,7 @@ public function getSize(): ?int return null; } - $size = \filesize((string) $this->path); + $size = \filesize((string) $this->getPath()); return $size === false ? null : $size; } @@ -85,7 +57,7 @@ public function getMTime(): ?\DateTimeImmutable return null; } - $mtime = \filemtime((string) $this->path); + $mtime = \filemtime((string) $this->getPath()); if ($mtime === false) { return null; } @@ -96,4 +68,29 @@ public function getMTime(): ?\DateTimeImmutable return null; } } + + public function getVersion(): ?BinaryVersion + { + if ($this->versionOutput !== null) { + return $this->versionOutput; + } + + if (!$this->exists() || $this->config->versionCommand === null) { + return null; + } + + try { + $output = $this->executor->execute($this->getPath(), $this->config->versionCommand); + return $this->versionOutput = BinaryVersion::fromBinaryOutput(\implode("\n", $output)); + } catch (\Throwable) { + return $this->versionOutput = BinaryVersion::empty(); + } + } + + public function getVersionString(): ?string + { + return $this->getVersion()?->number; + } + + abstract public function getPath(): Path; } diff --git a/src/Module/Binary/Internal/BinaryExecutor.php b/src/Module/Binary/Internal/BinaryExecutor.php index 5a37bb2..ddb2084 100644 --- a/src/Module/Binary/Internal/BinaryExecutor.php +++ b/src/Module/Binary/Internal/BinaryExecutor.php @@ -6,6 +6,7 @@ use Internal\DLoad\Module\Binary\Exception\BinaryExecutionException; use Internal\DLoad\Module\Common\FileSystem\Path; +use Internal\DLoad\Service\Logger; /** * Executes binary commands and captures their output. @@ -14,25 +15,32 @@ */ final class BinaryExecutor { + public function __construct( + private readonly Logger $logger, + ) {} + /** * Executes a binary with the specified command and returns the output. * * @param Path $binaryPath Full path to binary executable * @param string $command Command argument(s) to execute - * @return string Command output + * @return list Command output * @throws BinaryExecutionException If execution fails */ - public function execute(Path $binaryPath, string $command): string + public function execute(Path $binaryPath, string $command): array { // Escape command for shell execution $escapedPath = \escapeshellarg((string) $binaryPath); - // Execute the command and capture output + /** @var list $output */ $output = []; $returnCode = 0; + $cmd = "$escapedPath $command 2>&1"; + $this->logger->debug('Executing command: %s', $cmd); + // Execute with both stdout and stderr redirected to output - \exec("$escapedPath $command 2>&1", $output, $returnCode); + \exec($cmd, $output, $returnCode); // If command failed, throw exception if ($returnCode !== 0) { @@ -48,6 +56,6 @@ public function execute(Path $binaryPath, string $command): string } // Return combined output - return \implode("\n", $output); + return $output; } } diff --git a/src/Module/Binary/Internal/BinaryProviderImpl.php b/src/Module/Binary/Internal/BinaryProviderImpl.php index 1c1398f..39a613d 100644 --- a/src/Module/Binary/Internal/BinaryProviderImpl.php +++ b/src/Module/Binary/Internal/BinaryProviderImpl.php @@ -22,15 +22,27 @@ public function __construct( private readonly BinaryExecutor $executor, ) {} - public function getBinary(Path|string $destinationPath, BinaryConfig $config): ?Binary + public function getLocalBinary(Path|string $destinationPath, BinaryConfig $config, ?string $name = null): ?Binary { // Get binary path $binaryPath = $this->buildBinaryPath($destinationPath, $config); // Create binary instance - $binary = new BinaryHandle( - name: $config->name, + $binary = new LocalBinary( + name: $name ?? $config->name, + config: $config, + executor: $this->executor, path: $binaryPath, + ); + + // Return binary only if it exists + return $binary->exists() ? $binary : null; + } + + public function getGlobalBinary(BinaryConfig $config, ?string $name = null): ?Binary + { + $binary = new GlobalBinary( + name: $name ?? $config->name, config: $config, executor: $this->executor, ); diff --git a/src/Module/Binary/Internal/GlobalBinary.php b/src/Module/Binary/Internal/GlobalBinary.php new file mode 100644 index 0000000..6a97d57 --- /dev/null +++ b/src/Module/Binary/Internal/GlobalBinary.php @@ -0,0 +1,85 @@ +resolvePath(); + return $this->resolvedPath ?? throw new \RuntimeException("Can't resolve path for binary `{$this->name}`"); + } + + /** + * Resolves the binary path from system PATH environment variable. + */ + private function resolvePath(): void + { + if ($this->resolvedPath !== null) { + return; + } + + try { + $binaryPath = $this->findBinaryInPath($this->name); + $this->resolvedPath = $binaryPath !== null ? Path::create($binaryPath) : null; + } catch (\Throwable) { + $this->resolvedPath = null; + } + } + + /** + * Finds binary executable in system PATH. + * + * @param non-empty-string $binaryName Binary name to find + * @return non-empty-string|null Full path to binary or null if not found + */ + private function findBinaryInPath(string $binaryName): ?string + { + $isWindows = \PHP_OS_FAMILY === 'Windows'; + $command = $isWindows ? 'where' : 'which'; + + // Escape binary name for shell execution + $escapedBinaryName = \escapeshellarg($binaryName); + + // Execute command to find binary + $output = []; + $returnCode = 0; + + \exec("$command $escapedBinaryName 2>&1", $output, $returnCode); + + if ($returnCode !== 0 || $output === []) { + return null; + } + + // Return first found path (most relevant) + $binaryPath = \trim($output[0]); + return $binaryPath !== '' ? $binaryPath : null; + } +} diff --git a/src/Module/Binary/Internal/LocalBinary.php b/src/Module/Binary/Internal/LocalBinary.php new file mode 100644 index 0000000..633a1f4 --- /dev/null +++ b/src/Module/Binary/Internal/LocalBinary.php @@ -0,0 +1,39 @@ +path = $path->absolute(); + parent::__construct($name, $config, $executor); + } + + public function getPath(): Path + { + return $this->path; + } +} diff --git a/src/Module/Common/DloadResult.php b/src/Module/Common/DloadResult.php new file mode 100644 index 0000000..1256f5d --- /dev/null +++ b/src/Module/Common/DloadResult.php @@ -0,0 +1,33 @@ + $files All the downloaded files. + * @param Binary|null $binary Optional binary file if available. + */ + public function __construct( + public readonly array $files, + public readonly ?Binary $binary = null, + ) {} + + public static function empty(): self + { + return new self([]); + } + + public static function fromBinary(Binary $binary): self + { + return new self([$binary->getPath()], $binary); + } +} diff --git a/src/Module/Common/FileSystem/FS.php b/src/Module/Common/FileSystem/FS.php new file mode 100644 index 0000000..c7febab --- /dev/null +++ b/src/Module/Common/FileSystem/FS.php @@ -0,0 +1,125 @@ +join($sub); + $result->exists() or self::mkdir((string) $result); + + return $result; + } + + /** + * Removes a file or directory. + * + * @param Path $path Path to the file or directory to remove + * + * @throws \RuntimeException If the file or directory could not be removed + */ + public static function remove(Path $path): bool + { + return !$path->exists() or match (true) { + $path->isFile() => self::removeFile($path), + $path->isDir() => self::removeDir($path), + default => throw new \RuntimeException("Path `{$path->absolute()}` is neither a file nor a directory."), + }; + } + + /** + * Removes a file. + * + * @param Path $path Path to the file to remove + * + * @throws \RuntimeException If the file could not be removed + */ + public static function removeFile(Path $path): bool + { + return \unlink($path->__toString()); + } + + /** + * Removes a directory and all its contents. + * + * @param Path $path Path to the directory to remove + * @param bool $recursive Whether to remove the directory recursively (default: true) + * + * @throws \RuntimeException If the directory could not be removed + */ + public static function removeDir(Path $path, bool $recursive = true): bool + { + if (!$path->exists()) { + return true; + } + + if ($recursive) { + /** @var \DirectoryIterator $item */ + foreach (new \DirectoryIterator($path->__toString()) as $item) { + $item->isDot() or self::remove(Path::create($item->getPathname())); + } + } + + return \rmdir($path->__toString()); + } + + /** + * Moves a file from one path to another. + * + * @param Path $from Source file path + * @param Path $to Destination file path + * @param bool $overwrite Whether to overwrite the destination file if it exists (default: false) + * + * @throws \RuntimeException If the move operation fails + */ + public static function moveFile(Path $from, Path $to, bool $overwrite = false): bool + { + if ($from->absolute() === $to->absolute()) { + return true; // No need to move if paths are the same + } + + if ($to->exists()) { + !$overwrite and throw new \RuntimeException( + "Failed to move file from `{$from}` to `{$to}`: target file already exists.", + ); + + self::remove($to); + } + + return \rename($from->__toString(), $to->__toString()); + } +} diff --git a/src/Module/Common/FileSystem/Path.php b/src/Module/Common/FileSystem/Path.php index 89113e0..57447ef 100644 --- a/src/Module/Common/FileSystem/Path.php +++ b/src/Module/Common/FileSystem/Path.php @@ -71,6 +71,8 @@ public function name(): string /** * Return the file stem (the file name without its extension) + * + * @return non-empty-string */ public function stem(): string { @@ -194,15 +196,20 @@ public function absolute(): self return self::create($cwd . self::DS . $this->path); } + /** + * Return a normalized relative version of this path. + * + * @return non-empty-string + */ public function __toString(): string { return $this->path; } /** - * Check if a path is absolute + * Check if a path is absolute. * - * @param non-empty-string $path A normalized path + * @param non-empty-string $path A normalized path. */ private static function _isAbsolute(string $path): bool { @@ -210,7 +217,7 @@ private static function _isAbsolute(string $path): bool } /** - * Normalize a path by converting directory separators and resolving special path segments + * Normalize a path by converting directory separators and resolving special path segments. * * @return non-empty-string */ diff --git a/src/Module/Common/Internal/ObjectContainer.php b/src/Module/Common/Internal/ObjectContainer.php index eeac057..7d95962 100644 --- a/src/Module/Common/Internal/ObjectContainer.php +++ b/src/Module/Common/Internal/ObjectContainer.php @@ -24,7 +24,7 @@ final class ObjectContainer implements Container, ContainerInterface /** @var array */ private array $cache = []; - /** @var array */ + /** @var array */ private array $factory = []; private readonly Injector $injector; @@ -89,10 +89,21 @@ public function make(string $class, array $arguments = []): object /** * @template T * @param class-string $id Service identifier - * @param null|array|\Closure(Container): T $binding Factory function or constructor arguments + * @param null|class-string|array|\Closure(mixed ...): T $binding */ - public function bind(string $id, \Closure|array|null $binding = null): void + public function bind(string $id, \Closure|string|array|null $binding = null): void { + if (\is_string($binding)) { + \class_exists($binding) or throw new \InvalidArgumentException( + "Class `$binding` does not exist.", + ); + + /** @var class-string $binding */ + $binding = \is_a($binding, Factoriable::class, true) + ? fn(): object => $this->injector->invoke([$binding, 'create']) + : fn(): object => $this->injector->make($binding); + } + if ($binding !== null) { $this->factory[$id] = $binding; return; @@ -102,7 +113,7 @@ public function bind(string $id, \Closure|array|null $binding = null): void "Class `$id` must have a factory or be a factory itself and implement `Factoriable`.", ); - /** @var T $object */ + /** @var \Closure(mixed ...): T $object */ $object = $id::create(...); $this->factory[$id] = $object; } diff --git a/src/Module/Config/Schema/Action/Velox.php b/src/Module/Config/Schema/Action/Velox.php new file mode 100644 index 0000000..f8c8929 --- /dev/null +++ b/src/Module/Config/Schema/Action/Velox.php @@ -0,0 +1,57 @@ + + * 2. Remote API config: + * 3. Mixed approach: local base + additional plugins via API + * + * @internal + * @link https://docs.roadrunner.dev/docs/customization/build + */ +final class Velox +{ + /** @var non-empty-string|null $veloxVersion Version constraint for velox build tool */ + #[XPath('@velox-version')] + public ?string $veloxVersion = null; + + /** @var non-empty-string|null $golangVersion Required Go version constraint */ + #[XPath('@golang-version')] + public ?string $golangVersion = null; + + /** + * @var non-empty-string|null $roadrunnerVersion RoadRunner Git reference (tag, commit, or branch) + * to use for building. + */ + #[XPath('@roadrunner-ref')] + public ?string $roadrunnerVersion = null; + + /** @var non-empty-string|null $configFile Path to local velox.toml file */ + #[XPath('@config-file')] + public ?string $configFile = null; + + /** @var list $plugins List of plugins to include in build */ + #[XPathEmbedList('plugin', Plugin::class)] + public array $plugins = []; + + /** @var non-empty-string|null $binaryPath Path to the RoadRunner binary to build */ + #[XPath('@binary-path')] + public ?string $binaryPath = null; + + /** @var bool $debug Build RoadRunner with debug symbols to profile it with pprof */ + #[XPath('@debug')] + public bool $debug = false; +} diff --git a/src/Module/Config/Schema/Action/Velox/Plugin.php b/src/Module/Config/Schema/Action/Velox/Plugin.php new file mode 100644 index 0000000..a69f978 --- /dev/null +++ b/src/Module/Config/Schema/Action/Velox/Plugin.php @@ -0,0 +1,37 @@ + + * - With version: + * - Full specification: + * + * @internal + */ +final class Plugin +{ + /** @var non-empty-string $name Plugin name (required) */ + #[XPath('@name')] + public string $name; + + /** @var non-empty-string|null $version Plugin version constraint */ + #[XPath('@version')] + public ?string $version = null; + + /** @var non-empty-string|null $owner Repository owner/organization */ + #[XPath('@owner')] + public ?string $owner = null; + + /** @var non-empty-string|null $repository Repository name */ + #[XPath('@repository')] + public ?string $repository = null; +} diff --git a/src/Module/Config/Schema/Actions.php b/src/Module/Config/Schema/Actions.php index 7290da9..3c72b3b 100644 --- a/src/Module/Config/Schema/Actions.php +++ b/src/Module/Config/Schema/Actions.php @@ -6,11 +6,13 @@ use Internal\DLoad\Module\Common\Internal\Attribute\XPathEmbedList; use Internal\DLoad\Module\Config\Schema\Action\Download; +use Internal\DLoad\Module\Config\Schema\Action\Velox; /** * Configuration actions container. * - * Contains the list of download actions defined in the configuration file. + * Contains the list of actions defined in the configuration file, + * including both download and build actions. * * @internal */ @@ -19,4 +21,8 @@ final class Actions /** @var list $downloads Collection of download actions */ #[XPathEmbedList('/dload/actions/download', Download::class)] public array $downloads = []; + + /** @var list $veloxBuilds Collection of velox build actions */ + #[XPathEmbedList('/dload/actions/velox', Velox::class)] + public array $veloxBuilds = []; } diff --git a/src/Module/Config/Schema/Downloader.php b/src/Module/Config/Schema/Downloader.php index b18f7d3..220473e 100644 --- a/src/Module/Config/Schema/Downloader.php +++ b/src/Module/Config/Schema/Downloader.php @@ -13,7 +13,7 @@ */ final class Downloader { - /** @var string|null $tmpDir Temporary directory for downloads */ + /** @var non-empty-string|null $tmpDir Temporary directory for downloads */ #[XPath('/dload/@temp-dir')] public ?string $tmpDir = null; } diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index 4d304c7..0858f33 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -6,6 +6,7 @@ use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Common\Architecture; +use Internal\DLoad\Module\Common\FileSystem\FS; use Internal\DLoad\Module\Common\FileSystem\Path; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; @@ -23,6 +24,7 @@ use Internal\DLoad\Module\Repository\ReleaseInterface; use Internal\DLoad\Module\Repository\Repository; use Internal\DLoad\Module\Repository\RepositoryProvider; +use Internal\DLoad\Module\Task\Progress; use Internal\DLoad\Module\Version\Constraint; use Internal\DLoad\Service\Destroyable; use Internal\DLoad\Service\Logger; @@ -418,7 +420,7 @@ private function processAsset(DownloadContext $context): \Closure */ private function getTempDirectory(): Path { - $temp = Path::create($this->config->tmpDir ?? \sys_get_temp_dir()); + $temp = FS::tmpDir($this->config->tmpDir); $temp->exists() or \mkdir((string) $temp, recursive: true); $temp->isDir() && $temp->isWriteable() or throw new \LogicException( diff --git a/src/Module/Downloader/Internal/DownloadContext.php b/src/Module/Downloader/Internal/DownloadContext.php index 5ea76e1..eaa3576 100644 --- a/src/Module/Downloader/Internal/DownloadContext.php +++ b/src/Module/Downloader/Internal/DownloadContext.php @@ -8,9 +8,9 @@ use Internal\DLoad\Module\Config\Schema\Action\Download as DownloadConfig; use Internal\DLoad\Module\Config\Schema\Embed\Repository; use Internal\DLoad\Module\Config\Schema\Embed\Software; -use Internal\DLoad\Module\Downloader\Progress; use Internal\DLoad\Module\Repository\AssetInterface; use Internal\DLoad\Module\Repository\ReleaseInterface; +use Internal\DLoad\Module\Task\Progress; /** * Context object for download operations. diff --git a/src/Module/Downloader/SoftwareCollection.php b/src/Module/Downloader/SoftwareCollection.php index e322fcb..f5c7ea9 100644 --- a/src/Module/Downloader/SoftwareCollection.php +++ b/src/Module/Downloader/SoftwareCollection.php @@ -7,7 +7,6 @@ use Internal\DLoad\Info; use Internal\DLoad\Module\Config\Schema\CustomSoftwareRegistry; use Internal\DLoad\Module\Config\Schema\Embed\Software; -use IteratorAggregate; /** * Collection of software package configurations. @@ -15,7 +14,7 @@ * Manages both custom and default software registry entries. * Provides lookup functionality to find software by name or alias. * - * @implements IteratorAggregate + * @implements \IteratorAggregate */ final class SoftwareCollection implements \IteratorAggregate, \Countable { diff --git a/src/Module/Downloader/Task/DownloadTask.php b/src/Module/Downloader/Task/DownloadTask.php index 433e5c7..eac10a6 100644 --- a/src/Module/Downloader/Task/DownloadTask.php +++ b/src/Module/Downloader/Task/DownloadTask.php @@ -5,7 +5,7 @@ namespace Internal\DLoad\Module\Downloader\Task; use Internal\DLoad\Module\Config\Schema\Embed\Software; -use Internal\DLoad\Module\Downloader\Progress; +use Internal\DLoad\Module\Task\Progress; use React\Promise\PromiseInterface; /** diff --git a/src/Module/Downloader/TaskManager.php b/src/Module/Task/Manager.php similarity index 74% rename from src/Module/Downloader/TaskManager.php rename to src/Module/Task/Manager.php index 1fc8bc7..2be8c98 100644 --- a/src/Module/Downloader/TaskManager.php +++ b/src/Module/Task/Manager.php @@ -2,9 +2,11 @@ declare(strict_types=1); -namespace Internal\DLoad\Module\Downloader; +namespace Internal\DLoad\Module\Task; use Internal\DLoad\Service\Logger; +use React\Promise\Deferred; +use React\Promise\PromiseInterface; /** * Task Manager Service @@ -27,9 +29,9 @@ * $taskManager->await(); * ``` */ -final class TaskManager +final class Manager { - /** @var array<\Fiber> Active fiber tasks */ + /** @var array Active fiber tasks */ private array $tasks = []; /** @@ -44,11 +46,17 @@ public function __construct( /** * Adds a new task to the execution queue * - * @param \Closure $callback Task implementation + * @template TResult + * + * @param \Closure(): TResult $callback Task implementation + * + * @return PromiseInterface */ - public function addTask(\Closure $callback): void + public function addTask(\Closure $callback): PromiseInterface { - $this->tasks[] = new \Fiber($callback); + $deferred = new Deferred(); + $this->tasks[] = [new \Fiber($callback), $deferred]; + return $deferred->promise(); } /** @@ -66,10 +74,15 @@ public function getProcessor(): \Generator return; } - foreach ($this->tasks as $key => $task) { + /** + * @var \Fiber $task + * @var Deferred $deferred + */ + foreach ($this->tasks as $key => [$task, $deferred]) { try { if ($task->isTerminated()) { unset($this->tasks[$key]); + $deferred->resolve($task->getReturn()); continue; } @@ -83,6 +96,7 @@ public function getProcessor(): \Generator $this->logger->error($e->getMessage()); $this->logger->exception($e); unset($this->tasks[$key]); + $deferred->reject($e); yield $e; } } diff --git a/src/Module/Downloader/Progress.php b/src/Module/Task/Progress.php similarity index 93% rename from src/Module/Downloader/Progress.php rename to src/Module/Task/Progress.php index 1d9bb8b..89df577 100644 --- a/src/Module/Downloader/Progress.php +++ b/src/Module/Task/Progress.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Internal\DLoad\Module\Downloader; +namespace Internal\DLoad\Module\Task; /** * Represents download progress information. diff --git a/src/Module/Velox/ApiClient.php b/src/Module/Velox/ApiClient.php new file mode 100644 index 0000000..b07388f --- /dev/null +++ b/src/Module/Velox/ApiClient.php @@ -0,0 +1,45 @@ + $plugins List of plugins to include + * @param string|null $golangVersion Go version constraint + * @param string|null $binaryVersion RoadRunner version (reference) + * @param array $options Additional configuration options + * @return string Generated velox.toml content + * @throws Exception\Api When API request fails + * @throws Exception\Config When generated config is invalid + */ + public function generateConfig( + array $plugins, + ?string $golangVersion = null, + ?string $binaryVersion = null, + array $options = [], + ): string; + + /** + * Retrieves available plugin information from the API. + * + * @param non-empty-string|null $search Optional search term + * @return array Available plugins + * @throws Exception\Api When API request fails + */ + public function getAvailablePlugins(?string $search = null): array; +} diff --git a/src/Module/Velox/Builder.php b/src/Module/Velox/Builder.php new file mode 100644 index 0000000..bce0ede --- /dev/null +++ b/src/Module/Velox/Builder.php @@ -0,0 +1,33 @@ + \array_map(static fn(Plugin $plugin): string => $plugin->name, $plugins), + 'format' => 'toml', + ]; + + $golangVersion !== null and $requestData['golang_version'] = $golangVersion; + $binaryVersion !== null and $requestData['roadrunner_ref'] = $binaryVersion; + $options === [] or $requestData = \array_merge($requestData, $options); + + return $this->makeRequest( + 'POST', + '/plugins/generate-config', + $requestData, + ); + } + + public function getAvailablePlugins(?string $search = null): array + { + $query = []; + $search !== null and $query['search'] = $search; + + $response = $this->makeRequest('GET', '/plugins', query: $query); + + return \json_decode($response, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @param non-empty-string $method + * @param non-empty-string $endpoint + * @param array $data + * @param array $query + * @throws Api + */ + private function makeRequest( + string $method, + string $endpoint, + array $data = [], + array $query = [], + ): string { + try { + $uri = $this->httpFactory->uri(self::API_BASE_URL . $endpoint, $query); + $request = $this->httpFactory->request($method, $uri, [ + 'Accept' => 'text/plain', + 'Content-Type' => 'application/json', + ]); + + if ($data !== []) { + $body = $this->httpFactory->request('POST', '')->getBody(); + $body->write(\json_encode($data, JSON_THROW_ON_ERROR)); + $request = $request->withBody($body); + } + + $response = $this->httpFactory->client()->sendRequest($request); + + return $this->handleResponse($response); + } catch (\Throwable $e) { + throw new Api("API request failed: {$e->getMessage()}", 0, $e); + } + } + + /** + * @throws Api + */ + private function handleResponse(ResponseInterface $response): string + { + $statusCode = $response->getStatusCode(); + + if ($statusCode < 200 || $statusCode >= 300) { + throw new Api("API request failed with status {$statusCode}: {$response->getBody()->getContents()}"); + } + + return $response->getBody()->getContents(); + } +} diff --git a/src/Module/Velox/Internal/Config/ConfigPipelineBuilder.php b/src/Module/Velox/Internal/Config/ConfigPipelineBuilder.php new file mode 100644 index 0000000..c720ad4 --- /dev/null +++ b/src/Module/Velox/Internal/Config/ConfigPipelineBuilder.php @@ -0,0 +1,50 @@ + 1. RemoteAPI -> 2. LocalFile -> 3. BuildMixins -> 4. GitHubToken + * + * @internal + * @psalm-internal Internal\DLoad\Module\Velox + */ +final class ConfigPipelineBuilder +{ + /** + * @param list> $pipes Processors to be used in the pipeline + */ + public function __construct( + private readonly Container $container, + private readonly array $pipes = [ + BaseTemplateProcessor::class, + RemoteApiProcessor::class, + LocalFileProcessor::class, + BuildMixinsProcessor::class, + GitHubTokenProcessor::class, + ], + ) {} + + public function build(): ConfigPipeline + { + $processors = \array_map( + fn(string $pipe): ConfigProcessor => $this->container->get($pipe), + $this->pipes, + ); + + return new ConfigPipeline($processors); + } +} diff --git a/src/Module/Velox/Internal/Config/Pipeline/ConfigContext.php b/src/Module/Velox/Internal/Config/Pipeline/ConfigContext.php new file mode 100644 index 0000000..2558e80 --- /dev/null +++ b/src/Module/Velox/Internal/Config/Pipeline/ConfigContext.php @@ -0,0 +1,83 @@ + $metadata Additional metadata collected during pipeline processing + */ + public function __construct( + public readonly VeloxAction $action, + public readonly Path $buildDir, + public readonly TomlData $tomlData = new TomlData(), + public readonly array $metadata = [], + ) {} + + /** + * Creates a new context with updated TOML data. + * + * This method returns a new immutable instance with the specified TOML data, + * preserving all other context properties (action, build directory, and metadata). + * + * @param TomlData $tomlData The new TOML configuration data + * @return self A new context instance with the updated TOML data + */ + public function withTomlData(TomlData $tomlData): self + { + return new self($this->action, $this->buildDir, $tomlData, $this->metadata); + } + + /** + * Creates a new context with completely replaced metadata. + * + * This method returns a new immutable instance with the specified metadata array, + * completely replacing any existing metadata. To add individual entries while + * preserving existing metadata, use addMetadata() instead. + * + * @param array $metadata The complete metadata array to set + * @return self A new context instance with the replaced metadata + */ + public function withMetadata(array $metadata): self + { + return new self($this->action, $this->buildDir, $this->tomlData, $metadata); + } + + /** + * Adds a single metadata entry to the context. + * + * This method returns a new immutable instance with the specified key-value pair + * added to the metadata, preserving all existing metadata entries. If the key + * already exists, its value will be overwritten. + * + * @param non-empty-string $key The metadata key to add or update + * @param mixed $value The metadata value to associate with the key + * @return self A new context instance with the updated metadata + */ + public function addMetadata(string $key, mixed $value): self + { + $metadata = $this->metadata; + /** @var mixed */ + $metadata[$key] = $value; + return $this->withMetadata($metadata); + } +} diff --git a/src/Module/Velox/Internal/Config/Pipeline/ConfigPipeline.php b/src/Module/Velox/Internal/Config/Pipeline/ConfigPipeline.php new file mode 100644 index 0000000..81d4495 --- /dev/null +++ b/src/Module/Velox/Internal/Config/Pipeline/ConfigPipeline.php @@ -0,0 +1,34 @@ + $processors + */ + public function __construct( + private readonly array $processors, + ) {} + + public function process(ConfigContext $context): ConfigContext + { + return \array_reduce( + $this->processors, + static fn(ConfigContext $ctx, ConfigProcessor $processor): ConfigContext => $processor($ctx), + $context, + ); + } +} diff --git a/src/Module/Velox/Internal/Config/Pipeline/ConfigProcessor.php b/src/Module/Velox/Internal/Config/Pipeline/ConfigProcessor.php new file mode 100644 index 0000000..3b988ab --- /dev/null +++ b/src/Module/Velox/Internal/Config/Pipeline/ConfigProcessor.php @@ -0,0 +1,26 @@ + [ + 'level' => 'debug', + 'mode' => 'dev', + ], + 'debug' => [ + 'enabled ' => false, + ], + ]); + + return $context->withTomlData($baseTemplate) + ->addMetadata('base_template_applied', true); + } +} diff --git a/src/Module/Velox/Internal/Config/Pipeline/Processor/BuildMixinsProcessor.php b/src/Module/Velox/Internal/Config/Pipeline/Processor/BuildMixinsProcessor.php new file mode 100644 index 0000000..020737a --- /dev/null +++ b/src/Module/Velox/Internal/Config/Pipeline/Processor/BuildMixinsProcessor.php @@ -0,0 +1,37 @@ +tomlData; + $appliedMixins = []; + + if ($context->action->roadrunnerVersion !== null) { + $tomlData = $tomlData->set('roadrunner.ref', $context->action->roadrunnerVersion); + $appliedMixins[] = 'roadrunner_ref'; + } + + $tomlData = $tomlData->set('debug.enabled', $context->action->debug); + $appliedMixins[] = 'debug_enabled'; + + return $context->withTomlData($tomlData) + ->addMetadata('build_mixins_applied', true) + ->addMetadata('applied_mixins', $appliedMixins); + } +} diff --git a/src/Module/Velox/Internal/Config/Pipeline/Processor/GitHubTokenProcessor.php b/src/Module/Velox/Internal/Config/Pipeline/Processor/GitHubTokenProcessor.php new file mode 100644 index 0000000..5117607 --- /dev/null +++ b/src/Module/Velox/Internal/Config/Pipeline/Processor/GitHubTokenProcessor.php @@ -0,0 +1,39 @@ +gitHub->token === null) { + return $context; + } + + $tomlData = $context->tomlData->set('github.token.token', $this->gitHub->token); + + return $context + ->withTomlData($tomlData) + ->addMetadata('github_token_applied', true); + } +} diff --git a/src/Module/Velox/Internal/Config/Pipeline/Processor/LocalFileProcessor.php b/src/Module/Velox/Internal/Config/Pipeline/Processor/LocalFileProcessor.php new file mode 100644 index 0000000..e23fb5a --- /dev/null +++ b/src/Module/Velox/Internal/Config/Pipeline/Processor/LocalFileProcessor.php @@ -0,0 +1,52 @@ +configFile is not null. + * + * @internal + * @psalm-internal Internal\DLoad\Module\Velox + */ +final class LocalFileProcessor implements ConfigProcessor +{ + public function __invoke(ConfigContext $context): ConfigContext + { + // Early return if no config file + if ($context->action->configFile === null) { + return $context; + } + + $configPath = Path::create($context->action->configFile); + + if (!\file_exists($configPath->__toString())) { + throw new ConfigException( + "Local config file not found: {$configPath}", + ); + } + + $localToml = @\file_get_contents($configPath->__toString()); + + $localToml === false and throw new ConfigException( + "Failed to read local config file: {$configPath}.", + ); + + $localData = TomlData::fromString($localToml); + $mergedData = $context->tomlData->merge($localData); + + return $context->withTomlData($mergedData) + ->addMetadata('local_file_applied', true) + ->addMetadata('local_file_path', $configPath->__toString()); + } +} diff --git a/src/Module/Velox/Internal/Config/Pipeline/Processor/RemoteApiProcessor.php b/src/Module/Velox/Internal/Config/Pipeline/Processor/RemoteApiProcessor.php new file mode 100644 index 0000000..610923a --- /dev/null +++ b/src/Module/Velox/Internal/Config/Pipeline/Processor/RemoteApiProcessor.php @@ -0,0 +1,47 @@ +plugins is not empty. + * + * @internal + * @psalm-internal Internal\DLoad\Module\Velox + */ +final class RemoteApiProcessor implements ConfigProcessor +{ + public function __construct( + private readonly ApiClient $apiClient, + ) {} + + public function __invoke(ConfigContext $context): ConfigContext + { + // Early return if no plugins + if ($context->action->plugins === []) { + return $context; + } + + $apiToml = $this->apiClient->generateConfig( + $context->action->plugins, + $context->action->golangVersion, + $context->action->roadrunnerVersion, + ); + + $apiData = TomlData::fromString($apiToml); + $mergedData = $context->tomlData->merge($apiData); + + return $context->withTomlData($mergedData) + ->addMetadata('remote_api_applied', true) + ->addMetadata('plugin_count', \count($context->action->plugins)); + } +} diff --git a/src/Module/Velox/Internal/Config/Pipeline/TomlData.php b/src/Module/Velox/Internal/Config/Pipeline/TomlData.php new file mode 100644 index 0000000..d0a68a0 --- /dev/null +++ b/src/Module/Velox/Internal/Config/Pipeline/TomlData.php @@ -0,0 +1,499 @@ + $data The configuration data array + */ + public function __construct( + private readonly array $data = [], + ) {} + + /** + * Creates a new TOML data instance from a TOML string. + * + * Parses the provided TOML content and returns a new immutable instance + * containing the parsed configuration data. + * + * @param string $toml The TOML content to parse + * @return self A new TomlData instance with the parsed data + */ + public static function fromString(string $toml): self + { + $instance = new self(); + $data = $instance->parseToml($toml); + return new self($data); + } + + /** + * Merges remote TOML configuration into local base configuration. + * + * @param string $localToml Base TOML configuration + * @param string $remoteToml Remote TOML configuration with plugins + * @return string Merged TOML configuration + */ + public static function mergeTomlStrings(string $localToml, string $remoteToml): string + { + $local = self::fromString($localToml); + $remote = self::fromString($remoteToml); + return $local->merge($remote)->toToml(); + } + + /** + * Merges another TOML data instance into this one. + * + * Performs a deep merge where arrays from the other instance are recursively + * merged with arrays in this instance. Non-array values from the other instance + * will overwrite values in this instance. + * + * @param TomlData $other The TOML data to merge into this instance + * @return self A new TomlData instance with the merged data + */ + public function merge(TomlData $other): self + { + $merged = $this->deepMerge($this->data, $other->data); + return new self($merged); + } + + /** + * Sets a nested value using dot notation path. + * + * Creates a new immutable instance with the specified value set at the given path. + * The path uses dot notation to access nested array keys (e.g., "debug.enabled"). + * If intermediate keys don't exist, they will be created as arrays. + * + * @param string $path The dot-notation path to the value (e.g., "roadrunner.ref") + * @param mixed $value The value to set at the specified path + * @return self A new TomlData instance with the updated value + */ + public function set(string $path, mixed $value): self + { + $data = $this->data; + $this->setNestedValue($data, $path, $value); + return new self($data); + } + + /** + * Converts the internal data array to TOML string format. + * + * This is the main public API for serializing TOML data back to string format. + * Delegates to the internal arrayToToml method for the actual conversion logic. + * + * @return string The TOML-formatted string representation of the data + */ + public function toToml(): string + { + return $this->arrayToToml($this->data); + } + + /** + * Returns the raw configuration data array. + * + * Provides direct access to the internal data structure for cases where + * array manipulation is needed instead of TOML string operations. + * + * @return array The internal data array + */ + public function getData(): array + { + return $this->data; + } + + /** + * Simple TOML parser for basic configuration merging. + * + * @param string $toml TOML content + * @return array Parsed configuration + */ + private function parseToml(string $toml): array + { + $result = []; + $currentSection = null; // Track the current section context (e.g., "roadrunner" or "github.plugins") + $lines = \explode("\n", $toml); + + foreach ($lines as $line) { + $line = \trim($line); + + // Skip empty lines and comments (lines starting with #) + if ($line === '' || \str_starts_with($line, '#')) { + continue; + } + + // Parse section headers like [roadrunner] or [github.plugins.logger] + // These define the namespace for subsequent key-value pairs + if (\preg_match('/^\[([^\]]+)\]$/', $line, $matches)) { + $currentSection = $matches[1]; // Store the full section path + continue; + } + + // Parse key-value pairs in the format: key = value + // Handles both quoted and unquoted values + if (\preg_match('/^([^=]+)=(.+)$/', $line, $matches)) { + $key = \trim($matches[1]); + // Remove surrounding quotes and whitespace from values + $value = \trim($matches[2], ' "\''); + + if ($currentSection === null) { + // Top-level key-value pair (no section) + $result[$key] = $value; + } else { + // Nested key-value pair within a section + // Combine section path with key using dot notation + $this->setNestedValue($result, $currentSection . '.' . $key, $value); + } + } + } + + return $result; + } + + /** + * Converts array back to TOML format. + * + * @param array $data Configuration data + * @return string TOML content + */ + private function arrayToToml(array $data): string + { + $toml = ''; + $sections = []; // Collect associative arrays that will become TOML sections + + // First pass: output top-level scalar values and inline arrays + // This ensures proper TOML structure with values before sections + foreach ($data as $key => $value) { + if (!\is_array($value)) { + // Simple scalar value - output directly as key = "value" + $toml .= "{$key} = \"{$value}\"\n"; + } elseif ($this->isInlineArray($value)) { + // Sequential array - format as inline TOML array [item1, item2, ...] + $toml .= $this->formatKeyValue((string) $key, $value); + } else { + // Associative array - defer to sections for proper TOML structure + $sections[$key] = $value; + } + } + + // Add visual separator between top-level values and sections + // This improves readability of the generated TOML + if ($toml !== '' && !empty($sections)) { + $toml .= "\n"; + } + + // Apply custom ordering to sections for consistent output + // Prioritizes commonly used sections like 'roadrunner' first + $orderedSections = $this->orderSections($sections); + + // Second pass: output all sections (associative arrays) + // Each section becomes a [section.name] block in TOML + foreach ($orderedSections as $sectionKey => $sectionValue) { + $toml .= $this->sectionToToml($sectionKey, $sectionValue); + } + + // Clean up any trailing whitespace for cleaner output + return \rtrim($toml); + } + + /** + * Orders sections with priority: roadrunner first, then debug, logs, github, gitlab, etc. + * + * @param array $sections Sections to order + * @return array Ordered sections + */ + private function orderSections(array $sections): array + { + // Define priority order for commonly used configuration sections + // This ensures consistent output format with important sections first + $priority = ['roadrunner', 'debug', 'log', 'github', 'gitlab']; + $orderedSections = []; + $remainingSections = $sections; // Copy to track unprocessed sections + + // First pass: add priority sections in their defined order + // This maintains a predictable structure for configuration files + foreach ($priority as $sectionKey) { + if (isset($remainingSections[$sectionKey])) { + $orderedSections[$sectionKey] = $remainingSections[$sectionKey]; + unset($remainingSections[$sectionKey]); // Remove from remaining list + } + } + + // Second pass: append any remaining sections that weren't in priority list + // These will appear after priority sections in their original order + foreach ($remainingSections as $sectionKey => $sectionValue) { + $orderedSections[$sectionKey] = $sectionValue; + } + + return $orderedSections; + } + + /** + * Converts a section to TOML format. + * + * @param string $sectionName Section name + * @param array $data Section data + * @return string TOML section content + */ + private function sectionToToml(string $sectionName, array $data): string + { + $toml = ''; + + // Analyze section structure to determine rendering approach + // Check if this section contains nested associative arrays (subsections) + $hasSubsections = false; + foreach ($data as $value) { + if (\is_array($value) && !$this->isInlineArray($value)) { + $hasSubsections = true; + break; // Early exit once we find a subsection + } + } + + if (!$hasSubsections) { + // Simple flat section: only scalar values and inline arrays + // Format: [section_name] followed by key=value pairs + $toml .= "[{$sectionName}]\n"; + foreach ($data as $key => $value) { + $toml .= $this->formatKeyValue((string) $key, $value); + } + $toml .= "\n"; // Add blank line after section + } else { + // Complex section with mixed content: both simple values and nested sections + // Separate simple values from nested subsections for proper TOML structure + $simpleValues = []; // Scalar values and inline arrays + $subsections = []; // Associative arrays that become nested sections + + foreach ($data as $subKey => $subValue) { + if (\is_array($subValue) && !$this->isInlineArray($subValue)) { + // Associative array becomes a nested section + $subsections[$subKey] = $subValue; + } else { + // Scalar or inline array stays in this section + $simpleValues[$subKey] = $subValue; + } + } + + // Output the main section header only if there are simple values + // TOML requires section headers to have content, so we skip empty intermediate sections + if (!empty($simpleValues)) { + $toml .= "[{$sectionName}]\n"; + foreach ($simpleValues as $key => $value) { + $toml .= $this->formatKeyValue((string) $key, $value); + } + $toml .= "\n"; + } + + // Recursively render nested subsections with dotted notation + // e.g., [section.subsection] format + foreach ($subsections as $subKey => $subValue) { + $toml .= $this->renderNestedSection("{$sectionName}.{$subKey}", $subValue); + } + } + + return $toml; + } + + /** + * Renders a nested section recursively. + * + * @param string $sectionPath Full section path (e.g., "github.plugins.logger") + * @param array $data Section data + * @return string TOML section content + */ + private function renderNestedSection(string $sectionPath, array $data): string + { + $toml = ''; + + // Recursively separate content into simple values and nested subsections + // This maintains proper TOML hierarchy for deeply nested configurations + $simpleValues = []; // Scalar values and inline arrays for this section + $subsections = []; // Nested associative arrays for deeper sections + + foreach ($data as $key => $value) { + if (\is_array($value) && !$this->isInlineArray($value)) { + // Nested associative array - becomes a deeper subsection + $subsections[$key] = $value; + } else { + // Scalar value or inline array - stays at current nesting level + $simpleValues[$key] = $value; + } + } + + // Render section header and content based on what we found + // Rules: + // 1. If we have simple values, create the section header and add them + // 2. If we have no subsections but the section exists, create empty section + // 3. If we only have subsections and no simple values, skip this level's header + if (!empty($simpleValues) || empty($subsections)) { + $toml .= "[{$sectionPath}]\n"; + // Output all simple key-value pairs for this section level + foreach ($simpleValues as $key => $value) { + $toml .= $this->formatKeyValue((string) $key, $value); + } + $toml .= "\n"; // Blank line after section content + } + + // Recursively render all nested subsections with extended path + // Each subsection gets a dotted path like 'parent.child.grandchild' + foreach ($subsections as $key => $value) { + $toml .= $this->renderNestedSection("{$sectionPath}.{$key}", $value); + } + + return $toml; + } + + /** + * Formats a key-value pair for TOML output. + * + * @param string $key The key + * @param mixed $value The value + * @return string Formatted TOML key-value pair + */ + private function formatKeyValue(string $key, mixed $value): string + { + if (\is_array($value)) { + // Array values become inline TOML arrays: key = ["item1", "item2"] + // Delegate to specialized array formatting method + return "{$key} = " . $this->formatArrayValue($value) . "\n"; + } + + // Scalar values become quoted strings: key = "value" + // All values are quoted for consistency and safety + return "{$key} = \"{$value}\"\n"; + } + + /** + * Formats an array value for TOML output. + * + * @param array $array The array to format + * @return string Formatted TOML array + */ + private function formatArrayValue(array $array): string + { + $items = []; + + // Convert each array element to a TOML-compatible string representation + foreach ($array as $item) { + if (\is_array($item)) { + // Nested arrays aren't natively supported in TOML inline arrays + // Serialize to JSON string as a workaround for complex data structures + $items[] = '"' . \json_encode($item) . '"'; + } else { + // Simple scalar values are converted to strings and quoted + // This ensures proper TOML syntax for all data types + $items[] = '"' . (string) $item . '"'; + } + } + + // Join all items with commas and wrap in square brackets + // Result: ["item1", "item2", "item3"] + return '[' . \implode(', ', $items) . ']'; + } + + /** + * Determines if an array should be rendered as an inline array rather than a section. + * + * @param array $array The array to check + * @return bool True if it should be an inline array + */ + private function isInlineArray(array $array): bool + { + // Empty arrays are treated as sections, not inline arrays + // This prevents creating empty inline arrays and ensures proper TOML structure + if (empty($array)) { + return false; + } + + // Check if array has sequential numeric keys starting from 0 + // Only truly sequential arrays (0, 1, 2, ...) become inline TOML arrays + // Associative arrays with string keys become TOML sections instead + return \array_keys($array) === \range(0, \count($array) - 1); + } + + /** + * Performs a deep merge of two arrays, recursively merging nested structures. + * + * When both arrays contain nested arrays at the same key, they are recursively + * merged rather than the second array completely replacing the first. For scalar + * values, the second array takes precedence (overlay behavior). + * + * @param array $array1 The base array (lower precedence) + * @param array $array2 The overlay array (higher precedence) + * @return array The merged result with nested structures preserved + */ + private function deepMerge(array $array1, array $array2): array + { + $merged = $array1; // Start with base array as foundation + + // Iterate through all keys in the second array (the overlay) + foreach ($array2 as $key => $value) { + // Check if both the current value and existing value are arrays + // If so, recursively merge them instead of overwriting + if (\is_array($value) && isset($merged[$key]) && \is_array($merged[$key])) { + // Recursive merge for nested array structures + // This preserves nested configuration while allowing overrides + $merged[$key] = $this->deepMerge($merged[$key], $value); + } else { + // For scalar values or when no existing array exists, simply overwrite + // This gives precedence to values from array2 (the overlay/remote config) + /** @var mixed */ + $merged[$key] = $value; + } + } + + return $merged; + } + + /** + * Sets a value at a nested array path using dot notation. + * + * Creates intermediate array levels as needed to accommodate the full path. + * If any intermediate level exists but is not an array, it will be converted + * to an empty array. The final value overwrites any existing value at the path. + * + * @param array $array The array to modify (passed by reference) + * @param non-empty-string $path The dot-notation path (e.g., "section.subsection.key") + * @param mixed $value The value to set at the specified path + */ + private function setNestedValue(array &$array, string $path, mixed $value): void + { + // Split dot-notation path into individual keys + // e.g., "github.plugins.logger" becomes ["github", "plugins", "logger"] + $keys = \explode('.', $path); + $current = &$array; // Reference to current position in nested structure + + // Navigate through the nested array structure, creating missing levels + foreach ($keys as $key) { + // Ensure the current level is an array (convert if needed) + if (!\is_array($current)) { + $current = []; + } + + // Create the key if it doesn't exist (initialize as empty array) + if (!isset($current[$key])) { + $current[$key] = []; + } + + // Move reference deeper into the nested structure + $current = &$current[$key]; + } + + // Set the final value at the deepest nesting level + // This overwrites any existing value at this path + /** @var mixed */ + $current = $value; + } +} diff --git a/src/Module/Velox/Internal/Config/Strategy.php b/src/Module/Velox/Internal/Config/Strategy.php new file mode 100644 index 0000000..fe0a9d5 --- /dev/null +++ b/src/Module/Velox/Internal/Config/Strategy.php @@ -0,0 +1,29 @@ +exists() || !$configPath->isFile()) { + return false; + } + + $content = \file_get_contents($configPath->__toString()); + if ($content === false) { + return false; + } + + // Basic TOML validation - check for required sections + return $this->hasValidTomlStructure($content); + } + + private static function validateConfigSource(VeloxAction $config): void + { + if ($config->configFile === null && $config->plugins === []) { + throw new ConfigException( + 'Velox configuration must specify either config-file or plugins list', + ); + } + } + + private static function validateLocalConfigFile(VeloxAction $config): void + { + if ($config->configFile === null) { + return; + } + + $configPath = Path::create($config->configFile); + + if (!$configPath->exists()) { + throw new ConfigException( + "Velox config file not found: {$config->configFile}", + configPath: $config->configFile, + ); + } + + if (!$configPath->isFile()) { + throw new ConfigException( + "Velox config path is not a file: {$config->configFile}", + configPath: $config->configFile, + ); + } + } + + /** + * Validates basic TOML structure for Velox configurations. + */ + private function hasValidTomlStructure(string $content): bool + { + // Check for basic TOML syntax errors + $lines = \explode("\n", $content); + + foreach ($lines as $line) { + $line = \trim($line); + + // Skip empty lines and comments + if ($line === '' || \str_starts_with($line, '#')) { + continue; + } + + // Check section headers + if (\str_starts_with($line, '[') && \str_ends_with($line, ']')) { + continue; + } + + // Check key-value pairs + if (\str_contains($line, '=')) { + continue; + } + + // Invalid line found + return false; + } + + return true; + } +} diff --git a/src/Module/Velox/Internal/ConfigBuilder.php b/src/Module/Velox/Internal/ConfigBuilder.php new file mode 100644 index 0000000..fce96ab --- /dev/null +++ b/src/Module/Velox/Internal/ConfigBuilder.php @@ -0,0 +1,81 @@ +logger->debug('Building Velox configuration with pipeline...'); + + $pipeline = $this->pipelineBuilder->build(); + $context = new ConfigContext($action, $buildDir); + + $result = $pipeline->process($context); + $configContent = $result->tomlData->toToml(); + + $configPath = $buildDir->join('velox.toml'); + + \file_put_contents($configPath->__toString(), $configContent) === false and throw new ConfigException( + "Failed to write config file to: {$configPath}", + ); + + $this->logger->debug('Configuration written to: %s', (string) $configPath); + + // Validate the generated configuration + $this->validateConfig($configPath) or throw new ConfigException( + "Generated configuration is invalid: {$configPath}", + ); + + return $configPath; + } + + /** + * Validates that a configuration file is valid. + * + * @param Path $configPath Path to the configuration file to validate + * @return bool True if configuration is valid, false otherwise + */ + public function validateConfig(Path $configPath): bool + { + return $this->validator->validateTomlFile($configPath); + } +} diff --git a/src/Module/Velox/Internal/DependencyChecker.php b/src/Module/Velox/Internal/DependencyChecker.php new file mode 100644 index 0000000..6251b75 --- /dev/null +++ b/src/Module/Velox/Internal/DependencyChecker.php @@ -0,0 +1,373 @@ +checkServiceState(); + + # Prepare config + $binaryConfig = new BinaryConfig(); + $binaryConfig->name = self::GOLANG_BINARY_NAME; + $binaryConfig->versionCommand = 'version'; + + # Find Golang binary + $binary = $this->binaryProvider->getGlobalBinary($binaryConfig, 'Go') ?? throw new DependencyException( + 'Go (golang) binary not found. Please install Go or ensure it is in your PATH.', + dependencyName: self::GOLANG_BINARY_NAME, + ); + + # Check Go version + if (!$this->checkBinaryVersion($binary, $this->config->golangVersion)) { + throw new DependencyException( + \sprintf( + 'Go binary version `%s` does not satisfy the required constraint `%s`', + (string) $binary->getVersion(), + (string) $this->config->golangVersion, + ), + dependencyName: self::GOLANG_BINARY_NAME, + ); + } + + $this->logger->debug('Found Go binary: %s', (string) $binary->getPath()); + + return $binary; + } + + /** + * Checks if Velox is available. + * + * @throws DependencyException When Velox is not found + */ + public function prepareVelox(): Binary + { + $this->checkServiceState(); + + # Prepare config + $softwareConfig = $this->softwareCollection->findSoftware('velox'); + $binaryConfig = $softwareConfig?->binary; + if ($binaryConfig === null) { + $binaryConfig = new BinaryConfig(); + $binaryConfig->name = 'vx'; + $binaryConfig->versionCommand = '--version'; + } + + # Check Velox globally + try { + $binary = $this->binaryProvider->getGlobalBinary($binaryConfig, 'Velox'); + if ($binary !== null) { + $this->logger->debug( + 'Found global Velox binary: %s (%s)', + (string) $binary->getPath()->absolute(), + (string) $binary->getVersion(), + ); + if ($this->checkBinaryVersion($binary, $this->config->veloxVersion)) { + return $binary; + } + + $this->logger->debug( + 'Velox binary version `%s` does not satisfy the required constraint `%s`', + (string) $binary->getVersion(), + (string) $this->config->veloxVersion, + ); + } + } catch (\Throwable) { + // Do nothing + } + + # Check Velox locally + # 1. Check local binary + $binary = $this->binaryProvider->getLocalBinary($this->veloxPath, $binaryConfig, 'Velox'); + if ($binary !== null) { + $this->logger->debug( + 'Found local Velox binary: %s (%s)', + (string) $binary->getPath()->absolute(), + (string) $binary->getVersion(), + ); + if ($this->checkBinaryVersion($binary, $this->config->veloxVersion)) { + return $binary; + } + + $this->logger->debug( + 'Velox binary version `%s` does not satisfy the required constraint `%s`', + (string) $binary->getVersion(), + (string) $this->config->veloxVersion, + ); + } + + # 2. Check download actions + /** @var list $softwareList */ + $downloads = []; + $binary = $this->checkDownloadedVelox($softwareConfig, $downloads); + if ($binary !== null) { + $this->logger->debug( + 'Found downloaded Velox binary: %s (%s)', + (string) $binary->getPath()->absolute(), + (string) $binary->getVersion(), + ); + return $binary; + } + + # 3. If no binaries are found, download Velox + $softwareConfig === null and throw new DependencyException( + 'Velox software configuration not found. Please ensure Velox is defined in your configuration.', + dependencyName: 'Velox', + ); + + # Add a download action if no actions are configured + $fallbackAction = Download::fromSoftwareId('velox'); + $fallbackAction->version = $this->config->veloxVersion; + $fallbackAction->extractPath = $this->buildDirectory->__toString(); + $fallbackAction->type = DownloadType::Binary; + + $this->logger->info('Downloading Velox binary...'); + $binary = $this->downloadVelox($softwareConfig, $downloads, $fallbackAction); + if ($binary !== null) { + $this->logger->debug( + 'Downloaded Velox binary: %s (%s)', + (string) $binary->getPath()->absolute(), + (string) $binary->getVersion(), + ); + return $binary; + } + + # Throw exception if Velox is not found + throw new DependencyException( + 'Velox binary not found. Please install Velox or ensure it is in your PATH.', + dependencyName: 'Velox', + ); + } + + /** + * Sets the Velox configuration for dependency checks. + * + * @param VeloxConfig $config The Velox configuration to use + * @param Path $buildDirectory The directory where the build will take place + * + * @return self A new instance with the updated configuration + */ + public function withConfig(VeloxConfig $config, Path $buildDirectory): self + { + $self = clone $this; + $self->config = $config; + $self->veloxPath = Path::create('.'); + $self->buildDirectory = $buildDirectory; + return $self; + } + + /** + * Checks if the binary version satisfies the constraint. + * + * @param Binary $binary The Velox binary to check + * @param string|null $constraint The version constraint to check against + * + * @return bool True if the version is satisfied, false otherwise + */ + private function checkBinaryVersion(Binary $binary, ?string $constraint): bool + { + if ($constraint === null) { + return true; + } + + $version = $binary->getVersion(); + $constrain = Constraint::fromConstraintString($constraint); + + return $version !== null && $constrain->isSatisfiedBy($version); + } + + /** + * Checks if the Velox service is properly configured. + * + * @throws \LogicException If the configuration is not set + */ + private function checkServiceState(): void + { + /** @psalm-suppress RedundantPropertyInitializationCheck */ + isset($this->config) or throw new \LogicException( + 'Velox configuration is not set. Use `withConfig()` to set it before checking dependencies.', + ); + } + + /** + * Checks if Velox is downloaded and returns the binary if available. + * + * @param Software|null $softwareConfig The software configuration for Velox + * @param list $downloads The list of Velox download actions + * + * @return Binary|null The Velox binary if found, null otherwise + */ + private function checkDownloadedVelox(?Software $softwareConfig, array &$downloads): ?Binary + { + $binaryConfig = $softwareConfig?->binary; + if ($softwareConfig === null || $binaryConfig === null) { + return null; + } + + # Find the relevant download action for Velox + foreach ($this->actions->downloads as $download) { + $software = $this->softwareCollection->findSoftware($download->software); + if ($software !== $softwareConfig) { + continue; + } + + # Get binary + $binary = $this->binaryProvider + ->getLocalBinary(Path::create($download->extractPath ?? '.'), $binaryConfig, $softwareConfig->name); + + if ($binary === null) { + $downloads[] = $download; + continue; + } + + if ($this->config->veloxVersion === null) { + return $binary; + } + + # Check version constraint + $constraint = Constraint::fromConstraintString($this->config->veloxVersion); + $binaryVersion = $binary->getVersion(); + + if ($binaryVersion === null || !$constraint->isSatisfiedBy($binaryVersion)) { + $this->logger->debug( + 'Found downloaded Velox binary in `%s` but version `%s` does not satisfy constraint `%s`', + $binary->getPath()->absolute()->__toString(), + (string) $binaryVersion, + $constraint->__toString(), + ); + + # If local binary does not satisfy the download version constraint, + # then we can redownload it + if ($binaryVersion !== null && $download->version !== null) { + Constraint::fromConstraintString($download->version) + ->isSatisfiedBy($binaryVersion) or $downloads[] = $download; + } + + continue; + } + + return $binary; + } + + return null; + } + + /** + * Downloads the Velox binary based on the provided download actions. + * If no downloads are configured or the download fails on version constraint, + * it uses a fallback action to download Velox. + * + * @param Software $software The software configuration for Velox + * @param list $downloads The list of download actions for Velox + * @param Download $fallbackAction The fallback download action if no downloads are configured + * + * @return Binary|null The downloaded Velox binary or null + * + * @throws DependencyException If the download fails or the binary is not found + */ + private function downloadVelox(Software $software, array $downloads, Download $fallbackAction): ?Binary + { + $usedFallback = false; + + try_download: + foreach ($downloads as $download) { + try { + $promise = $this->downloader->addTask($download, force: true); + $this->downloader->run(); + + /** @var DloadResult $result */ + $result = await($promise); + + # Check if the binary download was successful + $binary = $result->binary; + if ($binary === null) { + $this->logger->debug('Download for Velox binary failed: no binary found.'); + continue; + } + + $constraint = $this->config->veloxVersion === null + ? null + : Constraint::fromConstraintString($this->config->veloxVersion); + $binaryVersion = $binary->getVersion(); + + # Check if the downloaded binary satisfies the version constraint + if ($constraint === null or $binaryVersion !== null && $constraint->isSatisfiedBy($binaryVersion)) { + return $binary; + } + + $this->logger->debug( + 'Downloaded Velox binary `%s` with version `%s` does not satisfy the constraint `%s`', + $binary->getPath()->__toString(), + (string) $binaryVersion, + (string) $this->config->veloxVersion, + ); + continue; + } catch (\Throwable) { + continue; + } + } + + /** @psalm-suppress RedundantCondition */ + if (!$usedFallback) { + # If no downloads were successful, use the fallback action + $usedFallback = true; + $downloads = [$fallbackAction]; + goto try_download; + } + + return null; // No valid binary found after trying all downloads + } +} diff --git a/src/Module/Velox/Internal/VeloxBuilder.php b/src/Module/Velox/Internal/VeloxBuilder.php new file mode 100644 index 0000000..544ebe2 --- /dev/null +++ b/src/Module/Velox/Internal/VeloxBuilder.php @@ -0,0 +1,216 @@ +validate($config); + + # Prepare the destination binary path + $destination = Path::create($config->binaryPath ?? 'rr')->absolute(); + $destination->extension() !== $this->operatingSystem->getBinaryExtension() and $destination = $destination + ->parent() + ->join($destination->stem() . $this->operatingSystem->getBinaryExtension()); + + # Prepare environment + # Create build directory + $buildDir = FS::tmpDir($this->appConfig->tmpDir, 'velox-build'); + + # Check required Dependencies + $dependencyChecker = $this->dependencyChecker->withConfig($config, $buildDir); + + # 1. Golang globally + $goBinary = $dependencyChecker->prepareGolang(); + + # 2. Velox locally or globally (downloads if not found) + $vxBinary = $dependencyChecker->prepareVelox(); + + # Prepare configuration file + $configPath = $this->configBuilder->buildConfig($config, $buildDir); + + # Build + # Execute build command + $builtPath = $this->executeBuild($configPath, $buildDir, $vxBinary); + # Move built binary to destination + $binary = $this->installBinary($builtPath, $destination); + + return resolve(new Result( + binary: $binary, + metadata: [ + 'velox_version' => $vxBinary->getVersion(), + 'golang_version' => $goBinary->getVersion(), + 'build_config' => $config, + ], + )); + } catch (\Throwable $e) { + return reject($e); + } finally { + # Remove the build directory + isset($buildDir) and FS::remove($buildDir); + } + }; + + return new Task($config, $onProgress, $handler, $this->getBuildName($config)); + } + + public function validate(VeloxAction $config): void + { + Validator::validate($config); + } + + /** + * Executes the Velox build command with the provided configuration. + * + * @param Path $configPath Path to the Velox configuration file + * @param Path $buildDir Directory where the built binary will be placed + * @param Binary $vxBinary The Velox binary to use for building + * + * @return Path The path to the built binary + * + * @throws BuildException If the build fails or the binary is not found + */ + private function executeBuild(Path $configPath, Path $buildDir, Binary $vxBinary): Path + { + $this->logger->info('Building...'); + $output = $vxBinary->execute( + 'build', + # Specify the build directory + '-o', + $buildDir->absolute()->__toString(), + # Specify the configuration file + '-c', + $configPath->absolute()->__toString(), + ); + + $this->logger->info('Build completed successfully.'); + + // Look for the built binary + return $this->findBuiltBinary($buildDir) ?? throw new BuildException( + 'Built binary not found in the build directory.', + buildOutput: \implode("\n", $output), + ); + } + + /** + * Searches for the built binary in the specified build directory. + * + * @param Path $buildDir The directory where the binary is expected to be found + * + * @return Path|null The path to the built binary, or null if not found + */ + private function findBuiltBinary(Path $buildDir): ?Path + { + // Common locations where velox places built binaries + $searchPaths = [ + $buildDir->join('rr'), + $buildDir->join('rr' . $this->operatingSystem->getBinaryExtension()), + $buildDir->join('roadrunner'), + $buildDir->join('roadrunner' . $this->operatingSystem->getBinaryExtension()), + ]; + + foreach ($searchPaths as $path) { + if ($path->exists() && $path->isFile()) { + $this->logger->debug('Found built binary: %s', (string) $path); + return $path; + } + } + + return null; + } + + /** + * Installs the built binary to the specified destination. + * + * @param Path $builtBinary The path to the built binary + * @param Path $destination The destination path where the binary should be installed + * + * @throws \RuntimeException If the destination cannot be created or the binary cannot be moved + */ + private function installBinary(Path $builtBinary, Path $destination): Binary + { + # Check if build binary already exists + $destination->exists() + ? FS::remove($destination) + : FS::mkdir($destination->parent()); + + FS::moveFile($builtBinary, $destination); + + // Set executable permissions + \chmod($destination->__toString(), 0755); + + $this->logger->info('Installed binary to: %s', $destination->__toString()); + + $binaryConfig = new BinaryConfig(); + $binaryConfig->versionCommand = '--version'; + $binaryConfig->name = $destination->stem(); + return $this->binaryProvider->getLocalBinary($destination->parent(), $binaryConfig) ?? throw new \RuntimeException( + "Failed to create binary instance for: {$destination}", + ); + } + + /** + * Gets a descriptive name for the build action. + */ + private function getBuildName(VeloxAction $veloxAction): string + { + if ($veloxAction->configFile !== null) { + return "Velox build (config: {$veloxAction->configFile})"; + } + + if ($veloxAction->plugins !== []) { + $pluginNames = \array_map(static fn($plugin) => $plugin->name, $veloxAction->plugins); + $pluginCount = \count($pluginNames); + + if ($pluginCount <= 3) { + return 'Velox build (plugins: ' . \implode(', ', $pluginNames) . ')'; + } + + return "Velox build ({$pluginCount} plugins)"; + } + + return 'Velox build'; + } +} diff --git a/src/Module/Velox/Result.php b/src/Module/Velox/Result.php new file mode 100644 index 0000000..e71b6b7 --- /dev/null +++ b/src/Module/Velox/Result.php @@ -0,0 +1,29 @@ + $metadata Additional build metadata + */ + public function __construct( + public readonly Binary $binary, + public readonly array $metadata = [], + ) {} +} diff --git a/src/Module/Velox/Task.php b/src/Module/Velox/Task.php new file mode 100644 index 0000000..cd80fb1 --- /dev/null +++ b/src/Module/Velox/Task.php @@ -0,0 +1,83 @@ + $handler Build execution handler + * @param string $name Optional task name for identification + */ + public function __construct( + public readonly VeloxAction $config, + public readonly \Closure $onProgress, + public readonly \Closure $handler, + public readonly string $name = 'velox-build', + ) {} + + /** + * Executes the build task. + * + * @return PromiseInterface Promise that resolves to build result + */ + public function execute(): PromiseInterface + { + return ($this->handler)(); + } + + /** + * Reports progress to the registered callback. + * + * @param Progress $progress Current progress state + */ + public function reportProgress(Progress $progress): void + { + ($this->onProgress)($progress); + } + + /** + * Returns a unique identifier for this task. + * + * @return string Task identifier + */ + public function getId(): string + { + return \sprintf('%s-%s', $this->name, \spl_object_hash($this)); + } + + /** + * Returns task configuration summary. + * + * @return string Human-readable task description + */ + public function getDescription(): string + { + $pluginCount = \count($this->config->plugins); + $configSource = $this->config->configFile === null ? 'API config' : 'local config'; + + return \sprintf( + 'Build RoadRunner with %d plugins using %s', + $pluginCount, + $configSource, + ); + } +} diff --git a/src/Service/Container.php b/src/Service/Container.php index f39b318..24dba6e 100644 --- a/src/Service/Container.php +++ b/src/Service/Container.php @@ -77,7 +77,8 @@ public function make(string $class, array $arguments = []): object; * * @template T * @param class-string $id Service identifier - * @param null|array|\Closure(Container): T $binding Factory function or constructor arguments + * @param null|class-string|array|\Closure(Container): T $binding Factory + * function, constructor arguments, or alias class name. */ - public function bind(string $id, \Closure|array|null $binding = null): void; + public function bind(string $id, \Closure|string|array|null $binding = null): void; } diff --git a/tests/Unit/Module/Velox/Internal/Config/Pipeline/ConfigPipelineTest.php b/tests/Unit/Module/Velox/Internal/Config/Pipeline/ConfigPipelineTest.php new file mode 100644 index 0000000..d2ff963 --- /dev/null +++ b/tests/Unit/Module/Velox/Internal/Config/Pipeline/ConfigPipelineTest.php @@ -0,0 +1,383 @@ +createMock(ConfigProcessor::class); + $processor2 = $this->createMock(ConfigProcessor::class); + $processors = [$processor1, $processor2]; + + // Act + $pipeline = new ConfigPipeline($processors); + + // Assert + self::assertInstanceOf(ConfigPipeline::class, $pipeline); + } + + public function testConstructorCreatesInstanceWithEmptyProcessors(): void + { + // Arrange + $processors = []; + + // Act + $pipeline = new ConfigPipeline($processors); + + // Assert + self::assertInstanceOf(ConfigPipeline::class, $pipeline); + } + + public function testProcessWithEmptyProcessorsReturnsOriginalContext(): void + { + // Arrange + $processors = []; + $pipeline = new ConfigPipeline($processors); + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['key' => 'value']), + ['metadata' => 'test'], + ); + + // Act + $result = $pipeline->process($originalContext); + + // Assert + self::assertSame($originalContext, $result); + } + + public function testProcessWithSingleProcessorCallsProcessor(): void + { + // Arrange + $processor = $this->createMock(ConfigProcessor::class); + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['key' => 'value']), + [], + ); + $modifiedContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['key' => 'modified']), + [], + ); + + $processor->expects(self::once()) + ->method('__invoke') + ->with($originalContext) + ->willReturn($modifiedContext); + + $pipeline = new ConfigPipeline([$processor]); + + // Act + $result = $pipeline->process($originalContext); + + // Assert + self::assertSame($modifiedContext, $result); + self::assertNotSame($originalContext, $result); + } + + public function testProcessWithMultipleProcessorsCallsThemInSequence(): void + { + // Arrange + $processor1 = $this->createMock(ConfigProcessor::class); + $processor2 = $this->createMock(ConfigProcessor::class); + $processor3 = $this->createMock(ConfigProcessor::class); + + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['step' => '0']), + [], + ); + $context1 = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['step' => '1']), + [], + ); + $context2 = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['step' => '2']), + [], + ); + $finalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['step' => '3']), + [], + ); + + // Set up expectations for sequential processing + $processor1->expects(self::once()) + ->method('__invoke') + ->with($originalContext) + ->willReturn($context1); + + $processor2->expects(self::once()) + ->method('__invoke') + ->with($context1) + ->willReturn($context2); + + $processor3->expects(self::once()) + ->method('__invoke') + ->with($context2) + ->willReturn($finalContext); + + $pipeline = new ConfigPipeline([$processor1, $processor2, $processor3]); + + // Act + $result = $pipeline->process($originalContext); + + // Assert + self::assertSame($finalContext, $result); + self::assertSame(['step' => '3'], $result->tomlData->getData()); + } + + public function testProcessPassesThroughComplexContextChanges(): void + { + // Arrange + $processor1 = $this->createMock(ConfigProcessor::class); + $processor2 = $this->createMock(ConfigProcessor::class); + + $originalTomlData = new TomlData(['initial' => 'data']); + $originalMetadata = ['version' => '1.0']; + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + $originalTomlData, + $originalMetadata, + ); + + // First processor modifies TOML data + $intermediateTomlData = new TomlData(['initial' => 'data', 'added_by_p1' => 'value1']); + $intermediateContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + $intermediateTomlData, + $originalMetadata, + ); + + // Second processor modifies metadata + $finalMetadata = ['version' => '1.0', 'processed_by' => 'p2']; + $finalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + $intermediateTomlData, + $finalMetadata, + ); + + $processor1->expects(self::once()) + ->method('__invoke') + ->with($originalContext) + ->willReturn($intermediateContext); + + $processor2->expects(self::once()) + ->method('__invoke') + ->with($intermediateContext) + ->willReturn($finalContext); + + $pipeline = new ConfigPipeline([$processor1, $processor2]); + + // Act + $result = $pipeline->process($originalContext); + + // Assert + self::assertSame($finalContext, $result); + self::assertSame(['initial' => 'data', 'added_by_p1' => 'value1'], $result->tomlData->getData()); + self::assertSame(['version' => '1.0', 'processed_by' => 'p2'], $result->metadata); + } + + public function testProcessPreservesContextImmutability(): void + { + // Arrange + $processor = $this->createMock(ConfigProcessor::class); + $originalTomlData = new TomlData(['original' => 'data']); + $originalMetadata = ['original' => 'metadata']; + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + $originalTomlData, + $originalMetadata, + ); + + $modifiedContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['modified' => 'data']), + ['modified' => 'metadata'], + ); + + $processor->expects(self::once()) + ->method('__invoke') + ->with($originalContext) + ->willReturn($modifiedContext); + + $pipeline = new ConfigPipeline([$processor]); + + // Act + $result = $pipeline->process($originalContext); + + // Assert - Original context should remain unchanged + self::assertSame(['original' => 'data'], $originalContext->tomlData->getData()); + self::assertSame(['original' => 'metadata'], $originalContext->metadata); + + // Result should have modified data + self::assertSame(['modified' => 'data'], $result->tomlData->getData()); + self::assertSame(['modified' => 'metadata'], $result->metadata); + } + + public function testProcessWithProcessorThatReturnsUnchangedContext(): void + { + // Arrange + $processor1 = $this->createMock(ConfigProcessor::class); + $processor2 = $this->createMock(ConfigProcessor::class); + $processor3 = $this->createMock(ConfigProcessor::class); + + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['data' => 'original']), + [], + ); + + $modifiedContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['data' => 'modified_by_p1']), + [], + ); + + $finalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['data' => 'modified_by_p3']), + [], + ); + + // First processor modifies context + $processor1->expects(self::once()) + ->method('__invoke') + ->with($originalContext) + ->willReturn($modifiedContext); + + // Second processor returns context unchanged (simulating conditional processing) + $processor2->expects(self::once()) + ->method('__invoke') + ->with($modifiedContext) + ->willReturn($modifiedContext); + + // Third processor modifies context again + $processor3->expects(self::once()) + ->method('__invoke') + ->with($modifiedContext) + ->willReturn($finalContext); + + $pipeline = new ConfigPipeline([$processor1, $processor2, $processor3]); + + // Act + $result = $pipeline->process($originalContext); + + // Assert + self::assertSame($finalContext, $result); + self::assertSame(['data' => 'modified_by_p3'], $result->tomlData->getData()); + } + + public function testProcessMaintainsActionAndBuildDirThroughPipeline(): void + { + // Arrange + $processor = $this->createMock(ConfigProcessor::class); + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['test' => 'data']), + ['test' => 'metadata'], + ); + + // Processor only modifies TOML data and metadata, not action or buildDir + $modifiedContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['modified' => 'data']), + ['modified' => 'metadata'], + ); + + $processor->expects(self::once()) + ->method('__invoke') + ->with($originalContext) + ->willReturn($modifiedContext); + + $pipeline = new ConfigPipeline([$processor]); + + // Act + $result = $pipeline->process($originalContext); + + // Assert + self::assertSame($this->veloxAction, $result->action); + self::assertSame($this->buildDir, $result->buildDir); + self::assertSame(['modified' => 'data'], $result->tomlData->getData()); + self::assertSame(['modified' => 'metadata'], $result->metadata); + } + + public function testProcessHandlesLargeNumberOfProcessors(): void + { + // Arrange + $processors = []; + $expectedValue = 0; + + // Create 10 processors that each increment a counter in the TOML data + for ($i = 0; $i < 10; $i++) { + $processor = $this->createMock(ConfigProcessor::class); + $expectedValue = $i + 1; + + $processor->expects(self::once()) + ->method('__invoke') + ->willReturnCallback(static function (ConfigContext $context) use ($expectedValue): ConfigContext { + return $context->withTomlData(new TomlData(['counter' => $expectedValue])); + }); + + $processors[] = $processor; + } + + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['counter' => 0]), + [], + ); + + $pipeline = new ConfigPipeline($processors); + + // Act + $result = $pipeline->process($originalContext); + + // Assert + self::assertSame(['counter' => 10], $result->tomlData->getData()); + } + + protected function setUp(): void + { + $this->veloxAction = new VeloxAction(); + $this->buildDir = Path::create('/tmp/build'); + } +} diff --git a/tests/Unit/Module/Velox/Internal/Config/Pipeline/Processor/LocalFileProcessorTest.php b/tests/Unit/Module/Velox/Internal/Config/Pipeline/Processor/LocalFileProcessorTest.php new file mode 100644 index 0000000..0d49907 --- /dev/null +++ b/tests/Unit/Module/Velox/Internal/Config/Pipeline/Processor/LocalFileProcessorTest.php @@ -0,0 +1,371 @@ + [ + 'binary_name = "test-binary"', + ['binary_name' => 'test-binary'], + ]; + + yield 'section with nested values' => [ + '[roadrunner]' . "\n" . 'version = "latest"', + ['roadrunner' => null], // Just verify the key exists + ]; + + yield 'empty file' => [ + '', + ['_empty' => null], // Add assertion to avoid risky test + ]; + + yield 'file with comments' => [ + '# This is a comment' . "\n" . 'key = "value"', + ['key' => 'value'], + ]; + + yield 'complex nested structure' => [ + '[github.plugins.http]' . "\n" . 'ref = "v4.7.0"' . "\n" . + '[github.plugins.logger]' . "\n" . 'ref = "v1.2.3"', + ['github' => null], + ]; + } + + public function testInvokeReturnsOriginalContextWhenConfigFileIsNull(): void + { + // Arrange + $this->veloxAction->configFile = null; + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(['base' => 'data']), + ['initial' => 'metadata'], + ); + + // Act + $result = $this->processor->__invoke($originalContext); + + // Assert + self::assertSame($originalContext, $result); + } + + public function testInvokeThrowsExceptionWhenConfigFileDoesNotExist(): void + { + // Arrange + $configPath = '/non/existent/path.toml'; + $this->veloxAction->configFile = $configPath; + $context = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(), + [], + ); + + // Assert (before Act for exceptions) + $this->expectException(ConfigException::class); + $this->expectExceptionMessage("Local config file not found: {$configPath}"); + + // Act + $this->processor->__invoke($context); + } + + public function testInvokeThrowsExceptionWhenFileCannotBeRead(): void + { + // Skip this test on Windows as chmod doesn't work the same way + if (PHP_OS_FAMILY === 'Windows') { + self::markTestSkipped('File permission tests are not reliable on Windows'); + } + + // Arrange + $tempFile = $this->createTempFile('test content'); + $this->veloxAction->configFile = $tempFile; + $context = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(), + [], + ); + + // Make file unreadable by changing permissions + \chmod($tempFile, 0000); + + // Assert (before Act for exceptions) + $this->expectException(ConfigException::class); + $this->expectExceptionMessage("Failed to read local config file: {$tempFile}"); + + // Act + try { + $this->processor->__invoke($context); + } finally { + // Clean up - restore permissions before deletion + \chmod($tempFile, 0644); + \unlink($tempFile); + } + } + + public function testInvokeSuccessfullyProcessesValidTomlFile(): void + { + // Arrange + $tomlContent = 'binary_name = "custom-roadrunner"' . "\n" . + '[github.plugins.logger]' . "\n" . + 'ref = "master"'; + $tempFile = $this->createTempFile($tomlContent); + + $this->veloxAction->configFile = $tempFile; + $baseData = new TomlData(['existing' => 'base_data']); + $context = new ConfigContext( + $this->veloxAction, + $this->buildDir, + $baseData, + ['original' => 'metadata'], + ); + + // Act + $result = $this->processor->__invoke($context); + + // Assert + self::assertNotSame($context, $result); + + $resultData = $result->tomlData->getData(); + self::assertSame('base_data', $resultData['existing']); + self::assertSame('custom-roadrunner', $resultData['binary_name']); + self::assertSame('master', $resultData['github']['plugins']['logger']['ref']); + + self::assertTrue($result->metadata['local_file_applied']); + self::assertSame(\str_replace('\\', '/', $tempFile), $result->metadata['local_file_path']); + self::assertSame('metadata', $result->metadata['original']); + + // Clean up + \unlink($tempFile); + } + + public function testInvokeMergesLocalDataWithExistingData(): void + { + // Arrange + $tomlContent = '[roadrunner]' . "\n" . + 'version = "2023.3.0"' . "\n" . + '[github.plugins.http]' . "\n" . + 'ref = "v4.7.0"'; + $tempFile = $this->createTempFile($tomlContent); + + $this->veloxAction->configFile = $tempFile; + $baseData = new TomlData([ + 'roadrunner' => ['binary' => 'rr'], + 'github' => ['plugins' => ['logger' => ['ref' => 'v1.0.0']]], + ]); + $context = new ConfigContext( + $this->veloxAction, + $this->buildDir, + $baseData, + [], + ); + + // Act + $result = $this->processor->__invoke($context); + + // Assert + $resultData = $result->tomlData->getData(); + + // Verify merge behavior + self::assertSame('rr', $resultData['roadrunner']['binary']); + self::assertSame('2023.3.0', $resultData['roadrunner']['version']); + self::assertSame('v1.0.0', $resultData['github']['plugins']['logger']['ref']); + self::assertSame('v4.7.0', $resultData['github']['plugins']['http']['ref']); + + // Clean up + \unlink($tempFile); + } + + #[DataProvider('provideValidTomlFiles')] + public function testInvokeHandlesVariousTomlFormats( + string $tomlContent, + array $expectedKeys, + ): void { + // Arrange + $tempFile = $this->createTempFile($tomlContent); + $this->veloxAction->configFile = $tempFile; + $context = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(), + [], + ); + + // Act + $result = $this->processor->__invoke($context); + + // Assert + $resultData = $result->tomlData->getData(); + + if (\array_key_exists('_empty', $expectedKeys)) { + // Special case for empty file test + self::assertEmpty($resultData); + } else { + foreach ($expectedKeys as $key => $expectedValue) { + self::assertArrayHasKey($key, $resultData); + if ($expectedValue !== null) { + self::assertSame($expectedValue, $resultData[$key]); + } + } + } + + // Clean up + \unlink($tempFile); + } + + public function testInvokePreservesContextImmutability(): void + { + // Arrange + $tomlContent = 'new_key = "new_value"'; + $tempFile = $this->createTempFile($tomlContent); + + $this->veloxAction->configFile = $tempFile; + $originalData = new TomlData(['original' => 'data']); + $originalMetadata = ['original' => 'metadata']; + $originalContext = new ConfigContext( + $this->veloxAction, + $this->buildDir, + $originalData, + $originalMetadata, + ); + + // Act + $result = $this->processor->__invoke($originalContext); + + // Assert - Original context should remain unchanged + self::assertSame(['original' => 'data'], $originalContext->tomlData->getData()); + self::assertSame(['original' => 'metadata'], $originalContext->metadata); + self::assertSame($this->veloxAction, $originalContext->action); + self::assertSame($this->buildDir, $originalContext->buildDir); + + // Result should have new data + $resultData = $result->tomlData->getData(); + self::assertSame('data', $resultData['original']); + self::assertSame('new_value', $resultData['new_key']); + self::assertTrue($result->metadata['local_file_applied']); + + // Clean up + \unlink($tempFile); + } + + public function testInvokePreservesActionAndBuildDir(): void + { + // Arrange + $tomlContent = 'test = "value"'; + $tempFile = $this->createTempFile($tomlContent); + + $this->veloxAction->configFile = $tempFile; + $context = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(), + [], + ); + + // Act + $result = $this->processor->__invoke($context); + + // Assert + self::assertSame($this->veloxAction, $result->action); + self::assertSame($this->buildDir, $result->buildDir); + + // Clean up + \unlink($tempFile); + } + + public function testInvokeAddsCorrectMetadata(): void + { + // Arrange + $tomlContent = 'test_key = "test_value"'; + $tempFile = $this->createTempFile($tomlContent); + + $this->veloxAction->configFile = $tempFile; + $originalMetadata = ['existing' => 'value', 'count' => 42]; + $context = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(), + $originalMetadata, + ); + + // Act + $result = $this->processor->__invoke($context); + + // Assert + self::assertTrue($result->metadata['local_file_applied']); + self::assertSame(\str_replace('\\', '/', $tempFile), $result->metadata['local_file_path']); + + // Verify existing metadata is preserved + self::assertSame('value', $result->metadata['existing']); + self::assertSame(42, $result->metadata['count']); + + // Clean up + \unlink($tempFile); + } + + public function testInvokeWithRelativeConfigPath(): void + { + // Arrange + $tomlContent = 'relative_test = "success"'; + $tempFile = $this->createTempFile($tomlContent); + $relativePath = \basename($tempFile); + + // Change to temp directory to make relative path work + $originalDir = \getcwd(); + \chdir(\dirname($tempFile)); + + $this->veloxAction->configFile = $relativePath; + $context = new ConfigContext( + $this->veloxAction, + $this->buildDir, + new TomlData(), + [], + ); + + // Act + $result = $this->processor->__invoke($context); + + // Assert + $resultData = $result->tomlData->getData(); + self::assertSame('success', $resultData['relative_test']); + self::assertTrue($result->metadata['local_file_applied']); + + // Clean up + \chdir($originalDir); + \unlink($tempFile); + } + + protected function setUp(): void + { + // Arrange (common setup) + $this->processor = new LocalFileProcessor(); + $this->veloxAction = new VeloxAction(); + $this->buildDir = Path::create('/tmp/build'); + } + + private function createTempFile(string $content): string + { + $tempFile = \tempnam(\sys_get_temp_dir(), 'test_velox_'); + \file_put_contents($tempFile, $content); + return $tempFile; + } +} diff --git a/tests/Unit/Module/Velox/Internal/Config/Pipeline/TomlDataTest.php b/tests/Unit/Module/Velox/Internal/Config/Pipeline/TomlDataTest.php new file mode 100644 index 0000000..c73a176 --- /dev/null +++ b/tests/Unit/Module/Velox/Internal/Config/Pipeline/TomlDataTest.php @@ -0,0 +1,763 @@ + [ + "[github.plugins.logger]\ntype = \"logger\"", + ['github' => ['plugins' => ['logger' => ['type' => 'logger']]]], + ]; + + yield 'multiple nested sections' => [ + "[github.plugins.logger]\ntype = \"logger\"\n\n[github.plugins.cache]\ntype = \"cache\"", + [ + 'github' => [ + 'plugins' => [ + 'logger' => ['type' => 'logger'], + 'cache' => ['type' => 'cache'], + ], + ], + ], + ]; + + yield 'deeply nested section' => [ + "[a.b.c.d]\nvalue = \"deep\"", + ['a' => ['b' => ['c' => ['d' => ['value' => 'deep']]]]], + ]; + } + + public static function provideSetPathData(): \Generator + { + yield 'simple key' => [ + 'newkey', + 'newvalue', + ['existing' => 'value', 'newkey' => 'newvalue'], + ]; + + yield 'nested path' => [ + 'section.nested', + 'data', + ['existing' => 'value', 'section' => ['nested' => 'data']], + ]; + + yield 'deeply nested path' => [ + 'a.b.c.d', + 'deep', + ['existing' => 'value', 'a' => ['b' => ['c' => ['d' => 'deep']]]], + ]; + + yield 'overwrite existing top-level key' => [ + 'existing', + 'updated', + ['existing' => 'updated'], + ]; + } + + public static function provideQuotedValues(): \Generator + { + yield 'double quotes' => [ + 'key = "value with spaces"', + ['key' => 'value with spaces'], + ]; + + yield 'single quotes' => [ + "key = 'value with spaces'", + ['key' => 'value with spaces'], + ]; + + yield 'no quotes' => [ + 'key = simple_value', + ['key' => 'simple_value'], + ]; + + yield 'mixed quotes in section' => [ + "[section]\ndouble = \"quoted\"\nsingle = 'quoted'\nbare = unquoted", + [ + 'section' => [ + 'double' => 'quoted', + 'single' => 'quoted', + 'bare' => 'unquoted', + ], + ], + ]; + } + + public function testConstructorCreatesEmptyInstance(): void + { + // Act + $tomlData = new TomlData(); + + // Assert + self::assertSame([], $tomlData->getData()); + } + + public function testConstructorCreatesInstanceWithData(): void + { + // Arrange + $data = ['key' => 'value', 'section' => ['nested' => 'data']]; + + // Act + $tomlData = new TomlData($data); + + // Assert + self::assertSame($data, $tomlData->getData()); + } + + public function testFromStringCreatesInstanceFromTomlString(): void + { + // Arrange + $toml = "key = \"value\"\n\n[section]\nnested = \"data\""; + $expectedData = [ + 'key' => 'value', + 'section' => ['nested' => 'data'], + ]; + + // Act + $tomlData = TomlData::fromString($toml); + + // Assert + self::assertSame($expectedData, $tomlData->getData()); + } + + public function testFromStringHandlesEmptyString(): void + { + // Act + $tomlData = TomlData::fromString(''); + + // Assert + self::assertSame([], $tomlData->getData()); + } + + public function testFromStringHandlesCommentsAndEmptyLines(): void + { + // Arrange + $toml = "# This is a comment\n\nkey = \"value\"\n# Another comment\n\n[section]\n# Comment in section\nnested = \"data\""; + $expectedData = [ + 'key' => 'value', + 'section' => ['nested' => 'data'], + ]; + + // Act + $tomlData = TomlData::fromString($toml); + + // Assert + self::assertSame($expectedData, $tomlData->getData()); + } + + #[DataProvider('provideNestedSectionData')] + public function testFromStringHandlesNestedSections(string $toml, array $expectedData): void + { + // Act + $tomlData = TomlData::fromString($toml); + + // Assert + self::assertSame($expectedData, $tomlData->getData()); + } + + public function testMergeCreatesNewInstanceWithMergedData(): void + { + // Arrange + $data1 = ['key1' => 'value1', 'section' => ['nested1' => 'data1']]; + $data2 = ['key2' => 'value2', 'section' => ['nested2' => 'data2']]; + $tomlData1 = new TomlData($data1); + $tomlData2 = new TomlData($data2); + $expectedMerged = [ + 'key1' => 'value1', + 'key2' => 'value2', + 'section' => [ + 'nested1' => 'data1', + 'nested2' => 'data2', + ], + ]; + + // Act + $merged = $tomlData1->merge($tomlData2); + + // Assert + self::assertNotSame($tomlData1, $merged); + self::assertNotSame($tomlData2, $merged); + self::assertEquals($expectedMerged, $merged->getData()); + } + + public function testMergeOverwritesExistingKeys(): void + { + // Arrange + $data1 = ['key' => 'original', 'section' => ['nested' => 'original']]; + $data2 = ['key' => 'updated', 'section' => ['nested' => 'updated']]; + $tomlData1 = new TomlData($data1); + $tomlData2 = new TomlData($data2); + $expectedMerged = [ + 'key' => 'updated', + 'section' => ['nested' => 'updated'], + ]; + + // Act + $merged = $tomlData1->merge($tomlData2); + + // Assert + self::assertSame($expectedMerged, $merged->getData()); + } + + public function testMergeHandlesNestedArrays(): void + { + // Arrange + $data1 = ['section' => ['key1' => 'value1', 'nested' => ['deep1' => 'data1']]]; + $data2 = ['section' => ['key2' => 'value2', 'nested' => ['deep2' => 'data2']]]; + $tomlData1 = new TomlData($data1); + $tomlData2 = new TomlData($data2); + $expectedMerged = [ + 'section' => [ + 'key1' => 'value1', + 'key2' => 'value2', + 'nested' => [ + 'deep1' => 'data1', + 'deep2' => 'data2', + ], + ], + ]; + + // Act + $merged = $tomlData1->merge($tomlData2); + + // Assert + self::assertEquals($expectedMerged, $merged->getData()); + } + + #[DataProvider('provideSetPathData')] + public function testSetCreatesNewInstanceWithUpdatedValue(string $path, mixed $value, array $expectedData): void + { + // Arrange + $initialData = ['existing' => 'value']; + $tomlData = new TomlData($initialData); + + // Act + $updated = $tomlData->set($path, $value); + + // Assert + self::assertNotSame($tomlData, $updated); + self::assertSame($expectedData, $updated->getData()); + self::assertSame($initialData, $tomlData->getData()); // Original unchanged + } + + public function testToTomlConvertsDataToTomlString(): void + { + // Arrange + $data = [ + 'key1' => 'value1', + 'key2' => 'value2', + 'section' => [ + 'nested1' => 'data1', + 'nested2' => 'data2', + ], + ]; + $tomlData = new TomlData($data); + $expectedToml = <<toToml(); + + // Assert + self::assertSame($expectedToml, $result); + } + + public function testToTomlHandlesNestedSections(): void + { + // Arrange + $data = [ + 'github' => [ + 'plugins' => [ + 'logger' => 'enabled', + 'cache' => 'disabled', + ], + ], + ]; + $tomlData = new TomlData($data); + $expectedToml = "[github.plugins]\nlogger = \"enabled\"\ncache = \"disabled\""; + + // Act + $result = $tomlData->toToml(); + + // Assert + self::assertSame($expectedToml, $result); + } + + public function testToTomlHandlesMixedSectionTypes(): void + { + // Arrange + $data = [ + 'roadrunner' => [ + 'simple' => 'value', + 'plugins' => ['logger' => 'enabled'], + ], + ]; + $tomlData = new TomlData($data); + $expectedToml = <<toToml(); + + // Assert + self::assertSame($expectedToml, $result); + } + + public function testToTomlHandlesEmptyData(): void + { + // Arrange + $tomlData = new TomlData(); + + // Act + $result = $tomlData->toToml(); + + // Assert + self::assertSame('', $result); + } + + public function testMergeTomlStringsReturnsMergedTomlString(): void + { + // Arrange + $localToml = "local_key = \"local_value\"\n\n[roadrunner]\nversion = \"1.0\""; + $remoteToml = "remote_key = \"remote_value\"\n\n[github.plugins]\nlogger = \"enabled\""; + $expectedMerged = "local_key = \"local_value\"\nremote_key = \"remote_value\"\n\n[roadrunner]\nversion = \"1.0\"\n\n[github.plugins]\nlogger = \"enabled\""; + + // Act + $result = TomlData::mergeTomlStrings($localToml, $remoteToml); + + // Assert + self::assertSame($expectedMerged, $result); + } + + public function testMergeTomlStringsHandlesEmptyStrings(): void + { + // Arrange + $localToml = "key = \"value\""; + $emptyToml = ""; + + // Act + $result1 = TomlData::mergeTomlStrings($localToml, $emptyToml); + $result2 = TomlData::mergeTomlStrings($emptyToml, $localToml); + + // Assert + self::assertSame("key = \"value\"", $result1); + self::assertSame("key = \"value\"", $result2); + } + + public function testRoundTripConversion(): void + { + // Arrange + $originalToml = "key = \"value\"\n\n[section]\nnested = \"data\""; + + // Act + $tomlData = TomlData::fromString($originalToml); + $convertedToml = $tomlData->toToml(); + $roundTripData = TomlData::fromString($convertedToml); + + // Assert + self::assertSame($tomlData->getData(), $roundTripData->getData()); + } + + #[DataProvider('provideQuotedValues')] + public function testFromStringHandlesQuotedValues(string $toml, array $expectedData): void + { + // Act + $tomlData = TomlData::fromString($toml); + + // Assert + self::assertSame($expectedData, $tomlData->getData()); + } + + public function testImmutabilityOfOriginalData(): void + { + // Arrange + $originalData = ['key' => 'value']; + $tomlData = new TomlData($originalData); + + // Act + $tomlData->set('newkey', 'newvalue'); + $tomlData->merge(new TomlData(['otherkey' => 'othervalue'])); + + // Assert - original data and instance should be unchanged + self::assertSame(['key' => 'value'], $tomlData->getData()); + self::assertSame(['key' => 'value'], $originalData); + } + + public function testGetDataReturnsReadOnlyArray(): void + { + // Arrange + $tomlData = new TomlData(['key' => 'value']); + + // Act + $data = $tomlData->getData(); + + // Assert + self::assertSame(['key' => 'value'], $data); + } + + public function testToTomlHandlesDeeplyNestedSections(): void + { + // Arrange + $data = [ + 'github' => [ + 'plugins' => [ + 'logger' => [ + 'ref' => 'v5.1.8', + 'owner' => 'roadrunner-server', + 'repository' => 'logger', + ], + 'server' => [ + 'ref' => 'v5.2.9', + 'owner' => 'roadrunner-server', + 'repository' => 'server', + ], + ], + ], + ]; + $tomlData = new TomlData($data); + $expectedToml = <<toToml(); + + // Assert + self::assertSame($expectedToml, $result); + } + + public function testToTomlHandlesInlineArrays(): void + { + // Arrange + $data = [ + 'features' => ['logging', 'caching', 'metrics'], + 'ports' => [8080, 9090, 3000], + ]; + $tomlData = new TomlData($data); + $expectedToml = <<toToml(); + + // Assert + self::assertSame($expectedToml, $result); + } + + public function testToTomlHandlesMixedTopLevelAndNestedStructures(): void + { + // Arrange + $data = [ + 'roadrunner' => [ + 'ref' => 'v2025.1.1', + ], + 'log' => [ + 'level' => 'debug', + 'mode' => 'dev', + ], + 'github' => [ + 'token' => [ + 'token' => '${GITHUB_TOKEN}', + ], + 'plugins' => [ + 'logger' => [ + 'ref' => 'v5.1.8', + 'owner' => 'roadrunner-server', + 'repository' => 'logger', + ], + ], + ], + ]; + $tomlData = new TomlData($data); + $expectedToml = <<toToml(); + + // Assert + self::assertSame($expectedToml, $result); + } + + public function testFromStringAndToTomlRoundTripWithNestedArrays(): void + { + // Arrange - This mimics the structure from velox.toml + $originalToml = <<toToml(); + $roundTripData = TomlData::fromString($convertedToml); + + // Assert + self::assertSame($tomlData->getData(), $roundTripData->getData()); + } + + public function testToTomlHandlesEmptyNestedSections(): void + { + // Arrange + $data = [ + 'section' => [ + 'empty_subsection' => [], + 'populated_subsection' => ['key' => 'value'], + ], + ]; + $tomlData = new TomlData($data); + $expectedToml = <<toToml(); + + // Assert + self::assertSame($expectedToml, $result); + } + + public function testParseTomlWithComplexNestedStructure(): void + { + // Arrange + $toml = << ['ref' => 'v2025.1.1'], + 'github' => [ + 'plugins' => [ + 'logger' => [ + 'ref' => 'v5.1.8', + 'owner' => 'roadrunner-server', + 'repository' => 'logger', + ], + 'temporal' => [ + 'ref' => 'v5.7.0', + 'owner' => 'temporalio', + 'repository' => 'roadrunner-temporal', + ], + ], + ], + ]; + + // Act + $tomlData = TomlData::fromString($toml); + + // Assert + self::assertSame($expectedData, $tomlData->getData()); + } + + public function testToTomlOrdersSectionsWithRoadrunnerFirst(): void + { + // Arrange - sections in different order + $data = [ + 'github' => ['plugins' => ['logger' => 'enabled']], + 'log' => ['level' => 'debug'], + 'roadrunner' => ['ref' => 'v2025.1.1'], + 'debug' => ['enabled' => 'true'], + 'other' => ['key' => 'value'], + ]; + $tomlData = new TomlData($data); + + // Act + $result = $tomlData->toToml(); + + // Assert - roadrunner should be first, then debug, log, github, then others + $expectedToml = << ['ref' => 'v2025.1.1'], + ]; + $tomlData = new TomlData($data); + + // Act + $result = $tomlData->toToml(); + + // Assert - "v" prefix should be preserved + $expectedToml = << ['ref' => ''], + ]; + $tomlData = new TomlData($data); + + // Act + $result = $tomlData->toToml(); + + // Assert - empty ref should remain empty + $expectedToml = << ['ref' => 123], + ]; + $tomlData = new TomlData($data); + + // Act + $result = $tomlData->toToml(); + + // Assert - non-string ref should remain unchanged + $expectedToml = << ['key' => 'value'], + 'gitlab' => ['url' => 'gitlab.com'], + 'github' => ['token' => 'secret'], + 'log' => ['level' => 'info'], + 'debug' => ['enabled' => 'false'], + 'roadrunner' => ['ref' => 'v2025.1.1'], + ]; + $tomlData = new TomlData($data); + + // Act + $result = $tomlData->toToml(); + + // Assert - proper ordering and normalization + $expectedToml = <<