@@ -14,23 +14,61 @@
-DLoad упрощает загрузку и управление бинарными артефактами для ваших проектов. Идеально подходит для сред разработки, которые требуют специфических инструментов, таких как RoadRunner, Temporal или пользовательские бинарные файлы.
+DLoad упрощает загрузку и управление бинарными артефактами в ваших проектах. Отлично подходит для dev-окружений, которым нужны специфические инструменты вроде RoadRunner, Temporal или собственные бинарники.
[](README.md)
[](README-zh.md)
[](README-ru.md)
[](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 或者自定义二进制文件。
[](README.md)
[](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.md)
[](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 = <<