diff --git a/Cargo.lock b/Cargo.lock index f6adb81..39f31ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -46,6 +52,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "assert_cmd" version = "2.2.0" @@ -331,6 +346,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -375,6 +405,17 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "difflib" version = "0.4.0" @@ -462,6 +503,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -1073,6 +1124,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1573,14 +1634,12 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", "webpki-roots", ] @@ -1871,6 +1930,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "similar" version = "2.7.0" @@ -2285,7 +2350,7 @@ dependencies = [ [[package]] name = "v8-runner" -version = "0.4.2" +version = "0.5.0" dependencies = [ "anyhow", "assert_cmd", @@ -2317,6 +2382,7 @@ dependencies = [ "tracing-subscriber", "uuid", "walkdir", + "zip", ] [[package]] @@ -2464,19 +2530,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasmparser" version = "0.244.0" @@ -2925,8 +2978,37 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 43a7c8d..9698c74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "v8-runner" -version = "0.4.2" +version = "0.5.0" edition = "2021" [[bin]] @@ -28,9 +28,11 @@ chrono = { version = "0.4", features = ["serde"] } libc = "0.2" regex = "1" uuid = { version = "1", features = ["v4"] } +zip = { version = "2", default-features = false, features = ["deflate"] } rmcp = { version = "1.2.0", default-features = false, features = ["client", "macros", "server", "transport-child-process", "transport-io", "transport-streamable-http-server"] } tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-util = "0.7" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } [dev-dependencies] assert_cmd = "2" @@ -38,7 +40,6 @@ jsonschema = "0.33" predicates = "3" insta = "1" quote = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } syn = { version = "2", features = ["full"] } [profile.release] diff --git a/README.md b/README.md index a0ea594..93989dc 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,26 @@ cargo build --release v8-runner config init ``` -Команда анализирует структуру проекта, находит поддержанные `source-set` (наборы исходников) и -создает `v8project.yaml`. +Команда анализирует структуру проекта, находит поддержанные `source-set` (наборы исходников), +создает `v8project.yaml`, пустой `v8project.local.yaml` со schema modeline и добавляет local +overlay в `.gitignore`, если он еще не указан. Machine-local пути, credentials и настройки инструментов можно вынести в `v8project.local.yaml` рядом с основным конфигом. Этот файл применяется автоматически и должен оставаться вне Git. +### Загрузите тестовые и MCP-инструменты: + +```bash +v8-runner tools download yaxunit --sources +v8-runner tools download vanessa +v8-runner tools download client-mcp --sources +``` + +Команды берут latest releases выбранного инструмента. Для YAxUnit и onec-client-mcp-devkit +`--sources` выбирает source install; без него скачивается `.cfe` artifact в `build/tools`. +Vanessa Automation single всегда скачивается как EPF в `build/tools` и прописывается в +`v8project.local.yaml`. + ### Подготовьте рабочую информационную базу: ```bash @@ -93,10 +107,10 @@ v8-runner launch mcp va ### Подключение к session-manager (WS-режим) `launch mcp`, `launch mcp va`, `test yaxunit ...` и `test va ...` поддерживают подключение -1С-клиента к [`v8-client-session-manager`](../v8-client-session-manager/) вместо запуска +1С-клиента к [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager) вместо запуска локального HTTP MCP. По умолчанию — `auto`: TCP-probe адреса менеджера, при успехе собирается `/C"mcpMode=ws;manager_url=...;client_uid=...;kind=...;..."`, иначе используется -legacy `/C"runMcp;..."`. Полный список ключей `/C` и CLI-флагов см. в +MCP `/C"runMcp;..."`. Полный список ключей `/C` и CLI-флагов см. в [docs/CONFIGURATION.md](docs/CONFIGURATION.md#tools-client_mcp). ### Поднимите MCP transport (MCP-транспорт) для AI-агентов: @@ -115,7 +129,7 @@ v8-runner mcp serve stdio | Зона | Команды | Что делает | | --- | --- | --- | -| Project setup (настройка проекта) | `config init`, `init`, `extensions`, `build` | Создает config, готовит ИБ, обновляет расширения и загружает исходники | +| Project setup (настройка проекта) | `config init`, `tools download`, `init`, `extensions`, `build` | Создает config, скачивает инструменты, готовит ИБ, обновляет расширения и загружает исходники | | Verification (проверка) | `syntax`, `test` | Запускает syntax checks, YAxUnit и Vanessa Automation | | File materialization (материализация файлов) | `dump`, `convert`, `load`, `make`, `artifacts` | Выгружает, конвертирует, загружает и публикует `.cf`, `.cfe`, `.epf`, `.erf` | | Direct launch (прямой запуск) | `launch `, `launch mcp [va]` | Запускает 1C clients (клиенты 1С), Designer и MCP/Vanessa сценарии | diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 91dbae4..071e538 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -20,7 +20,8 @@ CLI help, доверяйте текущему коду и затем синхр | Сценарий | Поддерживаемые комбинации | Примечания | | --- | --- | --- | -| `config init` | Работает без существующего конфига | Создаёт `v8project.yaml`, autodetect-ит supported `source-set` и aggregate external roots | +| `config init` | Работает без существующего конфига | Создаёт `v8project.yaml`, sibling `v8project.local.yaml`, `.gitignore` entry, autodetect-ит supported `source-set` и aggregate external roots | +| `tools download ` | CLI-only загрузка latest releases | Загружает выбранный YAxUnit, Vanessa Automation single или onec-client-mcp-devkit; обновляет local overlay для Vanessa/client MCP и при `yaxunit --sources` добавляет YAxUnit как `source-set` `tests` | | `init` | `format=DESIGNER` + `builder=DESIGNER` | Создаёт файловую ИБ через Designer; server connection остаётся manual prerequisite | | `init` | `format=DESIGNER` + `builder=IBCMD` | Выполняет `ensure` файловой или серверной ИБ через `ibcmd infobase create` | | `init` | `format=EDT` + `builder=DESIGNER|IBCMD` | Готовит ИБ по правилам builder и импортирует EDT workspace | @@ -74,6 +75,8 @@ v8-runner config init [--force] [--output ] [--connection ] [- - Не требует существующего `v8project.yaml`. - Пишет результат в текущий каталог или в `--output`. +- Рядом с primary config создает/обновляет пустой `v8project.local.yaml` со schema modeline и + добавляет `v8project.local.yaml` в `.gitignore`, если подходящий pattern еще не указан. - Не использует глобальный `--config` как shortcut output path. - Ищет supported `DESIGNER` / `EDT` `source-set` по marker files и их содержимому. - Для external roots создаёт aggregate `source-set` только при однородной классификации каталога. @@ -96,6 +99,33 @@ v8-runner init - Если настроен `tools.client_mcp.extension.source.format=EDT`, импортирует этот tool extension project в EDT workspace, не добавляя его в project `source-set`. +### `tools download` + +```bash +v8-runner tools download yaxunit [--sources] [--force] +v8-runner tools download vanessa [--force] +v8-runner tools download client-mcp [--sources] [--force] +``` + +- CLI-only; не публикуется как MCP tool. +- Берёт latest release из GitHub для выбранного инструмента: `bia-technologies/yaxunit`, + `Pr-Mex/vanessa-automation-single` или `1c-neurofish/onec-client-mcp-devkit`. +- `yaxunit --sources` распаковывает source subtree в `tests` и добавляет в primary + `v8project.yaml` `source-set` с именем `tests`; без `--sources` скачивает `.cfe` в + `build/tools`. +- `client-mcp --sources` распаковывает source subtree в + `build/tools/onec-client-mcp-devkit/exts/client-mcp`; без `--sources` требует + `builder=DESIGNER` и скачивает `.cfe` в `build/tools`. +- `vanessa` всегда скачивает `build/tools/vanessa-automation-single.epf`. +- `v8project.local.yaml` обновляется только для команд, которым нужны machine-local пути: + `vanessa` заполняет `tools.va.epf_path`, `client-mcp` заполняет + `tools.client_mcp.extension`; повторный запуск переиспользует уже скачанные файлы, а + `--force` перезаписывает только managed targets, созданные `tools download`. +- Managed target определяется sidecar marker-файлом `tools download`; если публикация файла или + каталога не завершилась, новый marker очищается и target не считается управляемым. +- Каждый HTTP response body ограничен 512 MiB; превышение лимита возвращает ошибку до публикации + target. + ### `extensions` ```bash @@ -109,10 +139,14 @@ v8-runner extensions [--name ...] ### `build` ```bash -v8-runner build [--source-set ] [--full-rebuild] +v8-runner build [--source-set ] [--full-rebuild] [--dynamic] ``` - Без `--source-set` обрабатывает все configured `source-set` в canonical order. +- `--dynamic` (или `build.dynamicUpdate: true` в `v8project.yaml`) добавляет к + `/UpdateDBCfg` флаг `-Dynamic+`. Платформа применяет изменения без захвата + исключительной блокировки; на изменениях, требующих реструктуризации, DESIGNER возвращает + ошибку — fallback на статический режим не выполняется. - С `--source-set` project stage анализирует и строит только указанный `source-set`; неизвестное имя отклоняется как validation error. - Для `DESIGNER` выбирает incremental, partial или full path по изменённым файлам выбранного scope. @@ -141,7 +175,9 @@ v8-runner test va v8-runner test va --feature login --filter-tag @smoke ``` -- Всегда сначала запускает `build`. +- Всегда сначала запускает `build` со статическим `/UpdateDBCfg`, даже если + `build.dynamicUpdate: true`. Для динамической подготовки перед тестами выполните отдельный + `v8-runner build --dynamic`. - `test yaxunit module ` требует непустое имя модуля. - `test va` использует профиль из `tests.va.profile`; `--feature`, `--filter-tag`, `--ignore-tag` и `--scenario-filter` переопределяют соответствующие списки выбранного профиля @@ -199,7 +235,7 @@ v8-runner convert [--source-set ] [--output ] - Работает от текущего `v8project.yaml`, а не по arbitrary source/target paths. - Направление определяется только из `format`. - Без `--output` публикует результат под `workPath/convert/out///`. -- `--output` задаёт только target root и зеркалит `source-set.path` относительно `basePath`. +- `--output` задаёт только target root и зеркалит `source-set.path` относительно каталога primary config. - Публикация остаётся staged full replacement с overlap guardrails. ### `load` @@ -278,7 +314,7 @@ v8-runner mcp serve http | Инструмент | Основные поля запроса | Примечания | | --- | --- | --- | -| `build_project` | `fullRebuild`, `sourceSet` | `fullRebuild=false`; `sourceSet` omitted значит все source-set | +| `build_project` | `fullRebuild`, `sourceSet`, `dynamicUpdate` | `fullRebuild=false`; `sourceSet` omitted значит все source-set; `dynamicUpdate` (опц.) переопределяет `build.dynamicUpdate` для одного вызова | | `run_all_tests` | `full` | Компактный вывод по умолчанию | | `run_module_tests` | `moduleName`, `full` | Отклоняет пустой `moduleName` | | `dump_config` | `mode`, `extension`, `objects` | Пустой `mode` нормализуется в `INCREMENTAL` | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f708fb0..aece034 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -31,8 +31,11 @@ v8-runner config init Что делает `config init`: - создаёт `v8project.yaml` в текущем каталоге или по `--output `; -- добавляет modeline `yaml-language-server` со ссылкой на versioned schema для текущей версии - `v8-runner`; +- добавляет modeline `yaml-language-server` со ссылкой на опубликованный schema artifact в + ветке `master`; +- создаёт рядом пустой `v8project.local.yaml` с modeline на + `https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json`; +- добавляет `v8project.local.yaml` в `.gitignore`, если подходящий pattern еще не указан; - заполняет `source-set` по найденным исходникам; - не перезаписывает существующий файл без `--force`; - не пишет synthetic `CONFIGURATION`: если конфигурационный `source-set` не найден, @@ -68,34 +71,39 @@ schema artifacts для редактирования `v8project.yaml` и `v8proj `v8-runner config init` пишет в начало `v8project.yaml` modeline: ```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.schema.json +# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.schema.json ``` В VS Code установите расширение `redhat.vscode-yaml`. Оно использует эту строку автоматически; отдельная настройка workspace для основного файла не нужна. -Для `v8project.local.yaml` schema подключается вручную через настройки VS Code, потому что local -overlay обычно не генерируется командой. Добавьте это в `.vscode/settings.json` проекта или в -user settings: +Для `v8project.local.yaml` `config init` пишет отдельную modeline: + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json +``` + +Если local overlay создаётся вручную, добавьте это в `.vscode/settings.json` проекта или в user +settings: ```json { "yaml.schemas": { - "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.local.schema.json": "v8project.local.yaml" + "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json": "v8project.local.yaml" } } ``` -Schema version равна версии приложения из `Cargo.toml` и release tag: `v0.4.2` публикует schemas -для `v8-runner 0.4.2`. Для воспроизводимого редактирования используйте raw URL с `refs/tags/vX.Y.Z`; -веточные raw URLs допустимы только для разработки следующей версии. +Schema URL всегда указывает на `master`, чтобы IDE подхватывала актуальный опубликованный schema +artifact без привязки к release tag. ## Именование ключей `v8project.yaml` использует не один стиль на весь документ. Это текущий loader contract, и docs ниже повторяют именно literal YAML keys. -- top-level app keys: `basePath`, `workPath`, `source-set`; +- top-level app keys: `workPath`, `execution_timeout`, `format`, `builder`, `infobase`, + `source-set`, `build`, `tools`, `mcp`, `tests`; - `build` использует `partialLoadThreshold`; - `mcp.*` и `tests.*` используют `snake_case`; - canonical key для EDT tool section: `tools.edt_cli`; @@ -110,8 +118,6 @@ Schema version равна версии приложения из `Cargo.toml` и ## Канонический пример ```yaml -# basePath можно опустить, тогда он равен каталогу v8project.yaml. -basePath: /path/to/project workPath: build execution_timeout: 300000 format: EDT @@ -121,6 +127,7 @@ infobase: connection: "File=build/ib" user: Admin password: secret + unlock_code: seal-42 # non-empty value is passed as `/UC <значение>` source-set: - name: main @@ -132,6 +139,7 @@ source-set: build: partialLoadThreshold: 20 + dynamicUpdate: false # `/UpdateDBCfg -Dynamic+` по умолчанию tools: client_mcp: @@ -141,7 +149,7 @@ tools: source: path: /path/to/onec-client-mcp/exts/client-mcp format: EDT - transport: auto # ws | legacy | auto (default) + transport: auto # ws | mcp | auto (default) manager_url: ws://127.0.0.1:4000/sessions log_level: info # off|error|warn|info|debug|trace ws_timeout_ms: 1000 @@ -191,7 +199,9 @@ tests: ## Локальный overlay `v8project.local.yaml` расположен рядом с выбранным primary config и применяется автоматически. -Файл не является самостоятельным config entrypoint: передавать его через `--config` нельзя. +`config init` создаёт пустой local overlay как валидный YAML mapping (`{}`), добавляет schema +modeline и сохраняет существующие значения, если файл уже был создан вручную. Файл не является +самостоятельным config entrypoint: передавать его через `--config` нельзя. Precedence: @@ -246,14 +256,6 @@ tests: ## Обязательный контракт -### `basePath` - -- Тип: путь -- Обязателен: нет -- По умолчанию: каталог primary `v8project.yaml` - -Корень исходников проекта. Должен существовать и быть каталогом. - ### `workPath` - Тип: путь @@ -318,6 +320,17 @@ tests: Credentials самой информационной базы. +#### `infobase.unlock_code` + +- Тип: строка +- Обязателен: нет + +Кодовое слово (`Конфигурация → Установить пароль`), которое транслируется в DESIGNER как +`/UC <значение>`. Без него платформа отказывается выполнять административные операции на +запароленных конфигурациях. Пустая строка считается отсутствием кода и не добавляет `/UC`. +Значение маскируется в логах команд (`/UC ***`), поэтому его безопасно держать в +`v8project.local.yaml` рядом с `infobase.password`. + #### `infobase.dbms` - Тип: объект @@ -344,6 +357,8 @@ Credentials самой информационной базы. - `type` - `path` +`path` задаётся относительно каталога primary `v8project.yaml`, если он не абсолютный. + `type` поддерживает только: - `CONFIGURATION` @@ -375,6 +390,18 @@ Validation rules: Порог между partial и full load. +#### `build.dynamicUpdate` + +- Тип: boolean +- По умолчанию: `false` + +Включает режим динамического обновления (`/UpdateDBCfg -Dynamic+`) для `build`. Полезно, +когда в инфобазе живут HTTP-сервисы или фоновые задания и захват исключительной блокировки +нежелателен. Если изменения требуют реструктуризации, DESIGNER возвращает ошибку, и +`v8-runner` пробрасывает её наружу — fallback на статический режим не выполняется. + +CLI-флаг `v8-runner build --dynamic` переопределяет это значение на одну команду. + CLI selector `v8-runner build --source-set ` использует `source-set[].name` как stable runtime identity и не добавляет отдельное поле конфигурации. Если selector не задан, `build` обрабатывает все `source-set`. @@ -412,8 +439,8 @@ runtime identity и не добавляет отдельное поле конф ведущий `@`, если он указан в `profiles..filter_tags`, `profiles..ignore_tags`, `--filter-tag` или `--ignore-tag`. -При генерации runtime `VAParams` runner добавляет `WorkspaceRoot` со значением `basePath`, -если это поле отсутствует или равно `null` в `tests.va.params_path`. +При генерации runtime `VAParams` runner добавляет `WorkspaceRoot` со значением каталога primary +`v8project.yaml`, если это поле отсутствует или равно `null` в `tests.va.params_path`. Для Vanessa Automation обязательны: @@ -446,19 +473,19 @@ runtime identity и не добавляет отдельное поле конф Поддержанные поля: -- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit (legacy режим). +- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit (MCP-режим). - `extension`, опциональное tool extension для клиентского MCP-сервера. -- `transport` (`ws`, `legacy`, `auto`; по умолчанию `auto`) — режим транспорта. См. раздел +- `transport` (`ws`, `mcp`, `auto`; по умолчанию `auto`) — режим транспорта. См. раздел «WS-режим к session-manager» ниже. -- `manager_url` — WS-эндпоинт `v8-client-session-manager`, +- `manager_url` — WS-эндпоинт `v8-client-session-manager` с IP-адресом и портом, по умолчанию `ws://127.0.0.1:4000/sessions`. - `log_level` (`off`/`error`/`warn`/`info`/`debug`/`trace`) — значение `mcp_log_level`, передаваемое в `/C` BSL-расширению `client_mcp`. -- `ws_timeout_ms` — значение `mcp_ws_timeout_ms` (таймаут установки WS-сессии в режиме `auto`, - > 0). +- `ws_timeout_ms` — значение `mcp_ws_timeout_ms`, таймаут установки WS-сессии + в миллисекундах (> 0). `launch mcp` передаёт `port` как `mcpPort` внутри `/C"runMcp..."` -если CLI не указал `--mcp-port` и выбран legacy/`auto`-fallback транспорт. +если CLI не указал `--mcp-port` и выбран `mcp`/`auto`-fallback транспорт. #### WS-режим к session-manager @@ -488,12 +515,12 @@ CLI-флаги: `--mcp-transport`, `--manager-url`, `--client-uid`, `--corr-id`, встроенными дефолтами. `client_uid` по умолчанию рандомный UUIDv4 на каждый запуск, `corr_id` по умолчанию `vr-<первые 8 символов uid>`. -Для `transport=auto` v8-runner делает короткий TCP-probe (200 ms) на хост:порт из -`manager_url`. При успехе выбирается WS, иначе legacy. Для `transport=ws` без живого +Для `transport=auto` v8-runner делает короткий TCP-probe (200 ms) на IP:порт из +`manager_url`. При успехе выбирается WS, иначе MCP. Для `transport=ws` без живого менеджера запуск падает с ошибкой `session-manager unreachable at `. Сам менеджер v8-runner не запускает — его нужно поднять отдельно -(см. соседний репозиторий [`v8-client-session-manager`](../../v8-client-session-manager/)). +(см. репозиторий [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager)). `extension` поддерживает: @@ -510,12 +537,21 @@ source-set build, а `launch mcp` и `launch mcp va` расширение не неизменёнными исходниками пропускает export/load, а `build --full-rebuild` принудительно обновляет расширение. +`v8-runner tools download client-mcp` может заполнить этот блок в `v8project.local.yaml`: +с `--sources` он указывает `source.path` на +`build/tools/onec-client-mcp-devkit/exts/client-mcp` и `source.format: EDT`, без +`--sources` указывает `artifact.path` на скачанный `client_mcp.cfe`. Artifact-режим +доступен только для `builder=DESIGNER`; для `builder=IBCMD` используйте `--sources`. + ### `tools.va` Поддержанные поля: - `epf_path`, путь к внешней обработке Vanessa Automation. +`v8-runner tools download vanessa` заполняет `tools.va.epf_path` в `v8project.local.yaml` путём +`build/tools/vanessa-automation-single.epf`. + ## `tools.platform` ### `tools.platform.path` diff --git a/docs/DEEP_DIVE.md b/docs/DEEP_DIVE.md index e9c71fc..325a0f9 100644 --- a/docs/DEEP_DIVE.md +++ b/docs/DEEP_DIVE.md @@ -66,7 +66,9 @@ runtime snapshot commit только указанным source-set. `test` и `syntax` проектируются как часть того же локального цикла, а не как отдельная эксплуатационная подсистема. -- `test` всегда сначала делает `build`, затем запускает YaXUnit или Vanessa Automation. +- `test` всегда сначала делает `build` со статическим `/UpdateDBCfg`, затем запускает YaXUnit или + Vanessa Automation. Динамическая подготовка перед тестами выполняется отдельным + `build --dynamic`. - `syntax designer-*` работает только для `DESIGNER` source format. - `syntax edt` использует EDT `validate` и привязан к `format=EDT`. - Таймауты и interruption metadata должны проходить через общий command-level contract, а не @@ -116,7 +118,7 @@ execution model для CLI и MCP. `workPath` является корнем runtime state. -- Логи, temp files, generated outputs и persisted snapshots не должны расползаться по `basePath`. +- Логи, temp files, generated outputs и persisted snapshots не должны расползаться по каталогу primary config. - Public CLI/MCP команды, работающие с runtime state под `workPath`, должны брать workspace lock. - Workspace lock сериализует доступ к конкретному runtime root, но не заменяет admission limits и не делает multi-step orchestration fully atomic. diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 85a20c9..dd9b9b5 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -224,12 +224,17 @@ }, "transport": { "description": "Machine-local override of the default MCP client transport.", + "enum": [ + "ws", + "mcp", + "auto" + ], "type": "string" }, "ws_timeout_ms": { "description": "Machine-local override of the default `mcp_ws_timeout_ms`.", "format": "uint64", - "minimum": 0, + "minimum": 1, "type": "integer" } }, @@ -260,6 +265,13 @@ "null" ] }, + "unlock_code": { + "description": "Optional local infobase unlock code. Non-empty value is propagated as `/UC `;\nempty string means no unlock code and `/UC` is not passed. Masked in command logs.", + "type": [ + "string", + "null" + ] + }, "user": { "description": "Optional local infobase user name.", "type": [ @@ -570,7 +582,7 @@ "type": "object" } }, - "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.local.schema.json", + "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index 18811f7..ca8fe78 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -3,6 +3,10 @@ "BuildSchema": { "additionalProperties": false, "properties": { + "dynamicUpdate": { + "description": "Default `/UpdateDBCfg -Dynamic+` toggle for `build`. CLI `--dynamic` overrides this.", + "type": "boolean" + }, "partialLoadThreshold": { "description": "Maximum changed-file count for partial Designer load before falling back to full load.", "format": "uint", @@ -41,7 +45,7 @@ ] }, "manager_url": { - "description": "Default WS endpoint for the session-manager.", + "description": "Default WS endpoint with IP address and port for the session-manager.", "type": [ "string", "null" @@ -58,7 +62,13 @@ ] }, "transport": { - "description": "Default transport for the MCP client side: `ws`, `legacy` or `auto`.", + "description": "Default transport for the MCP client side: `ws`, `mcp` or `auto`.", + "enum": [ + "ws", + "mcp", + "auto", + null + ], "type": [ "string", "null" @@ -67,7 +77,7 @@ "ws_timeout_ms": { "description": "Default `mcp_ws_timeout_ms` value forwarded into the `/C` payload.", "format": "uint64", - "minimum": 0, + "minimum": 1, "type": [ "integer", "null" @@ -228,6 +238,13 @@ "null" ] }, + "unlock_code": { + "description": "Optional unlock code. Non-empty value is propagated as `/UC ` to DESIGNER;\nempty string means no unlock code and `/UC` is not passed. Masked in command logs.", + "type": [ + "string", + "null" + ] + }, "user": { "description": "Optional infobase user name passed to platform utilities.", "type": [ @@ -347,7 +364,7 @@ "type": "string" }, "path": { - "description": "Source path relative to `basePath` or an EDT project path.", + "description": "Source path relative to the primary config directory or an EDT project path.", "type": "string" }, "type": { @@ -623,14 +640,10 @@ "type": "object" } }, - "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v0.4.2/docs/schemas/v8project.schema.json", + "$id": "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { - "basePath": { - "description": "Root directory for project sources; defaults to the directory containing `v8project.yaml`.", - "type": "string" - }, "build": { "$ref": "#/$defs/BuildSchema", "description": "Build pipeline settings." diff --git a/examples/live-cli-designer.fixture.yaml b/examples/live-cli-designer.fixture.yaml index 6cbf77d..3ddf535 100644 --- a/examples/live-cli-designer.fixture.yaml +++ b/examples/live-cli-designer.fixture.yaml @@ -1,7 +1,6 @@ # Fixture-based live smoke config for scripts/test/live-cli-fixture.sh # Copy this file, adjust the absolute paths, and export it as V8TR_DESIGNER_REAL_CONFIG. -basePath: /abs/path/to/repo/tests/fixtures/designer workPath: /abs/path/to/repo/target/manual-tests/live-cli-fixture/work format: DESIGNER builder: DESIGNER diff --git a/examples/v8project.yaml b/examples/v8project.yaml index 9154979..f41e96c 100644 --- a/examples/v8project.yaml +++ b/examples/v8project.yaml @@ -1,10 +1,6 @@ # Example configuration for v8-runner # Copy to v8project.yaml and adjust paths for your project -# Root path of the project sources (Designer format). -# Optional: when omitted, v8-runner uses the directory containing v8project.yaml. -basePath: /path/to/project/sources - # Working directory for temp files and hash storages workPath: build @@ -22,7 +18,7 @@ infobase: source-set: - name: main type: CONFIGURATION - path: . # relative to basePath + path: . # relative to v8project.yaml # Uncomment to add an extension: # - name: my-extension diff --git a/scripts/test/live-cli-designer.fixture.yaml b/scripts/test/live-cli-designer.fixture.yaml index 55b6f0a..e79839b 100644 --- a/scripts/test/live-cli-designer.fixture.yaml +++ b/scripts/test/live-cli-designer.fixture.yaml @@ -1,7 +1,6 @@ # Default live smoke config for scripts/test/live-cli-fixture.sh. # The script resolves __ROOT_DIR__ and __OUTPUT_ROOT__ into an isolated workspace. -basePath: __OUTPUT_ROOT__/workspace/basePath workPath: __OUTPUT_ROOT__/work format: DESIGNER builder: DESIGNER diff --git a/scripts/test/live-cli-fixture.sh b/scripts/test/live-cli-fixture.sh index 0420c3d..6eb978c 100755 --- a/scripts/test/live-cli-fixture.sh +++ b/scripts/test/live-cli-fixture.sh @@ -360,17 +360,6 @@ replacements = { for needle, replacement in replacements.items(): text = text.replace(needle, replacement) -if re.search(r"^\s*basePath:\s*.*$", text, re.MULTILINE): - text = re.sub( - r"^\s*basePath:\s*.*$", - f"basePath: {work_base_path.as_posix()}", - text, - count=1, - flags=re.MULTILINE, - ) -else: - raise SystemExit("live config must define basePath") - target.write_text(text, encoding="utf-8") PY } @@ -547,8 +536,8 @@ EXTERNAL_PROCESSOR_SOURCE_SET_PATH="${SOURCE_SET_PATH_BY_TYPE[EXTERNAL_DATA_PROC EXTERNAL_REPORT_SOURCE_SET_NAME="${SOURCE_SET_NAME_BY_TYPE[EXTERNAL_REPORTS]:-}" EXTERNAL_REPORT_SOURCE_SET_PATH="${SOURCE_SET_PATH_BY_TYPE[EXTERNAL_REPORTS]:-}" -WORK_BASE_PATH="$OUTPUT_ROOT/workspace/basePath" -WORK_CONFIG_PATH="$OUTPUT_ROOT/json/live-designer.config.yaml" +WORK_BASE_PATH="$OUTPUT_ROOT/workspace/project-root" +WORK_CONFIG_PATH="$WORK_BASE_PATH/v8project.yaml" if [[ ! -d "$FIXTURE_BASE_PATH" ]]; then die "Fixture source directory not found: $FIXTURE_BASE_PATH" @@ -564,7 +553,6 @@ mkdir -p \ "$WORK_BASE_PATH" \ "$OUTPUT_ROOT/artifacts/external-processor" \ "$OUTPUT_ROOT/artifacts/external-report" \ - "$OUTPUT_ROOT/json" \ "$OUTPUT_ROOT/launch" cp -R "$FIXTURE_BASE_PATH/." "$WORK_BASE_PATH/" @@ -575,7 +563,7 @@ DESIGNER_CONFIG_PATH="$WORK_CONFIG_PATH" for source_set_type in "${required_types[@]}"; do source_set_path="${SOURCE_SET_PATH_BY_TYPE[$source_set_type]}" if [[ ! -d "$WORK_BASE_PATH/$source_set_path" ]]; then - die "Configured source-set path does not exist under fixture basePath: $source_set_path" + die "Configured source-set path does not exist under fixture project root: $source_set_path" fi done diff --git a/spec/IMPLEMENTATION_TODO.md b/spec/IMPLEMENTATION_TODO.md index bc81418..68c884d 100644 --- a/spec/IMPLEMENTATION_TODO.md +++ b/spec/IMPLEMENTATION_TODO.md @@ -4,7 +4,7 @@ This file tracks open implementation work only. ## Current Status -- Open tasks as of `2026-05-03`: 0. +- Open tasks as of `2026-05-11`: 0. ## Open Tasks @@ -35,3 +35,5 @@ No open implementation tasks. closed source-backed tool extension change-detection task. - [spec/archive/completed-tasks-t25.md](archive/completed-tasks-t25.md): closed JSON Schema descriptions and config alias removal task. +- [spec/archive/completed-tasks-t26.md](archive/completed-tasks-t26.md): + closed post-T25 public `basePath` removal and schema URL follow-up. diff --git a/spec/acceptance/real-environment-validation.md b/spec/acceptance/real-environment-validation.md index 6446a70..5d96939 100644 --- a/spec/acceptance/real-environment-validation.md +++ b/spec/acceptance/real-environment-validation.md @@ -126,7 +126,7 @@ bash scripts/test/live-cli-fixture.sh - `format: DESIGNER` - `builder: DESIGNER` - файловое подключение `File=...` или raw `/F ...` -- `basePath`, резолвящийся в `tests/fixtures/designer` +- primary config directory, резолвящийся в `tests/fixtures/designer` - source-set'ы для `configuration`, `extension`, `external-processor`, `external-report` - заданный `tools.platform.path` или внешний override `V8TR_PLATFORM_PATH` diff --git a/spec/architecture/arc42/04-solution-strategy.md b/spec/architecture/arc42/04-solution-strategy.md index f35d8fc..1028131 100644 --- a/spec/architecture/arc42/04-solution-strategy.md +++ b/spec/architecture/arc42/04-solution-strategy.md @@ -13,6 +13,7 @@ - Публичные команды над одним canonical `workPath` сериализуются через workspace lock; nested flows используют явные unlocked entrypoints только под внешним lock. - Timeout/cancellation реализуются поверх общего execution core по host-specific policy: MCP может отсоединять caller от running EDT работы и удерживать capacity до terminal state, а CLI blocking flows ждут terminal cleanup или принудительно закрывают свой shared EDT manager перед возвратом. - Full replacement `dump` и `artifacts` публикуются через staging/backup, чтобы platform failure до publish сохранял старый target. +- `tools download` остаётся CLI-only bootstrap-сценарием: он материализует внешние release assets в рабочие каталоги проекта и обновляет local overlay, но не расширяет MCP surface и не выполняет platform load в ИБ. - Runner-like сценарии используют общий execution grammar: pipeline vocabulary, step entries и `ExecutionOutcome` как canonical domain outcome. - Общий интерактивный EDT actor вынесен в `platform::edt_session` и переиспользуется и MCP `check_syntax_edt`, и CLI interactive EDT use cases; различается только host policy (MCP может prewarm shared host, CLI остаётся lazy и short-lived). - Архитектура оптимизирована под agent-friendly contracts: use case возвращают transport-neutral DTO и структурированные failure payload, а логика представления остаётся на границе адаптера. diff --git a/spec/architecture/arc42/05-building-block-view.md b/spec/architecture/arc42/05-building-block-view.md index cb00d56..bd08a34 100644 --- a/spec/architecture/arc42/05-building-block-view.md +++ b/spec/architecture/arc42/05-building-block-view.md @@ -37,22 +37,23 @@ flowchart TB - Преобразует аргументы `clap` в транспортно-нейтральные запросы. - Отвечает за разбор аргументов и CLI-специфичный рендеринг результатов. -- Публикует команды `config init`, `init`, `extensions`, `build`, `load`, `test`, `dump`, `convert`, `make`/`artifacts`, `syntax`, `launch` и `mcp`. +- Публикует команды `config init`, `tools download`, `init`, `extensions`, `build`, `load`, `test`, `dump`, `convert`, `make`/`artifacts`, `syntax`, `launch` и `mcp`. #### `use_cases` -- Центральная оркестрация для `config init`, `init`, `extensions`, `build`, `load`, `test`, `dump`, `convert`, `artifacts`, `syntax` и `launch`. +- Центральная оркестрация для `config init`, `tools download`, `init`, `extensions`, `build`, `load`, `test`, `dump`, `convert`, `artifacts`, `syntax` и `launch`. - Определяет transport-neutral request/result contracts, которые должны оставаться стабильной внутренней опорой для адаптеров и AI-агентов, работающих через эти адаптеры. - Предоставляет workspace lock helper и internal unlocked entrypoints для nested flows вроде `test -> build`; public lock boundary остаётся в CLI/MCP adapters. - Для runner-like сценариев собирает typed pipeline-like flow и заполняет `ExecutionOutcome` вместо нового ad hoc result shape. - Для `convert` выводит direction из `format`, резолвит `source-set` из `v8project.yaml` и публикует generated output либо под default `workPath/convert/out`, либо под explicit `--output` root с mirror-layout. +- Для `tools download ` получает latest release metadata выбранного инструмента, скачивает sources/artifact, обновляет `v8project.local.yaml` для Vanessa/client MCP и при `yaxunit --sources` добавляет YAxUnit как project `source-set` `tests`. #### `mcp` - Преобразует MCP tool-запросы в запросы use case. - Публикует восемь текущих MCP-инструментов. - Обрабатывает stdio- и HTTP-транспорты, трекинг сессий, execution admission, HTTP session capacity и общий EDT actor-path. -- Намеренно не публикует весь CLI: `config init`, `init`, `extensions`, `load`, `convert` и `make`/`artifacts` остаются CLI-only сценариями. +- Намеренно не публикует весь CLI: `config init`, `tools download`, `init`, `extensions`, `load`, `convert` и `make`/`artifacts` остаются CLI-only сценариями. #### `platform` diff --git a/spec/architecture/arc42/06-runtime-view.md b/spec/architecture/arc42/06-runtime-view.md index 43819ec..2884466 100644 --- a/spec/architecture/arc42/06-runtime-view.md +++ b/spec/architecture/arc42/06-runtime-view.md @@ -71,7 +71,30 @@ sequenceDiagram - Используется как более узкий operational path по сравнению с `build`, когда нужно синхронизировать свойства расширений без полной загрузки исходников. - Так как операция мутирует ИБ, будущая общая execution policy должна помечать соответствующий platform step как critical DB phase. -### 6.4 Сценарий MCP EDT Syntax +### 6.4 Сценарий `tools download` + +- CLI adapter получает workspace lock, потому что команда меняет primary config, local overlay и + локальные tool directories. +- Use case читает latest release metadata для выбранной команды: `yaxunit`, `vanessa` или + `client-mcp`. +- Для `yaxunit --sources` распаковывается source subtree в `tests`; primary config получает + `source-set` `tests`, если его ещё нет. Без `--sources` скачивается `.cfe` в `build/tools`. +- Для `client-mcp --sources` распаковывается source subtree в + `build/tools/onec-client-mcp-devkit/exts/client-mcp`; без `--sources` команда требует + `builder=DESIGNER` и скачивает `.cfe` в `build/tools`. +- Vanessa Automation single материализуется командой `vanessa` как + `build/tools/vanessa-automation-single.epf`. +- `v8project.local.yaml` обновляется machine-local настройками `tools.va.epf_path` для + `vanessa` и `tools.client_mcp.extension` для `client-mcp`; загрузка не устанавливает + расширения в ИБ, не подменяет `build` и при `--force` заменяет только managed targets, + созданные этой командой. +- Managed target фиксируется sidecar marker-файлом до publish phase. Если публикация скачанного + файла или каталога завершается ошибкой, новый marker очищается, чтобы следующий запуск не считал + неуспешный target управляемым. +- HTTP download path ограничивает response body 512 MiB и прерывает сценарий до распаковки или + публикации, если release asset или source archive превышает лимит. + +### 6.5 Сценарий MCP EDT Syntax - MCP-запрос приходит через stdio или HTTP. - Глобальный admission control ограничивает параллельные tool-вызовы. @@ -79,7 +102,7 @@ sequenceDiagram - Ожидание в очереди, baseline reset/probe и выполнение команды используют один и тот же ограниченный бюджет таймаута. - Host policy различается: MCP может отпустить caller после running cancel/timeout и дождаться terminal state асинхронно внутри shared actor, а CLI blocking adapter ждёт terminal cleanup или завершает собственный short-lived manager принудительно перед возвратом. -### 6.5 Full Replacement `dump` / `artifacts` Publication +### 6.6 Full Replacement `dump` / `artifacts` Publication ```mermaid sequenceDiagram @@ -117,7 +140,7 @@ sequenceDiagram - `dump incremental` и `dump partial` не получают full replacement guarantee и остаются non-atomic update modes. - Publication phase после переноса старого target в backup является filesystem critical phase. -### 6.6 Command Boundary, Admission и Cancellation +### 6.7 Command Boundary, Admission и Cancellation - CLI и MCP используют разные public surfaces, но сходятся в transport-neutral use case boundary. - MCP tool call сначала проходит execution admission; HTTP session capacity проверяется отдельно на transport lifecycle. diff --git a/spec/architecture/invariants.md b/spec/architecture/invariants.md index fcdd37d..a93e422 100644 --- a/spec/architecture/invariants.md +++ b/spec/architecture/invariants.md @@ -39,7 +39,7 @@ 12. Unsupported или unsafe config combinations должны отклоняться на validation boundary до вызова platform DSL. 13. Новый public config field, `source-set` type или `infobase` subtree требует typed model, validation, `config init`/examples/docs sync и regression tests по checklist из `spec/architecture/change-checklist.md`. 14. `v8project.local.yaml` является optional local overlay рядом с primary config, применяется после `v8project.yaml` и до CLI overrides, не является самостоятельным `--config` entrypoint и не должен менять `source-set`, `format` или `builder`. -15. Если `basePath` отсутствует в итоговом YAML после overlay merge, он считается равным каталогу primary config. +15. `basePath` не является public key в `v8project.yaml`; внутренний project base path считается равным каталогу primary config. 16. Tool extensions, включая `tools.client_mcp.extension`, не являются project `source-set`; их подготовка выполняется через общий механизм подготовки расширений на стадии `build`, а не на стадии `launch`. См. [ADR-0017](../decisions/0017-v8project-yaml-source-set-kak-glavnyy-konfiguratsionnyy-kontrakt.md), [ADR-0018](../decisions/0018-perenesti-kontrakt-informatsionnoy-bazy-v-infobase.md), [ADR-0019](../decisions/0019-sozdavat-servernuyu-infobazu-cherez-ibcmd-pri-init-pri-otsutstvii.md), [ADR-0021](../decisions/0021-lokalnyy-overlay-config.md) и [ADR-0022](../decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md). diff --git a/spec/archive/completed-tasks-t23.md b/spec/archive/completed-tasks-t23.md index 0ae9e47..c9c1382 100644 --- a/spec/archive/completed-tasks-t23.md +++ b/spec/archive/completed-tasks-t23.md @@ -14,6 +14,13 @@ Implemented scope: schema URL for the current application version. - Documented VS Code setup, local overlay schema usage and schema versioning policy. +Current status after `2026-05-11` follow-up: + +- `basePath` is no longer a public YAML key; the project base path is derived from the primary + config directory. +- Generated schema `$id` values and `yaml-language-server` modelines now point to the published + `master` schema artifacts. + Verification: - `cargo test --locked config::schema::tests` diff --git a/spec/archive/completed-tasks-t26.md b/spec/archive/completed-tasks-t26.md new file mode 100644 index 0000000..d35557e --- /dev/null +++ b/spec/archive/completed-tasks-t26.md @@ -0,0 +1,32 @@ +# Completed Task T26 + +## T26: Sync public basePath removal and master schema URLs + +Status: completed on `2026-05-11`. + +Implemented scope: + +- `config init --output ` resolves generated `source-set[].path` relative to the selected + primary config directory, not necessarily the current working directory. +- `basePath` was removed from the public YAML contract; the internal project base path is derived + from the primary config directory. +- Generated JSON Schema `$id` values and `yaml-language-server` modelines point to the published + `master` schema artifacts, not release-tag schema URLs. + +Specification surfaces synchronized: + +- `spec/decisions/0021-lokalnyy-overlay-config.md` +- `spec/architecture/invariants.md` +- `spec/acceptance/real-environment-validation.md` +- `spec/archive/completed-tasks-t23.md` +- `docs/CONFIGURATION.md` +- `docs/CAPABILITIES.md` +- `docs/DEEP_DIVE.md` +- `README.md` +- `SKILL/SKILL.md` and focused skill references + +Verification: + +- `cargo test --locked generated_schema` +- `cargo test --locked config_init` +- `cargo test --locked cli_config_init` diff --git a/spec/decisions/0002-izolirovat-runtime-state-po-source-set-pod-workpath.md b/spec/decisions/0002-izolirovat-runtime-state-po-source-set-pod-workpath.md index f628fc3..7b7ce32 100644 --- a/spec/decisions/0002-izolirovat-runtime-state-po-source-set-pod-workpath.md +++ b/spec/decisions/0002-izolirovat-runtime-state-po-source-set-pod-workpath.md @@ -48,7 +48,7 @@ EDT source-set Правила: -1. `edt-` хранит состояние основных EDT-исходников из `basePath/source-set.path`. +1. `edt-` хранит состояние основных EDT-исходников из project root + `source-set.path`. 2. `edt-` используется только для решения, нужна ли конвертация/export EDT source-set в Designer-формат. 3. `designer-` хранит состояние generated Designer-файлов под `workPath/designer/`. 4. `designer-` используется для решения, какие Designer-файлы грузить: partial или full. @@ -67,7 +67,7 @@ EDT source-set ## Неграницы (Non-goals) 1. Не вводить единый глобальный hash storage на весь проект. -2. Не писать runtime-артефакты в `basePath` или каталоги пользовательских исходников. +2. Не писать runtime-артефакты в project root или каталоги пользовательских исходников. 3. Не считать `workPath/designer/` пользовательскими исходниками. 4. Не обещать атомарность `build` по нескольким `source-set`. 5. Не менять публичную YAML-модель `source-set` без отдельного решения. diff --git a/spec/decisions/0020-dobavit-cli-only-convert-dlya-dvustoronney-konvertatsii-edt-i-designer.md b/spec/decisions/0020-dobavit-cli-only-convert-dlya-dvustoronney-konvertatsii-edt-i-designer.md index c29c378..b5433c7 100644 --- a/spec/decisions/0020-dobavit-cli-only-convert-dlya-dvustoronney-konvertatsii-edt-i-designer.md +++ b/spec/decisions/0020-dobavit-cli-only-convert-dlya-dvustoronney-konvertatsii-edt-i-designer.md @@ -53,13 +53,13 @@ 8. Флаги `--source`, `--target`, `--version`, `--base-project-name` и `--build` не являются частью целевого public contract. 9. Внутренние EDT import/export параметры должны выводиться из `config.format`, `source-set` semantics, project metadata и tool discovery/config hints, а не требовать явного user-facing флага. 10. Без `--output` результат `convert` публикуется в owned generated directories под `workPath/convert/out///`. -11. С `--output ` команда публикует выбранные `source-set` под заданным target root, зеркаля logical source-set path относительно `basePath`, например `configuration`, `extension`, `external/processor`. +11. С `--output ` команда публикует выбранные `source-set` под заданным target root, зеркаля logical source-set path относительно каталога primary config, например `configuration`, `extension`, `external/processor`. 12. `--output` задаёт только target root, а не произвольные пары `source`/`target`; direction и список source-set по-прежнему выводятся из `v8project.yaml`. 13. Реальная EDT execution должна использовать тот же supported execution model, что и остальные EDT-сценарии: one-shot или shared interactive в зависимости от `tools.edt_cli.interactive_mode`. 14. Runtime state EDT для команды должен жить в отдельном рабочем каталоге `workPath/convert/edt-workspace`, а не переиспользовать `workPath/edt-workspace` из `init` и других EDT-сценариев. 15. Как и другие public команды с runtime state под `workPath`, `convert` должен брать workspace lock на adapter boundary по ADR-0011. 16. Validation, не требующая владения `workPath`, может выполняться до захвата lock, чтобы пользователь получал deterministic validation error раньше workspace-conflict error. -17. Публикация результата должна использовать full-replacement staging/backup contract по ADR-0015 для каждого resolved target; `basePath`, исходные каталоги проекта и пересекающиеся target paths не являются допустимыми publish target. +17. Публикация результата должна использовать full-replacement staging/backup contract по ADR-0015 для каждого resolved target; каталог primary config, исходные каталоги проекта и пересекающиеся target paths не являются допустимыми publish target. 18. Для `DESIGNER -> EDT` staging path не должен протекать в имена сгенерированных EDT-проектов: target project directory выбирается стабильно из logical source-set path/name до атомарной публикации. 19. Историческая path-based реализация `convert` была transition state; текущий public contract считается repo-aware только через `v8project.yaml`, `source-set` scope и optional target-root `--output`. 20. Это решение не расширяет контракт `dump`: реализованный `dump format=EDT` остаётся отдельной командной семантикой "ИБ -> файлы", а не thin alias поверх `convert`. @@ -124,7 +124,7 @@ - stable generated EDT project names; - validation-before-lock и busy workspace conflict; - one-shot и shared interactive execution paths; - - запрет destructive overlap по отношению к `basePath`. + - запрет destructive overlap по отношению к каталогу primary config. 7. После фактической реализации синхронизировать: - `README.md`; - `docs/CAPABILITIES.md`; diff --git a/spec/decisions/0021-lokalnyy-overlay-config.md b/spec/decisions/0021-lokalnyy-overlay-config.md index cfc737e..8286b48 100644 --- a/spec/decisions/0021-lokalnyy-overlay-config.md +++ b/spec/decisions/0021-lokalnyy-overlay-config.md @@ -45,7 +45,7 @@ Loader строит итоговую конфигурацию так: 3. List значения заменяются целиком. 4. `null` разрешён только для optional fields и означает явный сброс значения. 5. Относительные пути из local overlay резолвятся относительно каталога основного `v8project.yaml`. -6. Если `basePath` не задан в итоговом config, он считается равным каталогу основного `v8project.yaml`. +6. Внутренний project base path считается равным каталогу основного `v8project.yaml`; YAML-ключ `basePath` не является public contract. ### Supported local overlay scope @@ -74,13 +74,13 @@ Local overlay не должен менять project identity: 1. Типовой запуск остаётся коротким и не требует локальных флагов. 2. Общий `v8project.yaml` остаётся project truth, а локальные пути и credentials уходят в gitignored `v8project.local.yaml`. -3. `basePath` перестаёт быть обязательным в типовом config и по умолчанию совпадает с каталогом основного config. +3. `basePath` удалён из public config surface; внутренний `AppConfig.base_path` по умолчанию совпадает с каталогом основного config. 4. Реализация должна синхронизировать typed config model, loader, validation, docs, examples and tests. ## План реализации 1. `src/config/model.rs`: - - сделать `basePath` optional на YAML boundary или ввести raw config model; + - удалить `basePath` с YAML boundary; - сохранить итоговый `AppConfig.base_path` как resolved `PathBuf`. 2. `src/config/loader.rs`: - читать `v8project.local.yaml` рядом с primary config, если файл существует; @@ -88,18 +88,18 @@ Local overlay не должен менять project identity: - отклонять local overlay keys `source-set`, `format`, `builder`; - резолвить overlay paths относительно primary config directory. 3. `docs/CONFIGURATION.md`, examples: - - описать local overlay и default `basePath`; + - описать local overlay и project root от каталога primary config; - указать, что `v8project.local.yaml` должен быть gitignored. 4. Tests: - покрыть automatic overlay discovery; - overlay merge rules; - forbidden local keys; - - `basePath` default; + - default внутреннего `AppConfig.base_path`; - precedence `project -> local -> CLI override`. ## Верификация - [x] `v8-runner build` автоматически применяет `v8project.local.yaml` без дополнительных CLI-флагов. - [x] `source-set`, `format` и `builder` в `v8project.local.yaml` отклоняются как unsupported local overlay keys. -- [x] Отсутствующий `basePath` резолвится в каталог основного `v8project.yaml`. +- [x] Внутренний `AppConfig.base_path` резолвится в каталог основного `v8project.yaml`. - [x] `--workdir` остаётся сильнее local overlay. diff --git a/src/app.rs b/src/app.rs index 406074f..f193d09 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,12 +2,14 @@ use clap::Parser; use tracing::{debug, error}; use crate::cli::args::{ - Cli, Command, ConfigCommand, ConfigInitArgs, McpCommand, McpServeTransport, + Cli, Command, ConfigCommand, ConfigInitArgs, McpCommand, McpServeTransport, ToolsCommand, }; use crate::cli::execute; use crate::cli::output::print_command_error; use crate::command_envelope::Envelope; -use crate::config::loader::load_config; +use crate::config::loader::{ + load_config, load_config_for_tools_download, resolve_primary_config_path, +}; use crate::output::presenter::Presenter; use crate::output::text::{TimelineItem, TimelineStatus}; use crate::support::error::AppError; @@ -35,7 +37,7 @@ pub fn run() -> i32 { return run_config_command(args, &presenter); } - let config = match load_config(cli.config.as_deref(), cli.workdir.as_deref()) { + let config = match load_cli_config(&cli) { Ok(c) => c, Err(e) => { let message = e.to_string(); @@ -44,6 +46,15 @@ pub fn run() -> i32 { return error.exit_code(); } }; + let primary_config_path = match resolve_primary_config_path(cli.config.as_deref()) { + Ok(path) => path, + Err(e) => { + let message = e.to_string(); + let error = UseCaseError::from(AppError::from(e)); + print_command_error(&presenter, command_name(&cli.command), &error, &message); + return error.exit_code(); + } + }; let level = cli.log_level.as_deref().unwrap_or("info"); let action_log_path = match crate::support::logging::init_action_logging( @@ -74,6 +85,7 @@ pub fn run() -> i32 { let result = match &cli.command { Command::Init | Command::Config(_) + | Command::Tools(_) | Command::Extensions(_) | Command::Build(_) | Command::Load(_) @@ -85,6 +97,7 @@ pub fn run() -> i32 { | Command::Launch(_) => execute::execute_command( &config, &cli.command, + Some(primary_config_path), &presenter, cli.clean_before_execution, ), @@ -110,6 +123,21 @@ pub fn run() -> i32 { } } +fn load_cli_config( + cli: &Cli, +) -> Result { + if matches!( + &cli.command, + Command::Tools(crate::cli::args::ToolsArgs { + command: ToolsCommand::Download(_) + }) + ) { + load_config_for_tools_download(cli.config.as_deref(), cli.workdir.as_deref()) + } else { + load_config(cli.config.as_deref(), cli.workdir.as_deref()) + } +} + fn command_name(command: &Command) -> &'static str { match command { Command::Config(_) => "config", @@ -184,6 +212,8 @@ fn render_config_init_text( ) { let mut details = vec![ format!("path: {}", result.path), + format!("local path: {}", result.local_path), + format!("gitignore: {}", result.gitignore_path), format!("format: {}", result.format), format!("builder: {}", result.builder), ]; diff --git a/src/change_detection/partial_load.rs b/src/change_detection/partial_load.rs index 97e5ca9..9342bed 100644 --- a/src/change_detection/partial_load.rs +++ b/src/change_detection/partial_load.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use crate::change_detection::analyzer::{ChangeKind, FileChange}; @@ -9,6 +9,61 @@ pub const DEFAULT_PARTIAL_LOAD_THRESHOLD: usize = 20; /// The name of the root configuration descriptor — if changed, partial load is forbidden. const CONFIGURATION_XML: &str = "Configuration.xml"; +/// Top-level metadata-type directory names in the 1C Designer Hierarchical layout. +/// +/// When a `.bsl` change is located under `///...`, the +/// owning XML descriptor lives at `//.xml` (one level +/// above the object directory) and the related files for the object live under +/// `///`. Without this whitelist we cannot reliably +/// reconstruct the owning object from a deeply nested BSL path (think +/// `Documents//Forms/
/Ext/Form/Module.bsl`). +const METADATA_TYPES: &[&str] = &[ + "AccountingRegisters", + "AccumulationRegisters", + "BusinessProcesses", + "CalculationRegisters", + "Catalogs", + "ChartsOfAccounts", + "ChartsOfCalculationTypes", + "ChartsOfCharacteristicTypes", + "CommandGroups", + "CommonAttributes", + "CommonCommands", + "CommonForms", + "CommonModules", + "CommonPictures", + "CommonTemplates", + "Constants", + "DataProcessors", + "DefinedTypes", + "DocumentJournals", + "Documents", + "Enums", + "EventSubscriptions", + "ExchangePlans", + "ExternalDataSources", + "FilterCriteria", + "FunctionalOptions", + "FunctionalOptionsParameters", + "HTTPServices", + "InformationRegisters", + "Interfaces", + "Languages", + "Reports", + "Roles", + "ScheduledJobs", + "Sequences", + "SessionParameters", + "SettingsStorages", + "StyleItems", + "Styles", + "Subsystems", + "Tasks", + "WSReferences", + "WebServices", + "XDTOPackages", +]; + /// Decision made by [`decide`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LoadDecision { @@ -52,7 +107,7 @@ pub fn decide(changes: &[FileChange], source_root: &Path, threshold: usize) -> L /// Write a partial-load list file (UTF-8, one path per line, no empty lines). /// /// Paths are written relative to `source_root` as required by Designer's -/// `-listFile` parameter when running in agent mode. +/// `-listFile` parameter. pub fn write_list_file(paths: &[PathBuf], source_root: &Path, dest: &Path) -> std::io::Result<()> { let rel_paths = relative_paths(paths, source_root)?; let lines = rel_paths @@ -98,16 +153,29 @@ fn expand_files(changes: &[FileChange], source_root: &Path) -> Option не входит в состав объекта метаданных Configuration". + // So for every changed BSL we resolve /// + // and add the XML descriptor plus everything inside the object directory. + if !is_bsl(&change.path) { + continue; + } + + let Some(owner) = locate_metadata_object(&change.path, source_root) else { + continue; + }; + + push_if_safe_if_exists(&mut paths, &owner.xml, source_root, &root_real)?; - if let Some(object_dir) = object_dir(&change.path, source_root) { - push_if_safe_if_exists(&mut paths, &object_dir, source_root, &root_real)?; - } + if owner.dir.is_dir() { + collect_object_directory(&mut paths, &owner.dir, source_root, &root_real)?; } } @@ -116,6 +184,90 @@ fn expand_files(changes: &[FileChange], source_root: &Path) -> Option//.xml` — the XML descriptor Designer + /// uses to recognise the object's type and name. + xml: PathBuf, + /// `///` — contents recurse here for forms, + /// templates, manager/object modules, etc. + dir: PathBuf, +} + +/// Walks the relative path components looking for the first known metadata-type +/// directory followed by an object name. Returns the XML descriptor + object +/// directory pair, or `None` if the file does not sit inside a recognised +/// metadata container. +fn locate_metadata_object(bsl: &Path, source_root: &Path) -> Option { + let relative = bsl.strip_prefix(source_root).ok()?; + let components: Vec<&str> = relative + .components() + .filter_map(|component| match component { + Component::Normal(part) => part.to_str(), + _ => None, + }) + .collect(); + + // Need at least //.bsl to make sense: + // a `.bsl` directly inside / is not a real Hierarchical layout. + for (idx, component) in components.iter().enumerate() { + if !is_metadata_type(component) { + continue; + } + if idx + 2 > components.len() { + // No object name after the metadata-type directory. + continue; + } + let object_name = components[idx + 1]; + // Sanity: the object name must not be a file (e.g. nested BSL right under + // /); skip and keep searching. + if object_name.ends_with(".bsl") || object_name.ends_with(".xml") { + continue; + } + + let mut xml = source_root.to_path_buf(); + let mut dir = source_root.to_path_buf(); + for prefix in &components[..=idx] { + xml.push(prefix); + dir.push(prefix); + } + xml.push(format!("{object_name}.xml")); + dir.push(object_name); + return Some(MetadataOwner { xml, dir }); + } + + None +} + +fn is_metadata_type(name: &str) -> bool { + METADATA_TYPES.iter().any(|known| *known == name) +} + +/// Recursively adds every regular file under `dir` (relative to `source_root`) +/// into `paths`. Unreadable entries are silently skipped — the goal is best +/// effort enumeration of the owning object's tree, not a strict invariant. +fn collect_object_directory( + paths: &mut Vec, + dir: &Path, + source_root: &Path, + root_real: &Path, +) -> Option<()> { + let Ok(entries) = std::fs::read_dir(dir) else { + return Some(()); + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_object_directory(paths, &path, source_root, root_real)?; + } else if path.is_file() { + push_if_safe(paths, &path, source_root, root_real)?; + } + } + Some(()) +} + fn push_if_safe( paths: &mut Vec, candidate: &Path, @@ -174,33 +326,6 @@ fn is_bsl(path: &Path) -> bool { .unwrap_or(false) } -/// Return the XML descriptor alongside a `.bsl` file (same name, `.xml` ext). -fn sibling_xml(bsl: &Path) -> Option { - let parent = bsl.parent()?; - let stem = bsl.file_stem()?.to_str()?; - Some(parent.join(format!("{stem}.xml"))) -} - -/// Return the object directory that owns a `.bsl` module. -fn object_dir(bsl: &Path, source_root: &Path) -> Option { - let parent = bsl.parent()?; - let relative = bsl.strip_prefix(source_root).ok(); - let is_nested_module = bsl - .file_name() - .and_then(|n| n.to_str()) - .map(|name| name.eq_ignore_ascii_case("Module.bsl")) - .unwrap_or(false) - && relative - .map(|path| path.components().count() >= 4) - .unwrap_or(false); - - if is_nested_module { - return parent.parent()?.parent().map(Path::to_path_buf); - } - - Some(parent.to_path_buf()) -} - #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; @@ -208,29 +333,179 @@ mod tests { use tempfile::tempdir; use super::{ - decide, object_dir, relative_paths, write_list_file, LoadDecision, + decide, locate_metadata_object, relative_paths, write_list_file, LoadDecision, DEFAULT_PARTIAL_LOAD_THRESHOLD, }; use crate::change_detection::analyzer::{ChangeKind, FileChange}; + /// Helper: ensure that the file is created on disk so that `canonicalize` in the + /// production code succeeds. Returns the absolute path. + fn touch(root: &Path, relative: &str) -> PathBuf { + let path = root.join(relative); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, b"").expect("write"); + path + } + #[test] - fn object_dir_uses_parent_for_top_level_modules() { - let bsl = Path::new("/tmp/src/Catalogs.Items/ObjectModule.bsl"); + fn locate_metadata_object_for_common_module_designer_layout() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("CommonModules/MyModule/Ext/Module.bsl"); - assert_eq!( - object_dir(bsl, Path::new("/tmp/src")), - Some(PathBuf::from("/tmp/src/Catalogs.Items")) + let owner = locate_metadata_object(&bsl, root).expect("owner"); + + assert_eq!(owner.xml, root.join("CommonModules/MyModule.xml")); + assert_eq!(owner.dir, root.join("CommonModules/MyModule")); + } + + #[test] + fn locate_metadata_object_for_document_object_module() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("Documents/Order/Ext/ObjectModule.bsl"); + + let owner = locate_metadata_object(&bsl, root).expect("owner"); + + assert_eq!(owner.xml, root.join("Documents/Order.xml")); + assert_eq!(owner.dir, root.join("Documents/Order")); + } + + #[test] + fn locate_metadata_object_for_form_module_walks_up_to_document() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("Documents/Order/Forms/MainForm/Ext/Form/Module.bsl"); + + let owner = locate_metadata_object(&bsl, root).expect("owner"); + + // Form's owning object is the document itself — Designer needs the document + // XML, not the form XML, to recognise the target during partial load. + assert_eq!(owner.xml, root.join("Documents/Order.xml")); + assert_eq!(owner.dir, root.join("Documents/Order")); + } + + #[test] + fn locate_metadata_object_returns_none_when_no_metadata_type_in_path() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let bsl = root.join("Misc/something/Module.bsl"); + + assert!(locate_metadata_object(&bsl, root).is_none()); + } + + #[test] + fn decide_partial_load_for_common_module_adds_xml_and_object_dir_recursively() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + + let xml = touch(root, "CommonModules/MyModule.xml"); + let module = touch(root, "CommonModules/MyModule/Ext/Module.bsl"); + + let decision = decide( + &[FileChange { + path: module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, ); + + // Sorted, deduped: the BSL itself (picked up both as the change and via the + // recursive walk of the object directory) + the XML descriptor. PathBuf + // ordering is component-wise, so "MyModule/Ext/Module.bsl" sorts before + // "MyModule.xml" — the shorter `MyModule` component beats `MyModule.xml`. + assert_eq!(decision, LoadDecision::Partial(vec![module, xml])); } #[test] - fn object_dir_uses_owning_object_for_nested_modules() { - let bsl = Path::new("/tmp/src/Catalogs.Items/Forms/Form1/Module.bsl"); + fn decide_partial_load_for_document_object_module_includes_all_object_files() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); - assert_eq!( - object_dir(bsl, Path::new("/tmp/src")), - Some(PathBuf::from("/tmp/src/Catalogs.Items")) + let doc_xml = touch(root, "Documents/Order.xml"); + let object_module = touch(root, "Documents/Order/Ext/ObjectModule.bsl"); + let manager_module = touch(root, "Documents/Order/Ext/ManagerModule.bsl"); + let form_xml = touch(root, "Documents/Order/Forms/MainForm.xml"); + let form_descriptor = touch(root, "Documents/Order/Forms/MainForm/Ext/Form.xml"); + let form_module = touch(root, "Documents/Order/Forms/MainForm/Ext/Form/Module.bsl"); + + let decision = decide( + &[FileChange { + path: object_module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, ); + + let LoadDecision::Partial(mut paths) = decision else { + panic!("expected Partial decision, got {decision:?}"); + }; + paths.sort(); + + let mut expected = vec![ + doc_xml, + object_module, + manager_module, + form_xml, + form_descriptor, + form_module, + ]; + expected.sort(); + + assert_eq!(paths, expected); + } + + #[test] + fn decide_partial_load_for_form_module_pulls_in_owning_document() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + + let doc_xml = touch(root, "Documents/Order.xml"); + let object_module = touch(root, "Documents/Order/Ext/ObjectModule.bsl"); + let form_xml = touch(root, "Documents/Order/Forms/MainForm.xml"); + let form_module = touch(root, "Documents/Order/Forms/MainForm/Ext/Form/Module.bsl"); + + let decision = decide( + &[FileChange { + path: form_module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, + ); + + let LoadDecision::Partial(mut paths) = decision else { + panic!("expected Partial decision, got {decision:?}"); + }; + paths.sort(); + + // Editing one form module must still bring the document XML and sibling + // files (other modules, other forms) along so Designer can load the + // whole object coherently. + let mut expected = vec![doc_xml, object_module, form_xml, form_module]; + expected.sort(); + + assert_eq!(paths, expected); + } + + #[test] + fn decide_bsl_outside_metadata_whitelist_adds_only_the_file() { + let temp = tempdir().expect("tempdir"); + let root = temp.path(); + let module = touch(root, "Misc/SomeFolder/Module.bsl"); + + let decision = decide( + &[FileChange { + path: module.clone(), + kind: ChangeKind::Modified, + }], + root, + DEFAULT_PARTIAL_LOAD_THRESHOLD, + ); + + assert_eq!(decision, LoadDecision::Partial(vec![module])); } #[test] @@ -248,14 +523,14 @@ mod tests { fn relative_paths_returns_relative_entries_for_safe_paths() { let temp = tempdir().expect("tempdir"); let root = temp.path(); - let nested = root.join("Catalogs.Items"); - std::fs::create_dir_all(&nested).expect("mkdir"); - let file = nested.join("ObjectModule.bsl"); - std::fs::write(&file, "module").expect("write"); + let module = touch(root, "CommonModules/MyModule/Ext/Module.bsl"); - let rels = relative_paths(&[file.clone()], root).expect("relative paths"); + let rels = relative_paths(&[module.clone()], root).expect("relative paths"); - assert_eq!(rels, vec![PathBuf::from("Catalogs.Items/ObjectModule.bsl")]); + assert_eq!( + rels, + vec![PathBuf::from("CommonModules/MyModule/Ext/Module.bsl")] + ); } #[cfg(unix)] @@ -266,16 +541,15 @@ mod tests { let temp = tempdir().expect("tempdir"); let root = temp.path().join("src"); let outside = temp.path().join("outside"); - let link = root.join("Catalogs.Items"); - let escaped = outside.join("ObjectModule.bsl"); + let link = root.join("CommonModules"); + let escaped = outside.join("Module.bsl"); std::fs::create_dir_all(&root).expect("root"); std::fs::create_dir_all(&outside).expect("outside"); std::fs::write(&escaped, "module").expect("escaped"); symlink(&outside, &link).expect("link"); - let err = - relative_paths(&[link.join("ObjectModule.bsl")], &root).expect_err("expected error"); + let err = relative_paths(&[link.join("Module.bsl")], &root).expect_err("expected error"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } @@ -288,8 +562,8 @@ mod tests { let temp = tempdir().expect("tempdir"); let root = temp.path().join("src"); let outside = temp.path().join("outside"); - let link = root.join("Catalogs.Items"); - let escaped = outside.join("ObjectModule.bsl"); + let link = root.join("CommonModules"); + let escaped = outside.join("Module.bsl"); let list_file = temp.path().join("partial.lst"); std::fs::create_dir_all(&root).expect("root"); @@ -297,45 +571,17 @@ mod tests { std::fs::write(&escaped, "module").expect("escaped"); symlink(&outside, &link).expect("link"); - let err = write_list_file(&[link.join("ObjectModule.bsl")], &root, &list_file) + let err = write_list_file(&[link.join("Module.bsl")], &root, &list_file) .expect_err("expected invalid path"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } - #[test] - fn decide_expands_bsl_to_xml_and_object_dir() { - let temp = tempdir().expect("tempdir"); - let root = temp.path(); - let object_dir = root.join("Catalogs.Items"); - let module = object_dir.join("ObjectModule.bsl"); - let xml = object_dir.join("ObjectModule.xml"); - - std::fs::create_dir_all(&object_dir).expect("create object dir"); - std::fs::write(&module, "module").expect("write module"); - std::fs::write(&xml, "").expect("write xml"); - - let decision = decide( - &[FileChange { - path: module.clone(), - kind: ChangeKind::Modified, - }], - root, - DEFAULT_PARTIAL_LOAD_THRESHOLD, - ); - - assert_eq!( - decision, - LoadDecision::Partial(vec![object_dir, module, xml]) - ); - } - #[test] fn decide_forces_full_when_configuration_xml_changed() { let temp = tempdir().expect("tempdir"); let root = temp.path(); - let config_xml = root.join("Configuration.xml"); - std::fs::write(&config_xml, "").expect("write config"); + let config_xml = touch(root, "Configuration.xml"); let decision = decide( &[FileChange { @@ -353,7 +599,9 @@ mod tests { fn decide_forces_full_when_deleted_files_exist() { let temp = tempdir().expect("tempdir"); let root = temp.path(); - let removed = root.join("Catalogs.Items").join("ObjectModule.bsl"); + // Even though the path itself does not need to exist for a deletion event, + // the Hierarchical layout is preserved for readability. + let removed = root.join("CommonModules/MyModule/Ext/Module.bsl"); let decision = decide( &[FileChange { @@ -373,10 +621,14 @@ mod tests { let root = temp.path(); let mut changes = Vec::new(); + // Each module sits in its own object directory but without an XML descriptor + // on disk — that keeps the per-change file count to exactly one and lets us + // count past the threshold predictably. for index in 0..=DEFAULT_PARTIAL_LOAD_THRESHOLD { - let path = root.join(format!("CommonModules/Module{index}.bsl")); - std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); - std::fs::write(&path, "module").expect("write"); + let path = touch( + root, + &format!("CommonModules/Module{index}/Ext/Module.bsl"), + ); changes.push(FileChange { path, kind: ChangeKind::Modified, @@ -395,8 +647,8 @@ mod tests { let temp = tempdir().expect("tempdir"); let root = temp.path().join("src"); let outside_dir = temp.path().join("outside"); - let link_dir = root.join("Catalogs.Items"); - let escaped = outside_dir.join("ObjectModule.bsl"); + let link_dir = root.join("CommonModules"); + let escaped = outside_dir.join("Module.bsl"); std::fs::create_dir_all(&outside_dir).expect("outside"); std::fs::create_dir_all(&root).expect("root"); @@ -405,7 +657,7 @@ mod tests { let decision = decide( &[FileChange { - path: link_dir.join("ObjectModule.bsl"), + path: link_dir.join("Module.bsl"), kind: ChangeKind::Modified, }], &root, diff --git a/src/change_detection/source_sets.rs b/src/change_detection/source_sets.rs index 6378ee0..6adb256 100644 --- a/src/change_detection/source_sets.rs +++ b/src/change_detection/source_sets.rs @@ -6,7 +6,7 @@ use crate::domain::source_set::SourceSetContext; /// Builds the list of [`SourceSetContext`] instances for the given config. /// -/// - `DESIGNER` format: one context per source-set, rooted at `basePath/ss.path`. +/// - `DESIGNER` format: one context per source-set, rooted at project base path + `ss.path`. /// - `EDT` format (Wave 2): two contexts per source-set — the original EDT path /// and a generated Designer copy under `workPath/designer//`. pub struct SourceSetsService<'a> { @@ -20,7 +20,7 @@ impl<'a> SourceSetsService<'a> { /// Return all Designer-format contexts that should be scanned and built. /// - /// In `DESIGNER` mode this is simply each source-set resolved against `basePath`. + /// In `DESIGNER` mode this is simply each source-set resolved against the project base path. /// In `EDT` mode (Wave 2) this returns the generated Designer copies in /// `workPath/designer`. pub fn designer_contexts(&self) -> Vec { diff --git a/src/cli/args.rs b/src/cli/args.rs index 8c7dce3..47d016d 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -45,6 +45,8 @@ pub struct Cli { pub enum Command { /// Generate project configuration and autodetect source-sets Config(ConfigArgs), + /// Download YaXUnit, Vanessa Automation, and client MCP tool assets + Tools(ToolsArgs), /// Initialize the infobase and EDT workspace Init, /// Update configured extension properties inside the infobase @@ -70,6 +72,57 @@ pub enum Command { Mcp(McpArgs), } +#[derive(Args, Debug)] +pub struct ToolsArgs { + #[command(subcommand)] + pub command: ToolsCommand, +} + +#[derive(Subcommand, Debug)] +pub enum ToolsCommand { + /// Download a supported test or MCP helper tool from its latest GitHub release + Download(ToolsDownloadArgs), +} + +#[derive(Args, Debug)] +#[command(next_help_heading = "Command options")] +pub struct ToolsDownloadArgs { + #[command(subcommand)] + pub command: ToolsDownloadCommand, +} + +#[derive(Subcommand, Debug)] +pub enum ToolsDownloadCommand { + /// Download YAxUnit extension assets or sources + Yaxunit(ToolsDownloadExtensionArgs), + /// Download Vanessa Automation Single external processor + #[command(visible_alias = "vanessa-automation-single")] + Vanessa(ToolsDownloadToolArgs), + /// Download onec-client-mcp-devkit extension assets or sources + #[command(name = "client-mcp", visible_alias = "client_mcp")] + ClientMcp(ToolsDownloadExtensionArgs), +} + +#[derive(Args, Debug)] +#[command(next_help_heading = "Command options")] +pub struct ToolsDownloadExtensionArgs { + /// Download extension sources instead of the release artifact + #[arg(long)] + pub sources: bool, + + /// Re-download managed targets created by tools download + #[arg(long)] + pub force: bool, +} + +#[derive(Args, Debug)] +#[command(next_help_heading = "Command options")] +pub struct ToolsDownloadToolArgs { + /// Re-download managed targets created by tools download + #[arg(long)] + pub force: bool, +} + #[derive(Args, Debug)] pub struct ConfigArgs { #[command(subcommand)] @@ -116,6 +169,14 @@ pub struct BuildArgs { /// Limit build to one source-set from v8project.yaml #[arg(long)] pub source_set: Option, + + /// Apply changes via `/UpdateDBCfg -Dynamic+` (no exclusive lock). + /// + /// Overrides `build.dynamicUpdate` from v8project.yaml for this run. The platform itself + /// refuses dynamic mode when restructuring is required; the runner surfaces that error + /// instead of falling back to a static update. + #[arg(long)] + pub dynamic: bool, } #[derive(Args, Debug)] @@ -325,9 +386,9 @@ pub struct LaunchArgs { #[derive(Args, Debug, Clone, Default, PartialEq, Eq)] #[command(next_help_heading = "MCP client WS options")] pub struct McpClientWsArgs { - /// Override the transport selection: `ws` forces WS, `legacy` forces local - /// HTTP MCP, `auto` probes the manager and falls back to legacy. - #[arg(long = "mcp-transport", value_parser = ["ws", "legacy", "auto"])] + /// Override the transport selection: `ws` forces WS, `mcp` forces local + /// HTTP MCP, `auto` probes the manager and falls back to MCP. + #[arg(long = "mcp-transport", value_parser = ["ws", "mcp", "auto"])] pub mcp_transport: Option, /// Override the session-manager WS endpoint @@ -345,7 +406,7 @@ pub struct McpClientWsArgs { pub corr_id: Option, /// Override the `mcp_log_level` value passed to the BSL devkit. - #[arg(long = "mcp-log-level", value_parser = ["off", "error", "warn", "info", "debug", "trace"])] + #[arg(long = "mcp-log-level")] pub mcp_log_level: Option, /// Override the `mcp_ws_timeout_ms` value passed to the BSL devkit diff --git a/src/cli/execute.rs b/src/cli/execute.rs index 9ca1300..51a1d98 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -9,6 +9,7 @@ use crate::cli::args::{ ArtifactsArgs, BuildArgs, Command, ConvertArgs, DesignerConfigSyntaxArgs, DesignerModulesSyntaxArgs, DumpArgs, ExtensionsArgs, LaunchArgs, LaunchOptionsArgs, LoadArgs, SyntaxArgs, SyntaxTarget, TestArgs, TestRunner, TestScope, TestVaArgs, TestYaxunitArgs, + ToolsArgs, ToolsCommand, ToolsDownloadArgs, ToolsDownloadCommand, }; use crate::cli::output::{ failure_envelope, pre_dispatch_error_envelope, print_command_use_case_error, with_cli_error, @@ -39,6 +40,9 @@ use crate::domain::runner::{ }; use crate::domain::syntax::{SyntaxCheckResult, SyntaxCheckStatus}; use crate::domain::test::{RetainedPaths, TestReport, TestRunResult, TestStatus, TestTarget}; +use crate::domain::tools_download::{ + ToolDownloadTarget, ToolExtensionInstallMode, ToolsDownloadResult, +}; use crate::output::presenter::Presenter; use crate::output::text::{TimelineItem, TimelineStatus}; use crate::support::adapter_input::{ @@ -64,10 +68,11 @@ use crate::use_cases::request::{ DesignerClientScope, DesignerClientScopes, DesignerConfigCheck, DesignerConfigChecks, DesignerConfigSyntaxRequest, DesignerModulesSyntaxRequest, DumpRequest, InitRequest, LaunchRequest, LoadRequest, SyntaxExtensionScope, SyntaxRequest, SyntaxTargetRequest, - TestRequest, TestScopeRequest, + TestRequest, TestScopeRequest, ToolsDownloadRequest, }; use crate::use_cases::result::{UseCaseError, UseCaseErrorKind}; use crate::use_cases::run_tests; +use crate::use_cases::tools_download; use crate::use_cases::transport::dispatch_with_workspace_lock; /// Executes a parsed CLI command by mapping it into transport-neutral requests and @@ -75,6 +80,7 @@ use crate::use_cases::transport::dispatch_with_workspace_lock; pub fn execute_command( config: &AppConfig, command: &Command, + primary_config_path: Option, presenter: &Presenter, clean_before_execution: bool, ) -> Result<(), UseCaseError> { @@ -82,6 +88,14 @@ pub fn execute_command( let _signal_guard = CliSignalGuard::install(cancellation.clone()); match command { Command::Config(_) => unreachable!("config commands are handled outside cli::execute"), + Command::Tools(args) => execute_tools( + config, + args, + required_primary_config_path(primary_config_path)?, + presenter, + clean_before_execution, + cancellation, + ), Command::Init => execute_init(config, presenter, clean_before_execution, cancellation), Command::Extensions(args) => execute_extensions( config, @@ -154,6 +168,9 @@ pub fn execute_command( pub fn command_name(command: &Command) -> CommandName { match command { Command::Config(_) => unreachable!("config commands do not map to execution use cases"), + Command::Tools(ToolsArgs { + command: ToolsCommand::Download(_), + }) => CommandName::ToolsDownload, Command::Init => CommandName::Init, Command::Extensions(_) => CommandName::Extensions, Command::Build(_) => CommandName::Build, @@ -168,6 +185,94 @@ pub fn command_name(command: &Command) -> CommandName { } } +fn execute_tools( + config: &AppConfig, + args: &ToolsArgs, + primary_config_path: PathBuf, + presenter: &Presenter, + clean_before_execution: bool, + cancellation: CancellationToken, +) -> Result<(), UseCaseError> { + match &args.command { + ToolsCommand::Download(download) => execute_tools_download( + config, + download, + primary_config_path, + presenter, + clean_before_execution, + cancellation, + ), + } +} + +fn execute_tools_download( + config: &AppConfig, + args: &ToolsDownloadArgs, + primary_config_path: PathBuf, + presenter: &Presenter, + clean_before_execution: bool, + cancellation: CancellationToken, +) -> Result<(), UseCaseError> { + let request = ToolsDownloadRequest { + config_path: primary_config_path, + target: map_tools_download_target(args), + extensions: map_tool_extension_mode(args), + force: map_tools_download_force(args), + }; + let context = cli_context(config, CommandName::ToolsDownload, cancellation); + with_cli_workspace_lock( + config, + presenter, + CommandName::ToolsDownload, + clean_before_execution, + || match tools_download::execute(&context, config, &request) { + Ok(result) => { + if presenter.is_json() { + presenter.print_envelope(&Envelope::ok( + CommandName::ToolsDownload.as_str(), + result.duration_ms, + result, + )); + } else { + render_tools_download_text(&result, presenter); + } + Ok(()) + } + Err(failure) => { + let error = failure.error; + if presenter.is_json() { + match failure.payload { + Some(result) => presenter.print_envelope(&failure_envelope( + CommandName::ToolsDownload.as_str(), + result.duration_ms, + result, + &error, + )), + None => presenter.print_envelope(&pre_dispatch_error_envelope( + CommandName::ToolsDownload.as_str(), + &error, + )), + } + } else { + presenter.print_error(&error.to_string()); + } + Err(error) + } + }, + ) +} + +fn required_primary_config_path( + primary_config_path: Option, +) -> Result { + primary_config_path.ok_or_else(|| { + UseCaseError::new( + UseCaseErrorKind::Validation, + "tools download requires a resolved primary config path", + ) + }) +} + fn execute_extensions( config: &AppConfig, args: &ExtensionsArgs, @@ -702,6 +807,8 @@ fn map_build_request(args: &BuildArgs) -> BuildRequest { BuildRequest { full_rebuild: args.full_rebuild, source_set: args.source_set.clone(), + // CLI flag is a one-shot override; absence means "fall back to project config". + dynamic_update: if args.dynamic { Some(true) } else { None }, } } @@ -711,6 +818,34 @@ fn map_extensions_request(args: &ExtensionsArgs) -> ConfigureExtensionsRequest { } } +fn map_tools_download_target(args: &ToolsDownloadArgs) -> ToolDownloadTarget { + match &args.command { + ToolsDownloadCommand::Yaxunit(_) => ToolDownloadTarget::Yaxunit, + ToolsDownloadCommand::Vanessa(_) => ToolDownloadTarget::VanessaAutomationSingle, + ToolsDownloadCommand::ClientMcp(_) => ToolDownloadTarget::ClientMcp, + } +} + +fn map_tool_extension_mode(args: &ToolsDownloadArgs) -> ToolExtensionInstallMode { + match &args.command { + ToolsDownloadCommand::Yaxunit(args) | ToolsDownloadCommand::ClientMcp(args) => { + if args.sources { + ToolExtensionInstallMode::Sources + } else { + ToolExtensionInstallMode::Artifacts + } + } + ToolsDownloadCommand::Vanessa(_) => ToolExtensionInstallMode::Artifacts, + } +} + +fn map_tools_download_force(args: &ToolsDownloadArgs) -> bool { + match &args.command { + ToolsDownloadCommand::Yaxunit(args) | ToolsDownloadCommand::ClientMcp(args) => args.force, + ToolsDownloadCommand::Vanessa(args) => args.force, + } +} + fn map_test_request(config: &AppConfig, args: &TestArgs) -> Result { let client_mode = map_test_client_mode(args.client_mode.as_deref())?; let mcp_ws = map_mcp_ws_args(&args.mcp_ws)?; @@ -1194,8 +1329,8 @@ fn map_mcp_ws_args( Some(crate::use_cases::mcp_ws::McpClientTransport::Ws) => { Some(McpClientTransportRequest::Ws) } - Some(crate::use_cases::mcp_ws::McpClientTransport::Legacy) => { - Some(McpClientTransportRequest::Legacy) + Some(crate::use_cases::mcp_ws::McpClientTransport::Mcp) => { + Some(McpClientTransportRequest::Mcp) } Some(crate::use_cases::mcp_ws::McpClientTransport::Auto) => { Some(McpClientTransportRequest::Auto) @@ -1203,7 +1338,7 @@ fn map_mcp_ws_args( None => { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - format!("--mcp-transport must be one of: ws, legacy, auto (got: {value})"), + format!("--mcp-transport must be one of: ws, mcp, auto (got: {value})"), )); } }, @@ -1225,26 +1360,32 @@ fn map_mcp_ws_args( )); } if let Some(url) = args.manager_url.as_deref() { - if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(url) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--manager-url must not contain ';' or '=' because the /C payload is semicolon-delimited", + )); + } + if let Err(error) = crate::use_cases::mcp_ws::parse_manager_addr(url) { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - format!("--manager-url must include host:port (got: {url})"), + format!("--manager-url parse error: {error}"), )); } } if let Some(uid) = args.client_uid.as_deref() { - if uid.contains(';') { + if !crate::use_cases::mcp_ws::is_payload_token_safe(uid) { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - "--client-uid must not contain ';' because the /C payload is semicolon-delimited", + "--client-uid must not contain ';' or '=' because the /C payload is semicolon-delimited", )); } } if let Some(corr) = args.corr_id.as_deref() { - if corr.contains(';') { + if !crate::use_cases::mcp_ws::is_payload_token_safe(corr) { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - "--corr-id must not contain ';' because the /C payload is semicolon-delimited", + "--corr-id must not contain ';' or '=' because the /C payload is semicolon-delimited", )); } } @@ -1549,6 +1690,31 @@ fn render_build_text(result: &BuildResult, presenter: &Presenter, succeeded: boo presenter.print_timeline(&[summary]); } +fn render_tools_download_text(result: &ToolsDownloadResult, presenter: &Presenter) { + let mut details = vec![ + format!("tool: {}", result.tool), + format!("mode: {}", result.mode), + format!("config: {}", result.config_path.display()), + format!("local config: {}", result.local_config_path.display()), + ]; + for destination in &result.destinations { + details.push(format!( + "{} {} -> {} ({})", + destination.tool, + destination.tag, + destination.path.display(), + destination.config + )); + } + + single_timeline( + presenter, + TimelineStatus::Succeeded, + "Tools downloaded successfully", + details, + ); +} + fn timeline_status(ok: bool) -> TimelineStatus { if ok { TimelineStatus::Succeeded @@ -2471,6 +2637,7 @@ mod tests { map_build_request(&BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }) .full_rebuild ); @@ -2759,6 +2926,7 @@ mod tests { command_name(&Command::Build(BuildArgs { full_rebuild: false, source_set: None, + dynamic: false, })), CommandName::Build ); @@ -2829,7 +2997,9 @@ mod tests { &Command::Build(BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }), + None, &presenter, false, ) @@ -2862,6 +3032,7 @@ mod tests { }), mcp_ws: crate::cli::args::McpClientWsArgs::default(), }), + None, &presenter, false, ) @@ -2894,6 +3065,7 @@ mod tests { mcp_port: None, mcp_ws: crate::cli::args::McpClientWsArgs::default(), }), + None, &presenter, false, ) @@ -2927,6 +3099,7 @@ mod tests { }), mcp_ws: crate::cli::args::McpClientWsArgs::default(), }), + None, &presenter, false, ) @@ -2956,7 +3129,9 @@ mod tests { &Command::Build(BuildArgs { full_rebuild: true, source_set: None, + dynamic: false, }), + None, &presenter, true, ) diff --git a/src/config/loader.rs b/src/config/loader.rs index e5335d8..a04b865 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -5,11 +5,11 @@ use crate::config::model::AppConfig; use crate::config::schema::{ validate_local_overlay_schema_boundary, validate_main_config_schema_boundary, }; -use crate::config::validate::{validate, ConfigValidationError}; +use crate::config::validate::{validate, validate_tools_download_bootstrap, ConfigValidationError}; use crate::support::path::normalize_windows_verbatim_path; -const DEFAULT_CONFIG_FILE_NAME: &str = "v8project.yaml"; -const LOCAL_CONFIG_FILE_NAME: &str = "v8project.local.yaml"; +pub const DEFAULT_CONFIG_FILE_NAME: &str = "v8project.yaml"; +pub const LOCAL_CONFIG_FILE_NAME: &str = "v8project.local.yaml"; #[derive(Debug, Error)] pub enum ConfigLoadError { @@ -44,6 +44,30 @@ pub enum ConfigLoadError { pub fn load_config( config_path: Option<&str>, workdir_override: Option<&str>, +) -> Result { + load_config_with_mode(config_path, workdir_override, ConfigValidationMode::Full) +} + +pub fn load_config_for_tools_download( + config_path: Option<&str>, + workdir_override: Option<&str>, +) -> Result { + load_config_with_mode( + config_path, + workdir_override, + ConfigValidationMode::ToolsDownload, + ) +} + +enum ConfigValidationMode { + Full, + ToolsDownload, +} + +fn load_config_with_mode( + config_path: Option<&str>, + workdir_override: Option<&str>, + validation_mode: ConfigValidationMode, ) -> Result { let path = resolve_config_path(config_path)?; reject_local_overlay_as_primary_config(&path)?; @@ -65,10 +89,10 @@ pub fn load_config( merge_yaml_values(&mut root, overlay); } - default_base_path_to_config_dir(&mut root, config_dir)?; reject_legacy_config_keys(&root)?; validate_main_config_schema_boundary(root.clone()) .map_err(|error| ConfigLoadError::UnsupportedShape(error.to_string()))?; + default_base_path_to_config_dir(&mut root, config_dir)?; let mut config: AppConfig = serde_yaml::from_value(root)?; normalize_config_paths(&mut config, config_dir); @@ -77,10 +101,21 @@ pub fn load_config( config.work_path = normalize_optional_path(Path::new(wd), config_dir); } - validate(&config)?; + match validation_mode { + ConfigValidationMode::Full => validate(&config)?, + ConfigValidationMode::ToolsDownload => validate_tools_download_bootstrap(&config)?, + } Ok(config) } +pub fn resolve_primary_config_path(config_path: Option<&str>) -> Result { + let path = resolve_config_path(config_path)?; + reject_local_overlay_as_primary_config(&path)?; + Ok(normalize_windows_verbatim_path(&std::fs::canonicalize( + &path, + )?)) +} + fn read_yaml_file(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; Ok(serde_yaml::from_str(&content)?) @@ -617,8 +652,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -643,8 +677,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n purpose: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n purpose: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -671,8 +704,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nbuild:\n partialLoadThreshold: 7\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nbuild:\n partialLoadThreshold: 7\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -683,6 +715,36 @@ mod tests { assert_eq!(config.build.partial_load_threshold, 7); } + #[test] + fn load_config_reads_build_dynamic_update_and_infobase_unlock_code() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let src = base.join("src"); + std::fs::create_dir_all(&src).expect("src dir"); + let config_path = dir.path().join("v8project.yaml"); + std::fs::write( + &config_path, + format!( + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\n unlock_code: seal-1\nbuild:\n dynamicUpdate: true\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", + work.display() + ), + ) + .expect("write config"); + + let config = load_config(config_path.to_str(), None).expect("load config"); + + assert!(config.build.dynamic_update); + assert_eq!(config.infobase.unlock_code.as_deref(), Some("seal-1")); + + // And the resulting V8Connection carries the unlock code into the platform layer. + let connection = config.v8_connection(); + assert_eq!(connection.unlock_code.as_deref(), Some("seal-1")); + let args = connection.args(); + let uc_index = args.iter().position(|arg| arg == "/UC").expect("/UC"); + assert_eq!(args.get(uc_index + 1).map(String::as_str), Some("seal-1")); + } + #[test] fn load_config_reads_test_timeout_from_exact_yaml_key() { let dir = tempdir().expect("tempdir"); @@ -694,8 +756,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntests:\n execution_timeout_seconds: 17\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntests:\n execution_timeout_seconds: 17\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -717,8 +778,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nexecution_timeout: 4321\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nexecution_timeout: 4321\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -776,8 +836,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n va:\n epf_path: va/runner.epf\ntests:\n va:\n params_path: va/params.json\n profile: smoke\n profiles:\n smoke:\n feature_path: features\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n va:\n epf_path: va/runner.epf\ntests:\n va:\n params_path: va/params.json\n profile: smoke\n profiles:\n smoke:\n feature_path: features\nsource-set:\n - name: main\n type: CONFIGURATION\n path: ../base/src\n", work.display() ), ) @@ -814,13 +873,13 @@ mod tests { let config_path = config_dir.join("v8project.yaml"); std::fs::write( &config_path, - "basePath: sources\nworkPath: build\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=build/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", + "workPath: build\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=build/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: sources\n", ) .expect("write config"); let config = load_config(config_path.to_str(), None).expect("load config"); - assert_eq!(config.base_path, base); + assert_eq!(config.base_path, config_dir); assert_eq!(config.work_path, config_dir.join("build")); assert_eq!( config.infobase.connection, @@ -837,7 +896,7 @@ mod tests { let config_path = config_dir.join("v8project.yaml"); std::fs::write( &config_path, - "basePath: sources\nworkPath: build\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: \"Srvr=cluster:1541;Ref=demo\"\n dbms:\n kind: PostgreSQL\n server: localhost\n name: demo\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", + "workPath: build\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: \"Srvr=cluster:1541;Ref=demo\"\n dbms:\n kind: PostgreSQL\n server: localhost\n name: demo\nsource-set:\n - name: main\n type: CONFIGURATION\n path: sources\n", ) .expect("write config"); @@ -951,7 +1010,7 @@ mod tests { let config_path = config_dir.join("v8project.yaml"); std::fs::write( &config_path, - "basePath: sources\nworkPath: build\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: '/F \"build/my ib\"'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", + "workPath: build\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: '/F \"build/my ib\"'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: sources\n", ) .expect("write config"); @@ -981,8 +1040,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nmcp:\n http:\n bind_address: 127.0.0.1:4000\n path: /custom-mcp\n stateful_sessions: false\n max_sessions: 12\n idle_ttl_secs: 45\n execution:\n max_concurrent_calls: 3\n shutdown_grace_period_secs: 9\ntools:\n client_mcp:\n port: 9874\n extension:\n name: client_mcp\n source:\n path: exts/client-mcp\n format: DESIGNER\n enterprise:\n additional-launch-keys:\n - /TESTMANAGER\n edt_cli:\n interactive-mode: true\n startup_timeout_ms: 1234\n command_timeout_ms: 5678\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nmcp:\n http:\n bind_address: 127.0.0.1:4000\n path: /custom-mcp\n stateful_sessions: false\n max_sessions: 12\n idle_ttl_secs: 45\n execution:\n max_concurrent_calls: 3\n shutdown_grace_period_secs: 9\ntools:\n client_mcp:\n port: 9874\n extension:\n name: client_mcp\n source:\n path: exts/client-mcp\n format: DESIGNER\n enterprise:\n additional-launch-keys:\n - /TESTMANAGER\n edt_cli:\n interactive-mode: true\n startup_timeout_ms: 1234\n command_timeout_ms: 5678\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1035,8 +1093,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n client_mcp:\n extension:\n{extension_body}source-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n client_mcp:\n extension:\n{extension_body}source-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1066,8 +1123,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n enterprise:\n additional-launch-keys:\n - /TESTMANAGER\n - /TCUser\n - ci-user\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n enterprise:\n additional-launch-keys:\n - /TESTMANAGER\n - /TCUser\n - ci-user\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1121,8 +1177,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\n{extra_yaml}source-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\n{extra_yaml}source-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1144,8 +1199,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1175,8 +1229,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n edt_cli:\n startup_timeout_ms: 2222\n command_timeout_ms: 3333\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n edt_cli:\n startup_timeout_ms: 2222\n command_timeout_ms: 3333\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) @@ -1199,8 +1252,7 @@ mod tests { std::fs::write( &config_path, format!( - "basePath: {}\nworkPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n platform:\n version: 8.3.27.1859\n edt_cli:\n path: 1c-edt-2025.2.3\n version: 1c-edt-2025.2.3\nsource-set:\n - name: main\n type: CONFIGURATION\n path: src\n", - base.display(), + "workPath: {}\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: \"File=/tmp/ib\"\ntools:\n platform:\n version: 8.3.27.1859\n edt_cli:\n path: 1c-edt-2025.2.3\n version: 1c-edt-2025.2.3\nsource-set:\n - name: main\n type: CONFIGURATION\n path: base/src\n", work.display() ), ) diff --git a/src/config/model.rs b/src/config/model.rs index e5b7c1b..99bd0c9 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -66,6 +66,14 @@ pub struct InfobaseConfig { /// Optional infobase password passed to platform utilities. pub password: Option, + /// Optional infobase unlock code propagated to DESIGNER calls as `/UC `. + /// + /// Required by configurations sealed with `Конфигурация → Установить пароль`; without the + /// matching code the platform refuses every administrative operation. The value is treated + /// as a secret and masked in command logs. + #[serde(default)] + pub unlock_code: Option, + /// Optional DBMS contract for server-based infobases. #[serde(default)] pub dbms: Option, @@ -79,6 +87,7 @@ impl InfobaseConfig { connection: connection.into(), user: None, password: None, + unlock_code: None, dbms: None, } } @@ -98,6 +107,7 @@ impl InfobaseConfig { connection: connection.into(), user: None, password: None, + unlock_code: None, dbms: Some(dbms), } } @@ -159,6 +169,7 @@ impl AppConfig { let mut conn = V8Connection::from_connection_string(&self.infobase.connection); conn.user = self.infobase.user.clone(); conn.password = self.infobase.password.clone(); + conn.unlock_code = self.infobase.unlock_code.clone(); conn } @@ -202,7 +213,7 @@ pub struct SourceSetConfig { #[serde(rename = "type")] pub purpose: SourceSetPurpose, - /// Path relative to basePath (for DESIGNER) or EDT project path + /// Path relative to the project base path (for DESIGNER) or EDT project path. pub path: PathBuf, } @@ -226,12 +237,23 @@ impl SourceSetPurpose { pub struct BuildConfig { #[serde(default = "default_partial_load_threshold")] pub partial_load_threshold: usize, + + /// Default mode for `/UpdateDBCfg` during `build`. + /// + /// When `true`, DESIGNER is invoked with `-Dynamic+`, which lets the platform apply + /// metadata changes without taking an exclusive infobase lock (useful when HTTP services + /// or background jobs are live). If the change set is incompatible with dynamic update + /// (e.g. restructuring), DESIGNER returns an error — the runner does NOT silently fall + /// back to a static update. CLI `--dynamic` overrides this field for a single invocation. + #[serde(default)] + pub dynamic_update: bool, } impl Default for BuildConfig { fn default() -> Self { Self { partial_load_threshold: default_partial_load_threshold(), + dynamic_update: false, } } } @@ -287,12 +309,12 @@ pub struct ClientMcpToolConfig { /// Optional tool extension prepared by `build` for client MCP launches. pub extension: Option, - /// Default transport for the MCP client side: `ws`, `legacy` or `auto`. + /// Default transport for the MCP client side: `ws`, `mcp` or `auto`. /// When omitted, runtime treats it as `auto` (probe manager, fall back - /// to legacy local HTTP MCP). + /// to local HTTP MCP). pub transport: Option, - /// Default WS endpoint for the session-manager + /// Default WS endpoint with IP address and port for the session-manager /// (e.g. `ws://127.0.0.1:4000/sessions`). pub manager_url: Option, diff --git a/src/config/schema.rs b/src/config/schema.rs index b00d18c..d01d997 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -11,8 +11,8 @@ use serde_json::{json, Value}; pub const MAIN_CONFIG_SCHEMA_PATH: &str = "docs/schemas/v8project.schema.json"; pub const LOCAL_CONFIG_SCHEMA_PATH: &str = "docs/schemas/v8project.local.schema.json"; -const REPOSITORY_RAW_TAG_BASE: &str = - "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags"; +const REPOSITORY_RAW_SCHEMA_BASE: &str = + "https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas"; pub fn main_config_schema_url() -> String { schema_url("v8project.schema.json") @@ -27,6 +27,7 @@ pub fn main_config_schema_json() -> Value { set_schema_id(&mut schema, &main_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); + add_mcp_transport_schema_constraints(&mut schema); schema } @@ -36,6 +37,7 @@ pub fn local_config_schema_json() -> Value { set_schema_id(&mut schema, &local_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); + add_mcp_transport_schema_constraints(&mut schema); schema } @@ -58,10 +60,7 @@ pub fn schema_json_pretty(schema: &Value) -> String { } fn schema_url(file_name: &str) -> String { - format!( - "{REPOSITORY_RAW_TAG_BASE}/v{}/docs/schemas/{file_name}", - env!("CARGO_PKG_VERSION") - ) + format!("{REPOSITORY_RAW_SCHEMA_BASE}/{file_name}") } fn set_schema_id(schema: &mut Value, id: &str) { @@ -233,6 +232,7 @@ fn add_numeric_runtime_bounds(schema: &mut Value) { ); for def in ["ClientMcpToolSchema", "PartialClientMcpToolSchema"] { set_numeric_bounds(schema, &[def], "port", Some(1), None); + set_numeric_bounds(schema, &[def], "ws_timeout_ms", Some(1), None); } for name in ["max_sessions", "idle_ttl_secs"] { set_numeric_bounds(schema, &["McpHttpSchema"], name, Some(1), None); @@ -279,6 +279,35 @@ fn set_numeric_bounds( } } +fn add_mcp_transport_schema_constraints(schema: &mut Value) { + for def in ["ClientMcpToolSchema", "PartialClientMcpToolSchema"] { + set_string_enum(schema, &[def], "transport", &["ws", "mcp", "auto"]); + } +} + +fn set_string_enum(schema: &mut Value, def_path: &[&str], property: &str, variants: &[&str]) { + let Some(object) = schema_object_mut(schema, def_path) else { + return; + }; + let Some(property) = object + .get_mut("properties") + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut(property)) + .and_then(Value::as_object_mut) + else { + return; + }; + + let mut variants = variants + .iter() + .map(|value| json!(value)) + .collect::>(); + if matches!(property.get("type"), Some(Value::Array(_))) { + variants.push(Value::Null); + } + property.insert("enum".to_owned(), Value::Array(variants)); +} + fn schema_object_mut<'a>( schema: &'a mut Value, def_path: &[&str], @@ -307,14 +336,6 @@ where #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct MainConfigSchema { - /// Root directory for project sources; defaults to the directory containing `v8project.yaml`. - #[serde( - default, - deserialize_with = "deserialize_non_null_optional", - skip_serializing_if = "Option::is_none" - )] - #[schemars(with = "PathBuf")] - base_path: Option, /// Working directory for generated state, logs, temporary files, and hash storages. work_path: PathBuf, /// Global execution budget for public CLI and MCP commands in milliseconds. @@ -452,6 +473,10 @@ struct InfobaseSchema { /// Optional infobase password passed to platform utilities. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, + /// Optional unlock code. Non-empty value is propagated as `/UC ` to DESIGNER; + /// empty string means no unlock code and `/UC` is not passed. Masked in command logs. + #[serde(default, skip_serializing_if = "Option::is_none")] + unlock_code: Option, /// Optional DBMS settings for server-based infobases. #[serde(default, skip_serializing_if = "Option::is_none")] dbms: Option, @@ -474,6 +499,10 @@ struct PartialInfobaseSchema { /// Optional local infobase password. #[serde(default, skip_serializing_if = "Option::is_none")] password: Option, + /// Optional local infobase unlock code. Non-empty value is propagated as `/UC `; + /// empty string means no unlock code and `/UC` is not passed. Masked in command logs. + #[serde(default, skip_serializing_if = "Option::is_none")] + unlock_code: Option, /// Optional local DBMS settings override. #[serde(default, skip_serializing_if = "Option::is_none")] dbms: Option, @@ -509,7 +538,7 @@ struct SourceSetSchema { /// Source-set type: configuration, extension, external data processors, or external reports. #[serde(rename = "type")] purpose: SourceSetPurposeSchema, - /// Source path relative to `basePath` or an EDT project path. + /// Source path relative to the primary config directory or an EDT project path. path: PathBuf, } @@ -533,6 +562,14 @@ struct BuildSchema { )] #[schemars(with = "usize")] partial_load_threshold: Option, + /// Default `/UpdateDBCfg -Dynamic+` toggle for `build`. CLI `--dynamic` overrides this. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "bool")] + dynamic_update: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -705,10 +742,10 @@ struct ClientMcpToolSchema { /// Optional tool extension prepared by `build` for client MCP launches. #[serde(default, skip_serializing_if = "Option::is_none")] extension: Option, - /// Default transport for the MCP client side: `ws`, `legacy` or `auto`. + /// Default transport for the MCP client side: `ws`, `mcp` or `auto`. #[serde(default, skip_serializing_if = "Option::is_none")] transport: Option, - /// Default WS endpoint for the session-manager. + /// Default WS endpoint with IP address and port for the session-manager. #[serde(default, skip_serializing_if = "Option::is_none")] manager_url: Option, /// Default `mcp_log_level` value forwarded into the `/C` payload. @@ -1091,6 +1128,7 @@ mod tests { use std::path::Path; const REMOVED_SCHEMA_ALIAS_PROPERTIES: &[&str] = &[ + "basePath", "executionTimeout", "execution_timeout_ms", "edt-cli", @@ -1120,7 +1158,6 @@ mod tests { #[test] fn generated_schemas_include_user_facing_field_descriptions() { let main_schema = main_config_schema_json(); - assert_property_description_contains(&main_schema, &[], "basePath", "Root directory"); assert_property_description_contains(&main_schema, &[], "workPath", "Working directory"); assert_property_description_contains( &main_schema, @@ -1460,6 +1497,20 @@ mod tests { } } + #[test] + fn schemas_and_loader_reject_invalid_client_mcp_transport() { + let config = format!( + "{}tools:\n client_mcp:\n transport: legacy\n", + minimal_project_config_without_base_path() + ); + assert_schema_invalid(&main_config_schema_json(), &config); + assert_config_loader_error_any(&config); + + let overlay = "tools:\n client_mcp:\n transport: legacy\n"; + assert_schema_invalid(&local_config_schema_json(), overlay); + assert_overlay_loader_error(overlay); + } + #[test] fn schemas_and_loader_accept_supported_runtime_sections() { let config = format!( @@ -1522,6 +1573,10 @@ mod tests { "{}toolz: {{}}\n", minimal_project_config_without_base_path() ), + format!( + "{}basePath: /tmp/project\n", + minimal_project_config_without_base_path() + ), format!( "{}tools:\n platform:\n typo: value\n", minimal_project_config_without_base_path() diff --git a/src/config/validate.rs b/src/config/validate.rs index bdaa7f5..6093a37 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -17,7 +17,7 @@ pub enum ConfigValidationError { #[error("{0}")] InvalidYamlRoot(String), - #[error("basePath does not exist or is not a directory: {0}")] + #[error("project base path does not exist or is not a directory: {0}")] BasePathInvalid(String), #[error("workPath could not be created: {0}")] @@ -163,7 +163,7 @@ pub enum ConfigValidationError { #[error("tools.client_mcp.port must be greater than or equal to 1")] InvalidMcpClientPort, - #[error("tools.client_mcp.transport must be one of: ws, legacy, auto (got: {0})")] + #[error("tools.client_mcp.transport must be one of: ws, mcp, auto (got: {0})")] InvalidMcpClientTransport(String), #[error( @@ -216,6 +216,23 @@ pub fn validate(config: &AppConfig) -> Result<(), ConfigValidationError> { Ok(()) } +/// Validate only the configuration parts required to bootstrap downloaded tools. +/// +/// `tools download` may be invoked specifically to create Vanessa Automation and +/// client MCP paths, so those tool-dependent checks must not block the command. +pub fn validate_tools_download_bootstrap(config: &AppConfig) -> Result<(), ConfigValidationError> { + validate_base_path(&config.base_path)?; + validate_work_path(&config.work_path)?; + validate_matrix(config)?; + validate_connection(config)?; + validate_platform_version(config)?; + validate_build_config(config)?; + validate_execution_timeout(config)?; + validate_mcp_config(config)?; + validate_edt_cli_config(config)?; + Ok(()) +} + fn validate_base_path(path: &Path) -> Result<(), ConfigValidationError> { if !path.exists() || !path.is_dir() { return Err(ConfigValidationError::BasePathInvalid( @@ -850,6 +867,11 @@ fn validate_mcp_config(config: &AppConfig) -> Result<(), ConfigValidationError> } if let Some(url) = config.tools.client_mcp.manager_url.as_deref() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(url) { + return Err(ConfigValidationError::InvalidMcpClientManagerUrl( + url.to_owned(), + )); + } if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { return Err(ConfigValidationError::InvalidMcpClientManagerUrl( url.to_owned(), @@ -1360,6 +1382,7 @@ mod tests { }], build: BuildConfig { partial_load_threshold: 0, + dynamic_update: false, }, tools: ToolsConfig::default(), mcp: Default::default(), @@ -2175,6 +2198,7 @@ mod tests { connection: "Srvr=localhost;Ref=ib".to_owned(), user: None, password: None, + unlock_code: None, dbms: None, }, source_sets: vec![SourceSetConfig { diff --git a/src/domain/config_init.rs b/src/domain/config_init.rs index b61da31..d74f56f 100644 --- a/src/domain/config_init.rs +++ b/src/domain/config_init.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; pub struct ConfigInitResult { pub ok: bool, pub path: String, + pub local_path: String, + pub gitignore_path: String, pub format: String, pub builder: String, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/domain/launch.rs b/src/domain/launch.rs index 49e2c76..a0ba964 100644 --- a/src/domain/launch.rs +++ b/src/domain/launch.rs @@ -15,10 +15,10 @@ pub struct LaunchResult { pub binary: PathBuf, /// Human-readable launch summary. pub message: Option, - /// MCP transport selected for this launch (`ws` or `legacy`). + /// MCP transport selected for this launch. /// Present only for `launch mcp` and `test` flows. #[serde(skip_serializing_if = "Option::is_none", default)] - pub transport: Option, + pub transport: Option, /// Per-launch UUID announced to the session-manager /// (`mcpMode=ws` only). #[serde(skip_serializing_if = "Option::is_none", default)] @@ -34,11 +34,19 @@ pub struct LaunchResult { /// (`mcpMode=ws` only). #[serde(skip_serializing_if = "Option::is_none", default)] pub corr_id: Option, - /// Local HTTP MCP port (`legacy` transport only). + /// Local HTTP MCP port (`mcp` transport only). #[serde(skip_serializing_if = "Option::is_none", default)] pub mcp_port: Option, } +/// MCP client transport selected for a launch result. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LaunchMcpTransport { + Ws, + Mcp, +} + /// Supported application launch modes. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/src/domain/mod.rs b/src/domain/mod.rs index bad8f5f..eff9f1d 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -30,3 +30,5 @@ pub mod source_set; pub mod syntax; /// Test domain models. pub mod test; +/// Tool download domain models. +pub mod tools_download; diff --git a/src/domain/tools_download.rs b/src/domain/tools_download.rs new file mode 100644 index 0000000..3721779 --- /dev/null +++ b/src/domain/tools_download.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolExtensionInstallMode { + Sources, + Artifacts, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolDownloadTarget { + Yaxunit, + VanessaAutomationSingle, + ClientMcp, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolsDownloadResult { + pub ok: bool, + pub tool: String, + pub mode: String, + pub destinations: Vec, + pub config_path: PathBuf, + pub local_config_path: PathBuf, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolDownloadDestination { + pub tool: String, + pub tag: String, + pub source: String, + pub path: PathBuf, + pub config: String, +} diff --git a/src/mcp/port.rs b/src/mcp/port.rs index 2e8db54..54cf0bc 100644 --- a/src/mcp/port.rs +++ b/src/mcp/port.rs @@ -231,6 +231,7 @@ mod tests { &BuildRequest { full_rebuild: true, source_set: None, + dynamic_update: None, }, ) .expect_err("busy workspace"); diff --git a/src/mcp/request.rs b/src/mcp/request.rs index 05130d7..8948876 100644 --- a/src/mcp/request.rs +++ b/src/mcp/request.rs @@ -11,6 +11,11 @@ pub struct McpBuildProjectRequest { /// Optional source-set selector from v8project.yaml. #[schemars(description = "Source-set name to build. When omitted, all source-sets are built.")] pub source_set: Option, + /// Optional one-shot override for `/UpdateDBCfg -Dynamic+`. + #[schemars( + description = "Override build.dynamicUpdate for this call: true applies changes without exclusive lock." + )] + pub dynamic_update: Option, } /// MCP request for `run_all_tests`. diff --git a/src/mcp/service.rs b/src/mcp/service.rs index 465fa62..f80d2da 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -59,6 +59,7 @@ where let use_case_request = BuildRequest { full_rebuild: request.full_rebuild.unwrap_or(false), source_set: request.source_set.clone(), + dynamic_update: request.dynamic_update, }; match self @@ -826,6 +827,7 @@ mod tests { &McpBuildProjectRequest { full_rebuild: Some(true), source_set: Some("main".to_owned()), + dynamic_update: Some(true), }, ) .expect("success"); @@ -841,6 +843,7 @@ mod tests { assert_eq!(requests[0].0.transport(), ExecutionTransport::McpStdio); assert_eq!(requests[0].1.full_rebuild, true); assert_eq!(requests[0].1.source_set.as_deref(), Some("main")); + assert_eq!(requests[0].1.dynamic_update, Some(true)); } #[test] diff --git a/src/platform/connection.rs b/src/platform/connection.rs index c4880fa..10a7591 100644 --- a/src/platform/connection.rs +++ b/src/platform/connection.rs @@ -7,6 +7,12 @@ pub struct V8Connection { pub user: Option, /// Optional password added as `/P `. pub password: Option, + /// Optional infobase unlock code emitted as `/UC `. + /// + /// Configurations protected by a locking code (`Конфигурация → Установить пароль`) refuse any + /// administrative DESIGNER operation until the matching `/UC` is supplied; an empty string is + /// treated as "no unlock code" for backwards compatibility with explicit nulling in overlays. + pub unlock_code: Option, } impl V8Connection { @@ -24,6 +30,7 @@ impl V8Connection { connection_args, user: None, password: None, + unlock_code: None, } } @@ -40,6 +47,14 @@ impl V8Connection { args.push(password.clone()); } } + if let Some(unlock_code) = &self.unlock_code { + // Empty value is intentionally treated as absent: a misconfigured overlay setting + // `unlock_code: ""` should not push an `/UC` token without a value to the platform. + if !unlock_code.is_empty() { + args.push("/UC".to_owned()); + args.push(unlock_code.clone()); + } + } args } @@ -156,4 +171,41 @@ mod tests { assert_eq!(connection.args(), vec!["/F", "/tmp/ib"]); assert_eq!(connection.file_path(), Some("/tmp/ib")); } + + #[test] + fn appends_unlock_code_after_credentials() { + let mut connection = V8Connection::from_connection_string("File=/tmp/ib"); + connection.user = Some("Admin".to_owned()); + connection.password = Some("pw".to_owned()); + connection.unlock_code = Some("uc-secret".to_owned()); + + assert_eq!( + connection.args(), + vec![ + "/IBConnectionString", + "File=/tmp/ib", + "/N", + "Admin", + "/P", + "pw", + "/UC", + "uc-secret", + ] + ); + } + + #[test] + fn omits_unlock_code_when_value_is_empty() { + let mut connection = V8Connection::from_connection_string("File=/tmp/ib"); + connection.unlock_code = Some(String::new()); + + assert!(!connection.args().iter().any(|arg| arg == "/UC")); + } + + #[test] + fn omits_unlock_code_when_not_configured() { + let connection = V8Connection::from_connection_string("File=/tmp/ib"); + + assert!(!connection.args().iter().any(|arg| arg == "/UC")); + } } diff --git a/src/platform/designer.rs b/src/platform/designer.rs index c98faa1..67be1f9 100644 --- a/src/platform/designer.rs +++ b/src/platform/designer.rs @@ -88,13 +88,22 @@ impl<'a> DesignerDsl<'a> { self.run(&args) } - /// `/UpdateDBCfg` + /// `/UpdateDBCfg [-Dynamic+] [-Extension ]` + /// + /// `dynamic = true` adds `-Dynamic+`, instructing the platform to apply the change set + /// without grabbing an exclusive infobase lock. The platform itself refuses dynamic mode + /// when restructuring is required; the runner surfaces that error verbatim instead of + /// retrying statically. pub fn update_db_cfg( &self, extension: Option<&str>, + dynamic: bool, ) -> Result { let mut args = self.base_args(); args.push("/UpdateDBCfg".to_owned()); + if dynamic { + args.push("-Dynamic+".to_owned()); + } if let Some(extension) = extension { args.push("-Extension".to_owned()); args.push(extension.to_owned()); @@ -439,6 +448,84 @@ mod tests { .contains("failed to read designer /Out log")); } + #[cfg(unix)] + #[test] + fn update_db_cfg_emits_dynamic_flag_when_requested() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("1cv8"); + let args_log = dir.path().join("args.log"); + write_script( + &script, + &format!("printf '%s\n' \"$@\" > \"{}\"\nexit 0", args_log.display()), + ); + let runner = ProcessExecutor; + let dsl = DesignerDsl::new( + script, + V8Connection::from_connection_string("File=/tmp/ib"), + &runner as &dyn ProcessRunner, + None, + ); + + dsl.update_db_cfg(None, true).expect("dynamic update"); + + let args = fs::read_to_string(&args_log).expect("args log"); + assert!(args.contains("/UpdateDBCfg")); + assert!(args.contains("-Dynamic+")); + } + + #[cfg(unix)] + #[test] + fn update_db_cfg_omits_dynamic_flag_by_default() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("1cv8"); + let args_log = dir.path().join("args.log"); + write_script( + &script, + &format!("printf '%s\n' \"$@\" > \"{}\"\nexit 0", args_log.display()), + ); + let runner = ProcessExecutor; + let dsl = DesignerDsl::new( + script, + V8Connection::from_connection_string("File=/tmp/ib"), + &runner as &dyn ProcessRunner, + None, + ); + + dsl.update_db_cfg(Some("Ext"), false) + .expect("static update"); + + let args = fs::read_to_string(&args_log).expect("args log"); + assert!(args.contains("/UpdateDBCfg")); + assert!(!args.contains("-Dynamic")); + assert!(args.contains("-Extension")); + } + + #[cfg(unix)] + #[test] + fn base_args_propagate_unlock_code_to_every_designer_command() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("1cv8"); + let args_log = dir.path().join("args.log"); + write_script( + &script, + &format!("printf '%s\n' \"$@\" > \"{}\"\nexit 0", args_log.display()), + ); + let runner = ProcessExecutor; + let mut connection = V8Connection::from_connection_string("File=/tmp/ib"); + connection.unlock_code = Some("seal".to_owned()); + let dsl = DesignerDsl::new(script, connection, &runner as &dyn ProcessRunner, None); + + dsl.update_db_cfg(None, false).expect("update"); + + let args = fs::read_to_string(&args_log).expect("args log"); + let lines: Vec<&str> = args.lines().collect(); + let uc_index = lines + .iter() + .position(|line| *line == "/UC") + .expect("/UC token"); + assert_eq!(lines.get(uc_index + 1).copied(), Some("seal")); + } + #[cfg(unix)] #[test] fn dump_config_to_files_requests_config_dump_info_update() { diff --git a/src/platform/download.rs b/src/platform/download.rs new file mode 100644 index 0000000..b2ff223 --- /dev/null +++ b/src/platform/download.rs @@ -0,0 +1,251 @@ +use std::io::Read; +use std::time::{Duration, Instant}; + +use reqwest::blocking::Client; +use reqwest::StatusCode; +use thiserror::Error; +use tokio_util::sync::CancellationToken; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const RETRY_ATTEMPTS: usize = 3; +const RETRY_DELAY: Duration = Duration::from_secs(2); +const READ_BUFFER_SIZE: usize = 64 * 1024; +const MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; + +#[derive(Debug, Error)] +pub enum DownloadError { + #[error("HTTP client setup failed: {0}")] + Client(reqwest::Error), + + #[error("HTTP GET {url} failed: {source}")] + Request { url: String, source: reqwest::Error }, + + #[error("HTTP GET {url} returned status {status}")] + Status { url: String, status: StatusCode }, + + #[error("HTTP response read failed for {url}: {source}")] + Read { url: String, source: std::io::Error }, + + #[error( + "HTTP response for {url} exceeds maximum download size {max_bytes} bytes: {size_bytes} bytes" + )] + ResponseTooLarge { + url: String, + size_bytes: u64, + max_bytes: u64, + }, + + #[error("HTTP download timed out after {timeout_ms}ms")] + TimedOut { timeout_ms: u64 }, + + #[error("HTTP download was cancelled")] + Cancelled, + + #[error("response is not UTF-8: {0}")] + InvalidUtf8(#[from] std::string::FromUtf8Error), +} + +pub fn get_text( + url: &str, + timeout: Option, + cancellation: &CancellationToken, +) -> Result { + let bytes = get_bytes(url, timeout, cancellation)?; + String::from_utf8(bytes).map_err(DownloadError::InvalidUtf8) +} + +pub fn get_bytes( + url: &str, + timeout: Option, + cancellation: &CancellationToken, +) -> Result, DownloadError> { + if timeout.is_some_and(|value| value.is_zero()) { + return Err(DownloadError::TimedOut { timeout_ms: 0 }); + } + + let started = Instant::now(); + let mut last_error = None; + + for attempt in 1..=RETRY_ATTEMPTS { + ensure_not_cancelled(cancellation)?; + let request_timeout = remaining_budget(timeout, started)?; + let client = build_client(request_timeout)?; + + match download_once(&client, url, timeout, started, cancellation) { + Ok(bytes) => return Ok(bytes), + Err(error) if attempt < RETRY_ATTEMPTS && error.is_retryable() => { + last_error = Some(error); + sleep_before_retry(cancellation, timeout, started)?; + } + Err(error) => return Err(error), + } + } + + Err(last_error.unwrap_or(DownloadError::Cancelled)) +} + +fn build_client(timeout: Option) -> Result { + let connect_timeout = timeout + .map(|value| value.min(CONNECT_TIMEOUT)) + .unwrap_or(CONNECT_TIMEOUT); + let mut builder = Client::builder() + .connect_timeout(connect_timeout) + .user_agent("v8-runner"); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + builder.build().map_err(DownloadError::Client) +} + +fn download_once( + client: &Client, + url: &str, + timeout: Option, + started: Instant, + cancellation: &CancellationToken, +) -> Result, DownloadError> { + ensure_not_cancelled(cancellation)?; + let url_text = url.to_owned(); + let mut response = client + .get(url) + .header("Accept", "application/vnd.github+json") + .send() + .map_err(|source| DownloadError::Request { + url: url_text.clone(), + source, + })?; + + if !response.status().is_success() { + return Err(DownloadError::Status { + url: url_text, + status: response.status(), + }); + } + + let content_length = response.content_length(); + if let Some(size_bytes) = content_length { + ensure_allowed_download_size(&url_text, size_bytes)?; + } + let capacity = content_length + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or_default(); + let mut bytes = Vec::with_capacity(capacity); + let mut buffer = vec![0_u8; READ_BUFFER_SIZE]; + + loop { + ensure_not_cancelled(cancellation)?; + let _ = remaining_budget(timeout, started)?; + let read = response + .read(&mut buffer) + .map_err(|source| DownloadError::Read { + url: url_text.clone(), + source, + })?; + if read == 0 { + return Ok(bytes); + } + let next_len = bytes + .len() + .checked_add(read) + .and_then(|value| u64::try_from(value).ok()) + .unwrap_or(u64::MAX); + ensure_allowed_download_size(&url_text, next_len)?; + bytes.extend_from_slice(&buffer[..read]); + } +} + +fn ensure_allowed_download_size(url: &str, size_bytes: u64) -> Result<(), DownloadError> { + if size_bytes > MAX_DOWNLOAD_BYTES { + Err(DownloadError::ResponseTooLarge { + url: url.to_owned(), + size_bytes, + max_bytes: MAX_DOWNLOAD_BYTES, + }) + } else { + Ok(()) + } +} + +fn remaining_budget( + timeout: Option, + started: Instant, +) -> Result, DownloadError> { + let Some(limit) = timeout else { + return Ok(None); + }; + limit + .checked_sub(started.elapsed()) + .filter(|remaining| !remaining.is_zero()) + .map(Some) + .ok_or_else(|| DownloadError::TimedOut { + timeout_ms: limit.as_millis() as u64, + }) +} + +fn sleep_before_retry( + cancellation: &CancellationToken, + timeout: Option, + started: Instant, +) -> Result<(), DownloadError> { + let delay = remaining_budget(timeout, started)? + .map(|remaining| remaining.min(RETRY_DELAY)) + .unwrap_or(RETRY_DELAY); + let until = Instant::now() + delay; + while Instant::now() < until { + ensure_not_cancelled(cancellation)?; + std::thread::sleep( + Duration::from_millis(25).min(until.saturating_duration_since(Instant::now())), + ); + } + Ok(()) +} + +fn ensure_not_cancelled(cancellation: &CancellationToken) -> Result<(), DownloadError> { + if cancellation.is_cancelled() { + Err(DownloadError::Cancelled) + } else { + Ok(()) + } +} + +impl DownloadError { + fn is_retryable(&self) -> bool { + match self { + DownloadError::Request { source, .. } => source.is_timeout() || source.is_connect(), + DownloadError::Read { .. } => true, + DownloadError::Status { status, .. } => status.is_server_error(), + DownloadError::Client(_) + | DownloadError::ResponseTooLarge { .. } + | DownloadError::TimedOut { .. } + | DownloadError::Cancelled + | DownloadError::InvalidUtf8(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn download_size_limit_accepts_boundary_size() { + ensure_allowed_download_size("https://example.invalid/file", MAX_DOWNLOAD_BYTES) + .expect("boundary size is accepted"); + } + + #[test] + fn download_size_limit_rejects_oversized_response() { + let error = + ensure_allowed_download_size("https://example.invalid/file", MAX_DOWNLOAD_BYTES + 1) + .expect_err("oversized response is rejected"); + + assert!(matches!( + error, + DownloadError::ResponseTooLarge { + size_bytes, + max_bytes, + .. + } if size_bytes == MAX_DOWNLOAD_BYTES + 1 && max_bytes == MAX_DOWNLOAD_BYTES + )); + } +} diff --git a/src/platform/git.rs b/src/platform/git.rs new file mode 100644 index 0000000..6bba859 --- /dev/null +++ b/src/platform/git.rs @@ -0,0 +1,25 @@ +use std::path::Path; +use std::process::{Command, Stdio}; + +/// Returns Git's effective ignore decision for `path`. +/// +/// `None` means Git is unavailable, `path` is outside a worktree, or Git reported +/// an execution error that should fall back to local `.gitignore` editing. +pub fn check_ignored(path: &Path) -> Option { + let workdir = path.parent().unwrap_or_else(|| Path::new(".")); + let status = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["check-ignore", "--quiet", "--"]) + .arg(path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok()?; + + match status.code() { + Some(0) => Some(true), + Some(1) => Some(false), + _ => None, + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 45074fa..9b49e3f 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,8 +1,10 @@ pub mod connection; pub mod designer; +pub mod download; pub mod edt; pub mod edt_session; pub mod enterprise; +pub mod git; pub mod ibcmd; pub mod interactive; pub mod locator; diff --git a/src/platform/process.rs b/src/platform/process.rs index f86f9f3..e85fdf5 100644 --- a/src/platform/process.rs +++ b/src/platform/process.rs @@ -615,6 +615,9 @@ fn is_sensitive_flag(arg: &str) -> bool { "--db-pwd", "--target-database-password", "--target-db-pwd", + // `/UC` carries the infobase unlock code (TASK-124) — treat it as a secret. + "/UC", + "-UC", ]; FLAGS.iter().any(|flag| arg.eq_ignore_ascii_case(flag)) @@ -629,6 +632,9 @@ fn split_sensitive_assignment(arg: &str) -> Option<(&str, &str)> { "--db-pwd", "--target-database-password", "--target-db-pwd", + // `/UC` carries the infobase unlock code (TASK-124) — treat it as a secret. + "/UC", + "-UC", ]; let (key, value) = arg.split_once('=')?; @@ -707,6 +713,26 @@ mod tests { ); } + #[test] + fn render_command_masks_unlock_code_flag() { + let rendered = render_command(&ProcessRequest { + program: Path::new("/tmp/1cv8").to_path_buf(), + args: vec![ + "DESIGNER".to_owned(), + "/UC".to_owned(), + "seal-42".to_owned(), + "/UpdateDBCfg".to_owned(), + ], + workdir: None, + stdout_log_path: None, + stderr_log_path: None, + startup_probe: None, + }); + + assert!(rendered.contains("/UC ***")); + assert!(!rendered.contains("seal-42")); + } + #[test] fn render_command_masks_ibcmd_password_flags() { let rendered = render_command(&ProcessRequest { diff --git a/src/use_cases/artifacts.rs b/src/use_cases/artifacts.rs index a37a9cb..db4cfff 100644 --- a/src/use_cases/artifacts.rs +++ b/src/use_cases/artifacts.rs @@ -768,8 +768,10 @@ fn resolve_target( let canonical_output_path = nearest_existing_canonical_path(&output_path).map_err(|error| { AppError::Runtime(format!("failed to canonicalize output path: {error}")) })?; - let canonical_base_path = nearest_existing_canonical_path(&config.base_path) - .map_err(|error| AppError::Runtime(format!("failed to canonicalize basePath: {error}")))?; + let canonical_base_path = + nearest_existing_canonical_path(&config.base_path).map_err(|error| { + AppError::Runtime(format!("failed to canonicalize project base path: {error}")) + })?; let canonical_work_path = nearest_existing_canonical_path(&config.work_path) .map_err(|error| AppError::Runtime(format!("failed to canonicalize workPath: {error}")))?; let target_identity = stable_path_identity(&canonical_output_path); @@ -886,7 +888,7 @@ fn validate_publish_target(resolved: &ResolvedArtifactsTarget) -> Result<(), App } if resolved.canonical_output_path == resolved.canonical_base_path { return Err(AppError::Validation( - "artifacts output must not equal basePath".to_owned(), + "artifacts output must not equal project base path".to_owned(), )); } if resolved.canonical_output_path == resolved.canonical_work_path { diff --git a/src/use_cases/build_project.rs b/src/use_cases/build_project.rs index 5c2b359..6086b90 100644 --- a/src/use_cases/build_project.rs +++ b/src/use_cases/build_project.rs @@ -122,6 +122,14 @@ fn run_build_ibcmd( Ok(result) } +/// Resolves the effective `/UpdateDBCfg -Dynamic+` flag for a build invocation. +/// +/// `args.dynamic_update` (CLI/MCP one-shot override) takes priority over +/// `config.build.dynamic_update` (project-wide default). Default is `false`. +pub(crate) fn resolve_dynamic_update(config: &AppConfig, args: &BuildArgs) -> bool { + args.dynamic_update.unwrap_or(config.build.dynamic_update) +} + fn validate_designer_supported_matrix(config: &AppConfig) -> Option { if config.format == SourceFormat::Designer && matches!( @@ -416,6 +424,11 @@ fn recreate_directory(path: &Path) -> std::io::Result<()> { std::fs::create_dir_all(path) } +// Designer build step is intentionally wide: it threads the execution context, config, the +// source set and its commit/load directories, the partial-load file list and the dynamic +// flag. Refactoring into a builder is out of scope for TASK-124; the IBCMD sibling already +// breaches the same limit (9/7) without an allow. +#[allow(clippy::too_many_arguments)] fn execute_source_set_step( context: &ExecutionContext, config: &AppConfig, @@ -427,6 +440,7 @@ fn execute_source_set_step( step_index: usize, partial_paths: Option<&[PathBuf]>, commit: &StepCommit, + dynamic_update_db_cfg: bool, ) -> Result, AppError> { if let Some(error) = interruption_before_safe_point( context, @@ -517,7 +531,7 @@ fn execute_source_set_step( "update", InterruptionSafetyClass::CriticalNonAbortable, )? - .update_db_cfg(extension_name(source_set)) + .update_db_cfg(extension_name(source_set), dynamic_update_db_cfg) .map_err(AppError::from)?; ensure_platform_success("update_db_cfg", source_set, &update_result)?; @@ -851,6 +865,7 @@ mod tests { ], build: BuildConfig { partial_load_threshold: threshold, + dynamic_update: false, }, tools: ToolsConfig { platform: PlatformToolConfig { @@ -891,6 +906,7 @@ mod tests { ], build: BuildConfig { partial_load_threshold: 20, + dynamic_update: false, }, tools: ToolsConfig { platform: PlatformToolConfig { @@ -914,6 +930,15 @@ mod tests { BuildArgs { full_rebuild, source_set: None, + dynamic_update: None, + } + } + + fn build_args_dynamic(full_rebuild: bool, dynamic: bool) -> BuildArgs { + BuildArgs { + full_rebuild, + source_set: None, + dynamic_update: Some(dynamic), } } @@ -2582,6 +2607,206 @@ mod tests { assert!(matches!(rerun.steps[0].mode, BuildMode::Skipped)); } + #[cfg(unix)] + #[test] + fn dynamic_cli_flag_emits_dynamic_marker_in_update_db_cfg() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + // CLI `--dynamic` should reach DESIGNER as `/UpdateDBCfg -Dynamic+`. + let result = run_build(&config, &build_args_dynamic(false, true)).expect("dynamic build"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + assert!(result.ok); + assert!(calls_text.contains("/UpdateDBCfg")); + assert!(calls_text.contains("-Dynamic+")); + } + + #[cfg(unix)] + #[test] + fn build_dynamic_update_config_default_emits_dynamic_marker() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let mut config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + config.build.dynamic_update = true; + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + // `build.dynamicUpdate: true` in v8project.yaml is enough — no CLI flag needed. + let result = run_build(&config, &build_args(false)).expect("dynamic build"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + assert!(result.ok); + assert!(calls_text.contains("-Dynamic+")); + } + + #[cfg(unix)] + #[test] + fn build_without_dynamic_flag_emits_static_update_db_cfg() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + // Regression guard: default behavior MUST NOT add `-Dynamic+`. + let _ = run_build(&config, &build_args(false)).expect("static build"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + assert!(calls_text.contains("/UpdateDBCfg")); + assert!(!calls_text.contains("-Dynamic")); + } + + #[cfg(unix)] + #[test] + fn edt_build_designer_dynamic_update_modes_reach_update_db_cfg() { + for (case, config_dynamic_update, arg_dynamic_update, expected_dynamic) in [ + ("cli_dynamic", false, Some(true), true), + ("config_default_dynamic", true, None, true), + ("cli_static_overrides_config", true, Some(false), false), + ("static_default", false, None, false), + ] { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let platform_script = dir.path().join("platform").join("bin").join("1cv8"); + let edt_script = dir.path().join("edt").join("1cedtcli"); + let designer_calls = dir.path().join(format!("{case}-designer-calls.log")); + let edt_calls = dir.path().join(format!("{case}-edt-calls.log")); + create_source_tree(&base); + write_designer_script(&platform_script, &designer_calls, None); + write_edt_script(&edt_script, &edt_calls, None); + let mut config = + build_edt_config(&base, &work, &dir.path().join("platform"), &edt_script); + config.build.dynamic_update = config_dynamic_update; + prime_edt_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + format!("procedure Test()\n // changed in {case}\nendprocedure"), + ) + .expect("modify edt main"); + + let result = run_build( + &config, + &BuildArgs { + full_rebuild: false, + source_set: None, + dynamic_update: arg_dynamic_update, + }, + ) + .expect(case); + let calls_text = fs::read_to_string(&designer_calls).expect("designer calls"); + + assert!(result.ok, "{case}"); + assert!(calls_text.contains("/UpdateDBCfg"), "{case}"); + assert_eq!( + calls_text.contains("-Dynamic+"), + expected_dynamic, + "{case}: {calls_text}" + ); + } + } + + #[cfg(unix)] + #[test] + fn infobase_unlock_code_propagates_to_designer_args() { + let dir = tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let work = dir.path().join("work"); + let script = dir.path().join("1cv8"); + let calls = dir.path().join("calls.log"); + create_source_tree(&base); + write_designer_script(&script, &calls, None); + let mut config = build_config( + &base, + &work, + &script, + 20, + SourceFormat::Designer, + BuilderBackend::Designer, + ); + config.infobase.unlock_code = Some("seal-42".to_owned()); + prime_snapshots(&config); + + fs::write( + base.join("main") + .join("Catalogs.Items") + .join("ObjectModule.bsl"), + "procedure Test()\n // changed\nendprocedure", + ) + .expect("modify main"); + + let _ = run_build(&config, &build_args(false)).expect("build with /UC"); + let calls_text = fs::read_to_string(&calls).expect("calls"); + + // `/UC` reaches DESIGNER as a separate token + value pair. + assert!(calls_text.contains("/UC")); + assert!(calls_text.contains("seal-42")); + } + #[cfg(unix)] #[test] fn changed_extension_only_loads_extension_and_preserves_other_storage() { @@ -2656,6 +2881,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("ext".to_owned()), + dynamic_update: None, }, ) .expect("build"); @@ -2707,6 +2933,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("ext".to_owned()), + dynamic_update: None, }, ) .expect("build"); @@ -2745,6 +2972,7 @@ mod tests { &BuildArgs { full_rebuild: false, source_set: Some("missing".to_owned()), + dynamic_update: None, }, ) .expect_err("unknown source-set must fail"); diff --git a/src/use_cases/build_project/coordinator.rs b/src/use_cases/build_project/coordinator.rs index 4f06468..5d72410 100644 --- a/src/use_cases/build_project/coordinator.rs +++ b/src/use_cases/build_project/coordinator.rs @@ -174,6 +174,7 @@ pub(super) fn run_build_designer( index, partial_paths.as_deref(), &commit, + resolve_dynamic_update(config, args), ) { Ok(warnings) => push_build_step( &mut steps, @@ -888,6 +889,7 @@ pub(super) fn run_build_edt( index, partial_paths.as_deref(), &commit, + resolve_dynamic_update(config, args), ) } BuilderBackend::Ibcmd => { diff --git a/src/use_cases/config_init.rs b/src/use_cases/config_init.rs index 0ddbed1..3ddcd3d 100644 --- a/src/use_cases/config_init.rs +++ b/src/use_cases/config_init.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; use std::time::Instant; use crate::config::schema::main_config_schema_url; @@ -11,6 +12,9 @@ use crate::support::source_descriptor::{ self, SourceDescriptorParseError, SourceDescriptorPurpose, SourceSetRootScanError, }; +const LOCAL_CONFIG_FILE_NAME: &str = "v8project.local.yaml"; +const LOCAL_CONFIG_SCHEMA_MODEL_LINE: &str = "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json"; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigInitRequest { pub project_dir: PathBuf, @@ -58,14 +62,23 @@ pub fn execute(request: &ConfigInitRequest) -> Result Result Result Result<(), AppError> { + let content = if path.exists() { + let existing = std::fs::read_to_string(path).map_err(|error| { + AppError::Runtime(format!( + "failed to read local config file '{}': {error}", + path.display() + )) + })?; + with_local_schema_modeline(&existing) + } else { + render_empty_local_config() + }; + + std::fs::write(path, content).map_err(|error| { + AppError::Runtime(format!( + "failed to write local config file '{}': {error}", + path.display() + )) + }) +} + +fn with_local_schema_modeline(existing: &str) -> String { + if existing.trim().is_empty() { + return render_empty_local_config(); + } + + let mut lines = existing.lines(); + let first_line = lines.next().unwrap_or_default(); + let mut content = String::new(); + if first_line + .trim_start() + .starts_with("# yaml-language-server: $schema=") + { + content.push_str(LOCAL_CONFIG_SCHEMA_MODEL_LINE); + content.push('\n'); + let remainder = lines.collect::>().join("\n"); + if !remainder.is_empty() { + content.push_str(&remainder); + content.push('\n'); + } + } else { + content.push_str(LOCAL_CONFIG_SCHEMA_MODEL_LINE); + content.push('\n'); + content.push_str(existing); + if !existing.ends_with('\n') { + content.push('\n'); + } + } + if yaml_document_is_empty(&content) { + content.push_str("{}\n"); + } + content +} + +fn render_empty_local_config() -> String { + format!("{LOCAL_CONFIG_SCHEMA_MODEL_LINE}\n{{}}\n") +} + +fn yaml_document_is_empty(content: &str) -> bool { + matches!( + serde_yaml::from_str::(content), + Ok(serde_yaml::Value::Null) + ) +} + +fn ensure_gitignore_ignores_local_config( + local_config_path: &Path, + gitignore_path: &Path, +) -> Result<(), AppError> { + match crate::platform::git::check_ignored(local_config_path) { + Some(true) => Ok(()), + Some(false) => append_local_config_gitignore_pattern(gitignore_path, false), + None => append_local_config_gitignore_pattern(gitignore_path, true), + } +} + +fn append_local_config_gitignore_pattern( + path: &Path, + skip_existing_pattern: bool, +) -> Result<(), AppError> { + if path.exists() { + let existing = std::fs::read_to_string(path).map_err(|error| { + AppError::Runtime(format!( + "failed to read gitignore file '{}': {error}", + path.display() + )) + })?; + if skip_existing_pattern && gitignore_mentions_local_config(&existing) { + return Ok(()); + } + + let mut content = existing; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str("v8project.local.yaml\n"); + std::fs::write(path, content).map_err(|error| { + AppError::Runtime(format!( + "failed to write gitignore file '{}': {error}", + path.display() + )) + })?; + return Ok(()); + } + + std::fs::write(path, "v8project.local.yaml\n").map_err(|error| { + AppError::Runtime(format!( + "failed to write gitignore file '{}': {error}", + path.display() + )) + }) +} + +fn gitignore_mentions_local_config(content: &str) -> bool { + content.lines().any(|line| { + let pattern = line.trim(); + !pattern.is_empty() + && !pattern.starts_with('#') + && !pattern.starts_with('!') + && (pattern == LOCAL_CONFIG_FILE_NAME + || pattern == "/v8project.local.yaml" + || pattern == "**/v8project.local.yaml") + }) +} + fn resolve_output_path(project_dir: &Path, output_path: &Path) -> PathBuf { if output_path.is_absolute() { output_path.to_path_buf() @@ -493,6 +630,21 @@ fn build_source_sets( Ok(source_sets) } +fn source_sets_relative_to_config_dir( + project_dir: &Path, + config_dir: &Path, + source_sets: &[ConfigInitSourceSet], +) -> Vec { + source_sets + .iter() + .map(|source_set| ConfigInitSourceSet { + name: source_set.name.clone(), + source_type: source_set.source_type.clone(), + path: relative_path(config_dir, &project_dir.join(&source_set.path)), + }) + .collect() +} + fn source_set_name( project_dir: &Path, source: &DetectedSource, @@ -634,15 +786,56 @@ fn deduplicate_names(source_sets: &mut [ConfigInitSourceSet]) -> Result<(), AppE } fn relative_path(root: &Path, path: &Path) -> String { - path.strip_prefix(root) + if let Some(relative) = path + .strip_prefix(root) .ok() .filter(|relative| !relative.as_os_str().is_empty()) - .map(|relative| relative.display().to_string()) - .unwrap_or_else(|| ".".to_owned()) + { + return relative.display().to_string(); + } + + let root_components = normalized_components(root); + let path_components = normalized_components(path); + let common_len = root_components + .iter() + .zip(path_components.iter()) + .take_while(|(left, right)| left == right) + .count(); + + let mut relative = PathBuf::new(); + for _ in common_len..root_components.len() { + relative.push(".."); + } + for component in &path_components[common_len..] { + relative.push(component); + } + + if relative.as_os_str().is_empty() { + ".".to_owned() + } else { + relative.display().to_string() + } +} + +fn normalized_components(path: &Path) -> Vec { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => components.push(prefix.as_os_str().to_os_string()), + Component::RootDir | Component::CurDir => {} + Component::ParentDir => match components.last() { + Some(last) if last != ".." => { + components.pop(); + } + _ => components.push(OsString::from("..")), + }, + Component::Normal(part) => components.push(part.to_os_string()), + } + } + components } fn render_config( - project_dir: &Path, connection: Option<&str>, format: ConfigFormatRequest, builder: ConfigBuilderRequest, @@ -656,10 +849,6 @@ fn render_config( main_config_schema_url() )); yaml.push_str("# Generated by v8-runner config init\n"); - yaml.push_str(&format!( - "basePath: '{}'\n", - escape_yaml(&project_dir.display().to_string()) - )); yaml.push_str("workPath: 'build'\n"); yaml.push_str("execution_timeout: 300000\n"); yaml.push_str(&format!("format: {}\n", format.as_yaml())); @@ -789,6 +978,7 @@ mod tests { }; use crate::config::loader::load_config; use std::path::Path; + use std::process::Command; use tempfile::tempdir; fn write_file(path: &Path, contents: &str) { @@ -821,6 +1011,16 @@ mod tests { write_file(&project_dir.join("src").join("root.xml"), descriptor_xml); } + fn init_git_repo(path: &Path) { + let status = Command::new("git") + .arg("init") + .arg("-q") + .arg(path) + .status() + .expect("run git init"); + assert!(status.success()); + } + fn create_native_edt_project( project_dir: &Path, name: &str, @@ -998,6 +1198,201 @@ mod tests { assert!(error.to_string().contains("already exists")); } + #[test] + fn primary_config_write_failure_does_not_create_local_side_effects() { + let dir = tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::create_dir(dir.path().join("v8project.yaml")).expect("config path directory"); + + let error = execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: true, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect_err("directory output path cannot be written as file"); + + assert!(error.to_string().contains("failed to write config file")); + assert!(!dir.path().join("v8project.local.yaml").exists()); + assert!(!dir.path().join(".gitignore").exists()); + } + + #[test] + fn creates_empty_local_overlay_and_gitignore_entry() { + let dir = tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + + let result = execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + assert_eq!( + result.local_path, + dir.path() + .join("v8project.local.yaml") + .display() + .to_string() + ); + assert_eq!( + result.gitignore_path, + dir.path().join(".gitignore").display().to_string() + ); + let local_config = + std::fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local config"); + assert_eq!( + local_config, + format!("{}\n{{}}\n", super::LOCAL_CONFIG_SCHEMA_MODEL_LINE) + ); + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!(gitignore, "v8project.local.yaml\n"); + } + + #[test] + fn preserves_existing_local_overlay_and_gitignore_pattern() { + let dir = tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write( + dir.path().join("v8project.local.yaml"), + "# yaml-language-server: $schema=https://old.example/schema.json\nworkPath: local-work\n", + ) + .expect("local config"); + std::fs::write( + dir.path().join(".gitignore"), + "# local state\n**/v8project.local.yaml\n", + ) + .expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + let local_config = + std::fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local config"); + assert!(local_config.starts_with(super::LOCAL_CONFIG_SCHEMA_MODEL_LINE)); + assert!(local_config.contains("workPath: local-work")); + assert!(!local_config.contains("old.example")); + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!(gitignore, "# local state\n**/v8project.local.yaml\n"); + } + + #[test] + fn appends_gitignore_entry_when_existing_pattern_targets_only_nested_local_config() { + let dir = tempdir().expect("tempdir"); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write(dir.path().join(".gitignore"), "docs/v8project.local.yaml\n") + .expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!( + gitignore, + "docs/v8project.local.yaml\nv8project.local.yaml\n" + ); + } + + #[test] + fn does_not_append_gitignore_entry_when_git_already_ignores_local_config() { + let dir = tempdir().expect("tempdir"); + init_git_repo(dir.path()); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write(dir.path().join(".gitignore"), "*.local.yaml\n").expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!(gitignore, "*.local.yaml\n"); + } + + #[test] + fn appends_gitignore_entry_when_existing_pattern_is_negated() { + let dir = tempdir().expect("tempdir"); + init_git_repo(dir.path()); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write( + dir.path().join(".gitignore"), + "v8project.local.yaml\n!v8project.local.yaml\n", + ) + .expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!( + gitignore, + "v8project.local.yaml\n!v8project.local.yaml\nv8project.local.yaml\n" + ); + } + + #[test] + fn does_not_create_nested_gitignore_when_root_gitignore_covers_output_override_local_config() { + let dir = tempdir().expect("tempdir"); + init_git_repo(dir.path()); + std::fs::write(dir.path().join("Configuration.xml"), "").expect("main xml"); + std::fs::write( + dir.path().join(".gitignore"), + "config/v8project.local.yaml\n", + ) + .expect("gitignore"); + + execute(&ConfigInitRequest { + project_dir: dir.path().to_path_buf(), + output_path: "config/v8project.yaml".into(), + force: false, + connection: None, + format: ConfigFormatRequest::Designer, + builder: ConfigBuilderRequest::Designer, + }) + .expect("init config"); + + assert!(dir + .path() + .join("config") + .join("v8project.local.yaml") + .exists()); + assert!(!dir.path().join("config").join(".gitignore").exists()); + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert_eq!(gitignore, "config/v8project.local.yaml\n"); + } + #[test] fn extension_detection_uses_designer_xml_marker() { let dir = tempdir().expect("tempdir"); diff --git a/src/use_cases/context.rs b/src/use_cases/context.rs index c8da26f..dfdc175 100644 --- a/src/use_cases/context.rs +++ b/src/use_cases/context.rs @@ -7,6 +7,7 @@ use crate::platform::process::{ProcessExecutionPolicy, ProcessInterruptionSafety /// Identifies the logical command being executed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandName { + ToolsDownload, Init, Extensions, Build, @@ -23,6 +24,7 @@ impl CommandName { /// Returns the stable command label used in logs and CLI envelopes. pub const fn as_str(self) -> &'static str { match self { + Self::ToolsDownload => "tools download", Self::Init => "init", Self::Extensions => "extensions", Self::Build => "build", diff --git a/src/use_cases/convert_sources.rs b/src/use_cases/convert_sources.rs index 6378cff..d22d34c 100644 --- a/src/use_cases/convert_sources.rs +++ b/src/use_cases/convert_sources.rs @@ -772,13 +772,15 @@ fn validate_explicit_convert_target_roots( target_path: &Path, canonical_target_path: &Path, ) -> Result<(), AppError> { - let canonical_base_path = nearest_existing_canonical_path(&config.base_path) - .map_err(|error| AppError::Runtime(format!("failed to canonicalize basePath: {error}")))?; + let canonical_base_path = + nearest_existing_canonical_path(&config.base_path).map_err(|error| { + AppError::Runtime(format!("failed to canonicalize project base path: {error}")) + })?; if canonical_target_path == canonical_base_path || canonical_target_path.starts_with(&canonical_base_path) { return Err(AppError::Validation(format!( - "convert --output target must not be inside basePath: basePath={}, target={}", + "convert --output target must not be inside project base path: project_base_path={}, target={}", config.base_path.display(), target_path.display() ))); @@ -1442,7 +1444,7 @@ fn source_set_output_relative_path( let relative = if raw_path.is_absolute() { raw_path.strip_prefix(&config.base_path).map_err(|_| { AppError::Validation(format!( - "convert --output requires source-set '{}' path to be relative to basePath", + "convert --output requires source-set '{}' path to be relative to project base path", source_set.name )) })? diff --git a/src/use_cases/dump_config.rs b/src/use_cases/dump_config.rs index fc073e3..890a470 100644 --- a/src/use_cases/dump_config.rs +++ b/src/use_cases/dump_config.rs @@ -908,8 +908,10 @@ fn resolve_target(config: &AppConfig, args: &DumpArgs) -> Result UseCaseResult Err(InitExecutionFailure::with_payload(error, result)), @@ -71,6 +67,14 @@ fn run_init(context: &ExecutionContext, config: &AppConfig) -> UseCaseResult, ok: bool) -> InitResult { + InitResult { + ok, + steps, + duration_ms: started.elapsed().as_millis() as u64, + } +} + fn record_step( steps: &mut Vec, first_error: &mut Option, @@ -79,9 +83,42 @@ fn record_step( if first_error.is_none() { *first_error = outcome.error.clone(); } + log_step_status(&outcome.step); steps.push(outcome.step); } +fn log_step_status(step: &InitStep) { + let Some(label) = live_step_label(step) else { + return; + }; + let Some((status, marker)) = live_status_marker(&step.status) else { + return; + }; + log_live_stage_status( + label, + status, + &format!("{marker} {}: {}", step.target, step.action), + ); +} + +fn live_step_label(step: &InitStep) -> Option<&'static str> { + match (step.target.as_str(), step.action.as_str()) { + ("infobase", "create") => Some("init: infobase create"), + ("edt_workspace", "import") if !matches!(step.status, InitStepStatus::Skipped) => { + Some("init: edt import") + } + _ => None, + } +} + +fn live_status_marker(status: &InitStepStatus) -> Option<(LiveStageStatus, &'static str)> { + match status { + InitStepStatus::Ok => Some((LiveStageStatus::Succeeded, "✓")), + InitStepStatus::Failed => Some((LiveStageStatus::Failed, "✗")), + InitStepStatus::Skipped => None, + } +} + #[derive(Debug, Clone)] struct StepOutcome { step: InitStep, @@ -407,6 +444,7 @@ fn ensure_edt_workspace( ) }; debug!("[EDT] Инициализация workspace: {}", workspace.display()); + let mut imported_projects = Vec::new(); for project in projects { if let Some(outcome) = interruption_step_outcome( context, @@ -418,7 +456,10 @@ fn ensure_edt_workspace( return outcome; } debug!("[EDT] Импорт проекта: {}", project.name); - log_live_stage("init: edt import", "[EDT] importing source-set project"); + log_live_stage( + "init: edt import", + &format!("[EDT] importing source-set project '{}'", project.name), + ); match dsl.import_project(&project.path) { Ok(result) => { if let Err(error) = @@ -436,6 +477,7 @@ fn ensure_edt_workspace( ) } } + imported_projects.push(project.name); } if let Err(error) = std::fs::write(&marker, b"initialized\n") { @@ -455,12 +497,21 @@ fn ensure_edt_workspace( "import", started, with_optional_warning( - format!("workspace initialized: {}", workspace.display()), + edt_workspace_initialized_message(&workspace, &imported_projects), context_deferred_warning(context), ), ) } +fn edt_workspace_initialized_message(workspace: &Path, imported_projects: &[String]) -> String { + let mut message = format!("workspace initialized: {}", workspace.display()); + if !imported_projects.is_empty() { + message.push_str("; imported EDT projects: "); + message.push_str(&imported_projects.join(", ")); + } + message +} + fn create_infobase_via_designer( context: &ExecutionContext, config: &AppConfig, diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index a73e22f..d055c11 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::time::Duration; use crate::config::model::AppConfig; -use crate::domain::launch::{LaunchMode, LaunchResult}; +use crate::domain::launch::{LaunchMcpTransport, LaunchMode, LaunchResult}; use crate::domain::runner::LaunchOptions; use crate::platform::enterprise::{ build_launch_args, normalize_launch_payload_path, LaunchClientMode, @@ -24,7 +24,7 @@ use crate::use_cases::request::{ }; use crate::use_cases::result::{UseCaseFailure, UseCaseResult}; use crate::use_cases::tool_extension; -use tracing::debug; +use tracing::{debug, warn}; const LAUNCH_STARTUP_PROBE: Duration = Duration::from_millis(250); @@ -119,14 +119,14 @@ pub fn execute( fn apply_mcp_resolution_to_result(result: &mut LaunchResult, meta: McpResolutionMeta) { match meta { McpResolutionMeta::Ws(params) => { - result.transport = Some("ws".to_owned()); + result.transport = Some(LaunchMcpTransport::Ws); result.client_uid = Some(params.client_uid); result.kind = Some(params.kind.as_str().to_owned()); result.manager_url = Some(params.manager_url); result.corr_id = Some(params.corr_id); } - McpResolutionMeta::Legacy { port } => { - result.transport = Some("legacy".to_owned()); + McpResolutionMeta::Mcp { port } => { + result.transport = Some(LaunchMcpTransport::Mcp); result.mcp_port = port; } } @@ -135,7 +135,7 @@ fn apply_mcp_resolution_to_result(result: &mut LaunchResult, meta: McpResolution #[derive(Debug, Clone)] enum McpResolutionMeta { Ws(WsLaunchParams), - Legacy { port: Option }, + Mcp { port: Option }, } fn launch_message(config: &AppConfig, args: &LaunchArgs, binary: &Path, pid: u32) -> String { @@ -245,14 +245,22 @@ fn effective_launch_options( let mut launch = args.launch.clone(); let (mut payload, meta) = match decision { TransportDecision::Ws => { + if client_mcp.port.is_some() || config.tools.client_mcp.port.is_some() { + warn!( + "ws transport: client_mcp.port is ignored; session-manager controls the port" + ); + } + if client_mcp.config_path.is_some() { + warn!("ws transport: client_mcp.config_path is ignored"); + } let params = resolve_ws_launch_params(config, &args.mcp_ws, kind); let snippet = params.payload_snippet(); (snippet, McpResolutionMeta::Ws(params)) } - TransportDecision::Legacy => { + TransportDecision::Mcp => { let payload = build_legacy_client_mcp_payload(client_mcp, config.tools.client_mcp.port); let port = client_mcp.port.or(config.tools.client_mcp.port); - (payload, McpResolutionMeta::Legacy { port }) + (payload, McpResolutionMeta::Mcp { port }) } }; if matches!( @@ -308,7 +316,7 @@ pub(crate) fn effective_transport( if let Some(t) = cli.transport { return match t { McpClientTransportRequest::Ws => McpClientTransport::Ws, - McpClientTransportRequest::Legacy => McpClientTransport::Legacy, + McpClientTransportRequest::Mcp => McpClientTransport::Mcp, McpClientTransportRequest::Auto => McpClientTransport::Auto, }; } @@ -354,6 +362,7 @@ mod tests { SourceFormat, SourceSetConfig, SourceSetPurpose, TestsConfig, ToolExtensionArtifactConfig, ToolExtensionConfig, ToolExtensionInput, ToolsConfig, }; + use crate::domain::launch::LaunchMcpTransport; use crate::use_cases::context::{CommandName, ExecutionContext}; use crate::use_cases::request::{ ClientMcpMode, ClientMcpOptionsRequest, LaunchRequest, LaunchTargetRequest, @@ -533,7 +542,7 @@ mod tests { launch: Default::default(), client_mcp: Some(ClientMcpOptionsRequest::default()), mcp_ws: crate::use_cases::request::McpClientWsRequest { - transport: Some(crate::use_cases::request::McpClientTransportRequest::Legacy), + transport: Some(crate::use_cases::request::McpClientTransportRequest::Mcp), ..Default::default() }, }, @@ -546,7 +555,7 @@ mod tests { .as_deref() .expect("message") .contains("v8-runner build")); - assert_eq!(result.transport.as_deref(), Some("legacy")); + assert_eq!(result.transport, Some(LaunchMcpTransport::Mcp)); assert_eq!(result.mcp_port, Some(9874)); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("ENTERPRISE")); diff --git a/src/use_cases/load_artifact.rs b/src/use_cases/load_artifact.rs index 54acc42..82dd4c2 100644 --- a/src/use_cases/load_artifact.rs +++ b/src/use_cases/load_artifact.rs @@ -328,8 +328,10 @@ fn run_load( "load: update_db_cfg", "[Конфигуратор] updating database configuration", ); + // `load` mirrors the historical static update — DBA approves the artifact, the runner + // applies the locked change. Dynamic mode is scoped to `build` per TASK-124. let update_result = update_dsl - .update_db_cfg(resolved.extension.as_deref()) + .update_db_cfg(resolved.extension.as_deref(), false) .map_err(AppError::from); let update_result = match update_result { diff --git a/src/use_cases/mcp_ws.rs b/src/use_cases/mcp_ws.rs index 3c39bc1..9551422 100644 --- a/src/use_cases/mcp_ws.rs +++ b/src/use_cases/mcp_ws.rs @@ -1,11 +1,11 @@ //! Shared helpers for assembling the WS-mode `/C` payload that connects -//! 1C-clients to `v8-client-session-manager` instead of the legacy local +//! 1C-clients to `v8-client-session-manager` instead of the local //! HTTP MCP server. //! //! The actual `/C` payload is parsed by the BSL extension `client_mcp` (see //! `Мсп_ПараметрыЗапускаКлиент`). This module is responsible for: //! -//! * choosing between the new WS transport and the legacy HTTP transport, +//! * choosing between the new WS transport and the local HTTP MCP transport, //! * probing the manager's TCP socket when the transport is `auto`, //! * generating per-launch `client_uid`/`corr_id` values, //! * and serializing the final `key=value;...` snippet. @@ -13,7 +13,7 @@ //! Higher layers (`launch_app`, `run_tests`) decide where this snippet is //! merged into the final `/C` value. -use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::net::{IpAddr, SocketAddr, TcpStream}; use std::time::Duration; use uuid::Uuid; @@ -33,9 +33,9 @@ pub const PROBE_TIMEOUT_MS: u64 = 200; pub enum McpClientTransport { /// Force WS-only mode. Resolution fails if the manager is unreachable. Ws, - /// Force the legacy local HTTP transport (`runMcp[=...][;mcpPort=...]`). - Legacy, - /// Probe the manager: WS when reachable, legacy otherwise. + /// Force the local HTTP MCP transport (`runMcp[=...][;mcpPort=...]`). + Mcp, + /// Probe the manager: WS when reachable, local HTTP MCP otherwise. #[default] Auto, } @@ -44,7 +44,7 @@ impl McpClientTransport { pub fn from_str_value(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "ws" => Some(Self::Ws), - "legacy" => Some(Self::Legacy), + "mcp" => Some(Self::Mcp), "auto" => Some(Self::Auto), _ => None, } @@ -75,7 +75,15 @@ const ALLOWED_LOG_LEVELS: &[&str] = &["off", "error", "warn", "info", "debug", " /// Returns `true` when the value is one of the levels accepted by the devkit. pub fn is_supported_log_level(level: &str) -> bool { - ALLOWED_LOG_LEVELS.contains(&level) + ALLOWED_LOG_LEVELS + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(level)) +} + +/// Returns `true` when a value can be embedded into the semicolon-delimited +/// `/C` payload without changing its key/value structure. +pub fn is_payload_token_safe(value: &str) -> bool { + !value.contains([';', '=']) } /// Result of resolving the WS-mode connection parameters before launch. @@ -95,16 +103,23 @@ impl WsLaunchParams { pub fn payload_snippet(&self) -> String { format!( "mcpMode=ws;manager_url={};client_uid={};kind={};corr_id={};mcp_log_level={};mcp_ws_timeout_ms={}", - self.manager_url, - self.client_uid, + encode_payload_token(&self.manager_url), + encode_payload_token(&self.client_uid), self.kind.as_str(), - self.corr_id, - self.log_level, + encode_payload_token(&self.corr_id), + encode_payload_token(&self.log_level), self.ws_timeout_ms ) } } +fn encode_payload_token(value: &str) -> String { + value + .replace('%', "%25") + .replace(';', "%3B") + .replace('=', "%3D") +} + /// Inputs to [`resolve_ws_params`]. Each field carries either an explicit /// override (CLI takes precedence over config), or `None` to fall back to the /// internal defaults. @@ -139,6 +154,7 @@ pub fn resolve_ws_params(kind: ClientKind, inputs: WsResolveInputs) -> WsLaunchP let log_level = inputs .log_level .filter(|l| !l.trim().is_empty()) + .map(|level| level.to_ascii_lowercase()) .unwrap_or_else(|| DEFAULT_MCP_LOG_LEVEL.to_owned()); let ws_timeout_ms = inputs.ws_timeout_ms.unwrap_or(DEFAULT_MCP_WS_TIMEOUT_MS); WsLaunchParams { @@ -152,7 +168,10 @@ pub fn resolve_ws_params(kind: ClientKind, inputs: WsResolveInputs) -> WsLaunchP } fn default_corr_id(client_uid: &str) -> String { - let short: String = client_uid.chars().filter(|c| *c != '-').take(8).collect(); + let mut short: String = client_uid.chars().filter(|c| *c != '-').take(8).collect(); + while short.len() < 8 { + short.push('0'); + } format!("vr-{short}") } @@ -169,7 +188,7 @@ pub enum WsResolveError { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TransportDecision { Ws, - Legacy, + Mcp, } /// Selects the effective transport given the requested mode and a `probe` @@ -183,7 +202,7 @@ where F: FnOnce(SocketAddr) -> bool, { match requested { - McpClientTransport::Legacy => Ok(TransportDecision::Legacy), + McpClientTransport::Mcp => Ok(TransportDecision::Mcp), McpClientTransport::Ws => { let addr = parse_manager_addr(manager_url)?; if probe(addr) { @@ -199,7 +218,7 @@ where if probe(addr) { Ok(TransportDecision::Ws) } else { - Ok(TransportDecision::Legacy) + Ok(TransportDecision::Mcp) } } } @@ -211,8 +230,11 @@ pub fn probe_tcp(addr: SocketAddr, timeout: Duration) -> bool { TcpStream::connect_timeout(&addr, timeout).is_ok() } -/// Parses the `host:port` portion of a `ws://host:port/path` URL and resolves -/// it to a usable [`SocketAddr`]. Falls back to lookup via `to_socket_addrs`. +/// Parses the `host:port` portion of a `ws://host:port/path` URL. +/// +/// Hostnames are intentionally rejected: transport auto-detection must stay +/// bounded by the 200 ms TCP probe, and synchronous DNS lookup would not share +/// that timeout budget. pub fn parse_manager_addr(url: &str) -> Result { let trimmed = url.trim(); if trimmed.is_empty() { @@ -232,23 +254,26 @@ pub fn parse_manager_addr(url: &str) -> Result { reason: "missing host:port".to_owned(), }); } - if !host_port.contains(':') { + let Some((host, port)) = host_port.rsplit_once(':') else { return Err(WsResolveError::InvalidManagerUrl { url: url.to_owned(), reason: "missing :port".to_owned(), }); - } - host_port - .to_socket_addrs() + }; + let host = host.trim_matches(['[', ']']); + let ip = host + .parse::() + .map_err(|_| WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "host must be an IP address".to_owned(), + })?; + let port = port + .parse::() .map_err(|err| WsResolveError::InvalidManagerUrl { url: url.to_owned(), reason: err.to_string(), - })? - .next() - .ok_or_else(|| WsResolveError::InvalidManagerUrl { - url: url.to_owned(), - reason: "address resolved to empty set".to_owned(), - }) + })?; + Ok(SocketAddr::new(ip, port)) } #[cfg(test)] @@ -263,9 +288,10 @@ mod tests { Some(McpClientTransport::Ws) ); assert_eq!( - McpClientTransport::from_str_value("LEGACY"), - Some(McpClientTransport::Legacy) + McpClientTransport::from_str_value("MCP"), + Some(McpClientTransport::Mcp) ); + assert_eq!(McpClientTransport::from_str_value("legacy"), None); assert_eq!( McpClientTransport::from_str_value("auto"), Some(McpClientTransport::Auto) @@ -292,25 +318,25 @@ mod tests { } #[test] - fn select_transport_legacy_short_circuits() { + fn select_transport_mcp_short_circuits() { let decision = select_transport( - McpClientTransport::Legacy, + McpClientTransport::Mcp, "ws://127.0.0.1:4000/sessions", |_| panic!("probe must not be called"), ) - .expect("legacy"); - assert_eq!(decision, TransportDecision::Legacy); + .expect("mcp"); + assert_eq!(decision, TransportDecision::Mcp); } #[test] - fn select_transport_auto_falls_back_to_legacy_when_unreachable() { + fn select_transport_auto_falls_back_to_mcp_when_unreachable() { let decision = select_transport( McpClientTransport::Auto, "ws://127.0.0.1:4000/sessions", |_| false, ) .expect("auto-fallback"); - assert_eq!(decision, TransportDecision::Legacy); + assert_eq!(decision, TransportDecision::Mcp); } #[test] @@ -344,14 +370,8 @@ mod tests { #[test] fn probe_tcp_fails_when_no_listener() { - // Bind to an ephemeral port and immediately drop the listener; the OS - // will reject connections to that port until reuse, which is enough - // for a unit-test. - let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); - let addr = listener.local_addr().expect("local addr"); - drop(listener); - // Allow a generous timeout — RST should arrive quickly. - let connected = probe_tcp(addr, Duration::from_millis(200)); + let addr = SocketAddr::from(([127, 0, 0, 1], 1)); + let connected = probe_tcp(addr, Duration::from_millis(50)); assert!(!connected); } @@ -385,6 +405,18 @@ mod tests { assert_eq!(params.kind, ClientKind::YaxunitRunner); } + #[test] + fn resolve_ws_params_pads_default_corr_id_for_short_uid() { + let params = resolve_ws_params( + ClientKind::V8RunnerClient, + WsResolveInputs { + client_uid: Some("abc".to_owned()), + ..Default::default() + }, + ); + assert_eq!(params.corr_id, "vr-abc00000"); + } + #[test] fn payload_snippet_contains_all_keys_in_order() { let params = WsLaunchParams { @@ -401,11 +433,28 @@ mod tests { ); } + #[test] + fn payload_snippet_encodes_delimiters() { + let params = WsLaunchParams { + manager_url: "ws://127.0.0.1:1/s".to_owned(), + client_uid: "uid=1".to_owned(), + kind: ClientKind::V8RunnerClient, + corr_id: "corr;1".to_owned(), + log_level: "info".to_owned(), + ws_timeout_ms: 1000, + }; + assert_eq!( + params.payload_snippet(), + "mcpMode=ws;manager_url=ws://127.0.0.1:1/s;client_uid=uid%3D1;kind=v8_runner_client;corr_id=corr%3B1;mcp_log_level=info;mcp_ws_timeout_ms=1000" + ); + } + #[test] fn is_supported_log_level_accepts_known_values() { for level in ["off", "error", "warn", "info", "debug", "trace"] { assert!(is_supported_log_level(level), "expected {level} supported"); } + assert!(is_supported_log_level("INFO")); assert!(!is_supported_log_level("verbose")); } diff --git a/src/use_cases/mod.rs b/src/use_cases/mod.rs index fae65a6..c001137 100644 --- a/src/use_cases/mod.rs +++ b/src/use_cases/mod.rs @@ -48,6 +48,8 @@ pub(crate) mod source_inventory; mod staged_publication; /// Shared internal preparation for tool extensions. pub(crate) mod tool_extension; +/// Supported external tool download use case. +pub mod tools_download; /// Shared transport-neutral adapter helpers used by CLI and MCP boundaries. pub mod transport; /// Shared Vanessa Automation launch and runtime params helpers. diff --git a/src/use_cases/progress.rs b/src/use_cases/progress.rs index 27506f8..282c46b 100644 --- a/src/use_cases/progress.rs +++ b/src/use_cases/progress.rs @@ -1,5 +1,20 @@ use tracing::info; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LiveStageStatus { + Succeeded, + Failed, +} + +impl LiveStageStatus { + fn as_str(self) -> &'static str { + match self { + Self::Succeeded => "succeeded", + Self::Failed => "failed", + } + } +} + /// Emits a text-mode live progress timeline event before a blocking stage starts. /// /// Callers must pass fixed, sanitized vocabulary only: no rendered commands, raw @@ -12,3 +27,13 @@ pub(crate) fn log_live_stage(label: &str, detail: &str) { timeline_detail = detail ); } + +/// Emits a sanitized live progress status after a blocking stage finishes. +pub(crate) fn log_live_stage_status(label: &str, status: LiveStageStatus, detail: &str) { + info!( + target: "v8_runner::live_progress", + timeline_status = status.as_str(), + timeline_label = label, + timeline_detail = detail + ); +} diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index c0dcac6..5623de3 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -6,6 +6,7 @@ use crate::domain::runner::{ RunnerProfile, ScenarioExecutionRequest, }; use crate::domain::test::TEST_RUNNER_ID; +use crate::domain::tools_download::{ToolDownloadTarget, ToolExtensionInstallMode}; use crate::use_cases::result::{UseCaseError, UseCaseErrorKind}; /// Transport-neutral request for the `build` use case. @@ -15,6 +16,24 @@ pub struct BuildRequest { pub full_rebuild: bool, /// Optional source-set selector. When absent, all configured source-sets are built. pub source_set: Option, + /// Per-invocation override for `/UpdateDBCfg -Dynamic+`. + /// + /// `None` means "no override — use `build.dynamic_update` from project config"; + /// `Some(true)` / `Some(false)` overrides the project default for this call. + pub dynamic_update: Option, +} + +/// Transport-neutral request for the `tools download` use case. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolsDownloadRequest { + /// Canonical path to the primary project config that may be updated. + pub config_path: std::path::PathBuf, + /// Selected tool to download. + pub target: ToolDownloadTarget, + /// Installation mode for extension tools. + pub extensions: ToolExtensionInstallMode, + /// Allows replacing existing downloaded paths. + pub force: bool, } /// Transport-neutral request for the `load` use case. @@ -619,7 +638,7 @@ pub struct LaunchRequest { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpClientTransportRequest { Ws, - Legacy, + Mcp, Auto, } diff --git a/src/use_cases/run_tests/coordinator.rs b/src/use_cases/run_tests/coordinator.rs index 6f96ce1..b028a4a 100644 --- a/src/use_cases/run_tests/coordinator.rs +++ b/src/use_cases/run_tests/coordinator.rs @@ -76,6 +76,9 @@ pub(super) fn run_tests( &BuildArgs { full_rebuild: false, source_set: None, + // Test prerequisite builds must stay static even when the project default enables + // dynamic updates; use `build --dynamic && test` for one-shot dynamic preparation. + dynamic_update: Some(false), }, ) { Ok(result) => result, @@ -228,7 +231,25 @@ pub(super) fn run_tests( let enterprise_runner = crate::platform::process::ProcessExecutor; let mut platform_launch = build_platform_launch(&args.execution.launch, &prepared_run, &artifacts); - apply_test_mcp_ws_payload(config, &args.mcp_ws, &prepared_run, &mut platform_launch); + if let Err(error) = + apply_test_mcp_ws_payload(config, &args.mcp_ws, &prepared_run, &mut platform_launch) + { + let outcome = ExecutionOutcome::new(ExecutionStatus::Failed) + .with_diagnostics(vec![error.to_string()]) + .with_errors(vec![test_execution_error( + TestErrorKind::TestSetupFailed, + error.to_string(), + )]); + let result = make_test_result( + target, + mode, + outcome, + warnings, + steps, + started.elapsed().as_millis() as u64, + ); + return Err(TestExecutionFailure::with_payload(error, result)); + } let enterprise = match build_enterprise_dsl( context, config, diff --git a/src/use_cases/run_tests/helpers.rs b/src/use_cases/run_tests/helpers.rs index 71d6c2b..e0a0f38 100644 --- a/src/use_cases/run_tests/helpers.rs +++ b/src/use_cases/run_tests/helpers.rs @@ -431,32 +431,25 @@ pub(super) fn append_mcp_ws_snippet(launch: &mut LaunchOptions, snippet: &str) { /// `mcpMode=ws;...` snippet to the platform `/C` so the BSL devkit registers /// with `v8-client-session-manager` instead of starting a local HTTP MCP. /// -/// Errors from the resolution layer (e.g. invalid `manager_url`) are logged -/// and treated as "no WS snippet"; the test run still proceeds with its -/// regular `/C` payload. This preserves the previous behavior when the -/// session-manager is not used. +/// Errors from explicit WS resolution are returned to the caller. Auto mode +/// still falls back to the regular `/C` payload when the manager is down. pub(super) fn apply_test_mcp_ws_payload( config: &AppConfig, mcp_ws: &crate::use_cases::request::McpClientWsRequest, prepared_run: &PreparedRun, launch: &mut LaunchOptions, -) { +) -> Result<(), AppError> { let kind = match prepared_run { PreparedRun::YaXUnit => crate::use_cases::mcp_ws::ClientKind::YaxunitRunner, PreparedRun::Vanessa { .. } => crate::use_cases::mcp_ws::ClientKind::VanessaTestClient, }; - let decision = match crate::use_cases::launch_app::decide_mcp_transport(config, mcp_ws) { - Ok(d) => d, - Err(err) => { - tracing::warn!(error = %err, "failed to resolve MCP client transport for test run"); - return; - } - }; + let decision = crate::use_cases::launch_app::decide_mcp_transport(config, mcp_ws)?; if !matches!(decision, crate::use_cases::mcp_ws::TransportDecision::Ws) { - return; + return Ok(()); } let params = crate::use_cases::launch_app::resolve_ws_launch_params(config, mcp_ws, kind); append_mcp_ws_snippet(launch, ¶ms.payload_snippet()); + Ok(()) } pub(super) fn collect_diagnostics( diff --git a/src/use_cases/tool_extension.rs b/src/use_cases/tool_extension.rs index 2b1229c..34003a5 100644 --- a/src/use_cases/tool_extension.rs +++ b/src/use_cases/tool_extension.rs @@ -318,8 +318,10 @@ fn prepare_designer_source_extension( "update", &extension_stage_detail("Конфигуратор", "Применение", extension), ); + // Tool-extension flow keeps the historical static update; dynamic mode is opt-in + // through `build --dynamic` / `build.dynamicUpdate` and not propagated here. let update = dsl - .update_db_cfg(Some(&extension.name)) + .update_db_cfg(Some(&extension.name), false) .map_err(AppError::from)?; ensure_tool_extension_success("update_db_cfg", extension, &update) } @@ -410,8 +412,9 @@ fn prepare_artifact_extension( extension, "update-artifact", )?; + // Tool-extension artifact apply path uses static update; see tool_extension::execute(). let update = dsl - .update_db_cfg(Some(&extension.name)) + .update_db_cfg(Some(&extension.name), false) .map_err(AppError::from)?; ensure_tool_extension_success("update_db_cfg", extension, &update) } diff --git a/src/use_cases/tools_download.rs b/src/use_cases/tools_download.rs new file mode 100644 index 0000000..1135e8f --- /dev/null +++ b/src/use_cases/tools_download.rs @@ -0,0 +1,1071 @@ +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::{self, Cursor}; +use std::path::{Component, Path, PathBuf}; +use std::time::Instant; + +use serde::Deserialize; +use tracing::debug; +use zip::ZipArchive; + +use crate::config::loader::LOCAL_CONFIG_FILE_NAME; +use crate::config::model::{AppConfig, BuilderBackend}; +use crate::domain::tools_download::{ + ToolDownloadDestination, ToolDownloadTarget, ToolExtensionInstallMode, ToolsDownloadResult, +}; +use crate::platform::download; +use crate::support::error::AppError; +use crate::support::fs::{ + ensure_dir, publish_file_atomically, remove_path_if_exists, replace_dir_atomically, +}; +use crate::use_cases::context::ExecutionContext; +use crate::use_cases::request::ToolsDownloadRequest; +use crate::use_cases::result::{UseCaseFailure, UseCaseResult}; + +const LOCAL_CONFIG_SCHEMA_MODEL_LINE: &str = "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json"; + +const YAXUNIT_REPO: &str = "bia-technologies/yaxunit"; +const VANESSA_REPO: &str = "Pr-Mex/vanessa-automation-single"; +const CLIENT_MCP_REPO: &str = "1c-neurofish/onec-client-mcp-devkit"; + +const YAXUNIT_SOURCE_PREFIX: &str = "exts/yaxunit/"; +const CLIENT_MCP_SOURCE_PREFIX: &str = "exts/client-mcp/"; +const DOWNLOAD_MARKER_FILE: &str = ".v8-runner-tools-download.json"; + +pub fn execute( + context: &ExecutionContext, + config: &AppConfig, + request: &ToolsDownloadRequest, +) -> UseCaseResult { + tools_download(context, config, request).map_err(|error| UseCaseFailure::without_payload(error)) +} + +fn tools_download( + context: &ExecutionContext, + config: &AppConfig, + request: &ToolsDownloadRequest, +) -> Result { + let started = Instant::now(); + let config_path = request.config_path.clone(); + let config_dir = config_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let local_config_path = config_dir.join(LOCAL_CONFIG_FILE_NAME); + let tools_dir = config.base_path.join("build").join("tools"); + + ensure_dir(&tools_dir).map_err(|error| { + AppError::Runtime(format!( + "failed to create tools directory '{}': {error}", + tools_dir.display() + )) + })?; + + let destinations = match request.target { + ToolDownloadTarget::Yaxunit => download_yaxunit( + context, + config, + &tools_dir, + request.extensions, + request.force, + &config_path, + )?, + ToolDownloadTarget::VanessaAutomationSingle => { + download_vanessa(context, &tools_dir, request.force)? + } + ToolDownloadTarget::ClientMcp => download_client_mcp( + context, + config, + &tools_dir, + request.extensions, + request.force, + )?, + }; + + update_config_for_download( + context, + &config_path, + &local_config_path, + request.target, + request.extensions, + &destinations, + )?; + + Ok(ToolsDownloadResult { + ok: true, + tool: target_label(request.target).to_owned(), + mode: download_mode_label(request.target, request.extensions).to_owned(), + destinations, + config_path, + local_config_path, + duration_ms: started.elapsed().as_millis() as u64, + }) +} + +fn download_yaxunit( + context: &ExecutionContext, + config: &AppConfig, + tools_dir: &Path, + mode: ToolExtensionInstallMode, + force: bool, + config_path: &Path, +) -> Result, AppError> { + let release = fetch_latest_release(context, YAXUNIT_REPO)?; + match mode { + ToolExtensionInstallMode::Sources => { + validate_yaxunit_source_set_config(config_path)?; + let path = config.base_path.join("tests"); + let marker_path = config + .base_path + .join("build") + .join(format!(".tests{DOWNLOAD_MARKER_FILE}")); + download_source_subdir( + context, + &release, + YAXUNIT_SOURCE_PREFIX, + &path, + &marker_path, + force, + )?; + Ok(vec![destination( + "yaxunit", + &release, + path, + "source-set tests", + )]) + } + ToolExtensionInstallMode::Artifacts => { + let asset = release.required_asset("YAxUnit", ".cfe")?; + let path = tools_dir.join(&asset.name); + download_asset_file(context, asset, &path, force)?; + Ok(vec![destination("yaxunit", &release, path, "artifact")]) + } + } +} + +fn download_vanessa( + context: &ExecutionContext, + tools_dir: &Path, + force: bool, +) -> Result, AppError> { + let release = fetch_latest_release(context, VANESSA_REPO)?; + let asset = release.required_asset("vanessa-automation-single", ".zip")?; + let path = tools_dir.join("vanessa-automation-single.epf"); + download_single_file_from_zip( + context, + asset, + "vanessa-automation-single.epf", + &path, + force, + )?; + Ok(vec![destination( + "vanessa-automation-single", + &release, + path, + "tools.va.epf_path", + )]) +} + +fn download_client_mcp( + context: &ExecutionContext, + config: &AppConfig, + tools_dir: &Path, + mode: ToolExtensionInstallMode, + force: bool, +) -> Result, AppError> { + if mode == ToolExtensionInstallMode::Artifacts && config.builder != BuilderBackend::Designer { + return Err(AppError::Validation( + "`tools download client-mcp` requires builder=DESIGNER because client_mcp.cfe is registered as a tool extension artifact; use `tools download client-mcp --sources` for builder=IBCMD" + .to_owned(), + )); + } + + let release = fetch_latest_release(context, CLIENT_MCP_REPO)?; + match mode { + ToolExtensionInstallMode::Sources => { + let path = tools_dir + .join("onec-client-mcp-devkit") + .join("exts") + .join("client-mcp"); + let marker_path = source_download_marker_path(&path); + download_source_subdir( + context, + &release, + CLIENT_MCP_SOURCE_PREFIX, + &path, + &marker_path, + force, + )?; + Ok(vec![destination( + "onec-client-mcp-devkit", + &release, + path, + "tools.client_mcp.extension.source", + )]) + } + ToolExtensionInstallMode::Artifacts => { + let asset = release.required_asset("client_mcp", ".cfe")?; + let path = tools_dir.join(&asset.name); + download_asset_file(context, asset, &path, force)?; + Ok(vec![destination( + "onec-client-mcp-devkit", + &release, + path, + "tools.client_mcp.extension.artifact", + )]) + } + } +} + +fn fetch_latest_release(context: &ExecutionContext, repo: &str) -> Result { + let base = release_base_url(); + let url = format!("{base}/repos/{repo}/releases/latest"); + debug!(repo, url = %url, "fetching latest tool release"); + let cancellation = context.cancellation(); + let text = + download::get_text(&url, context.remaining_budget(), &cancellation).map_err(|error| { + AppError::Runtime(format!("failed to fetch latest release {repo}: {error}")) + })?; + serde_json::from_str::(&text).map_err(|error| { + AppError::Runtime(format!("failed to parse latest release {repo}: {error}")) + }) +} + +fn release_base_url() -> String { + std::env::var("V8TR_GITHUB_API_BASE_URL") + .unwrap_or_else(|_| "https://api.github.com".to_owned()) + .trim_end_matches('/') + .to_owned() +} + +fn download_asset_file( + context: &ExecutionContext, + asset: &GitHubAsset, + target_path: &Path, + force: bool, +) -> Result<(), AppError> { + if !should_download_file(target_path, force)? { + return Ok(()); + } + debug!( + asset = %asset.name, + url = %asset.browser_download_url, + path = %target_path.display(), + "downloading tool asset" + ); + let cancellation = context.cancellation(); + let bytes = download::get_bytes( + &asset.browser_download_url, + context.remaining_budget(), + &cancellation, + ) + .map_err(|error| { + AppError::Runtime(format!( + "failed to download asset '{}': {error}", + asset.name + )) + })?; + publish_file_bytes_with_marker(context, &bytes, target_path) +} + +fn download_single_file_from_zip( + context: &ExecutionContext, + asset: &GitHubAsset, + file_name: &str, + target_path: &Path, + force: bool, +) -> Result<(), AppError> { + if !should_download_file(target_path, force)? { + return Ok(()); + } + debug!( + asset = %asset.name, + url = %asset.browser_download_url, + file_name, + path = %target_path.display(), + "downloading tool archive asset" + ); + let cancellation = context.cancellation(); + let bytes = download::get_bytes( + &asset.browser_download_url, + context.remaining_budget(), + &cancellation, + ) + .map_err(|error| { + AppError::Runtime(format!( + "failed to download asset '{}': {error}", + asset.name + )) + })?; + let file = find_file_in_zip(&bytes, file_name)?; + publish_file_bytes_with_marker(context, &file, target_path) +} + +fn download_source_subdir( + context: &ExecutionContext, + release: &GitHubRelease, + source_prefix: &str, + target_path: &Path, + marker_path: &Path, + force: bool, +) -> Result<(), AppError> { + if !should_download_source_dir(target_path, marker_path, force)? { + return Ok(()); + } + let archive_url = source_archive_url(release); + debug!( + tag = %release.tag_name, + url = %archive_url, + source_prefix, + path = %target_path.display(), + "downloading tool source archive" + ); + let cancellation = context.cancellation(); + let bytes = download::get_bytes(&archive_url, context.remaining_budget(), &cancellation) + .map_err(|error| { + AppError::Runtime(format!( + "failed to download source archive '{}': {error}", + archive_url + )) + })?; + let staged = target_path.with_extension(format!( + "download-{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + if staged.exists() { + fs::remove_dir_all(&staged).map_err(io_error("failed to cleanup stale staged dir"))?; + } + ensure_dir(&staged).map_err(io_error("failed to create staged source dir"))?; + extract_zip_subdir(&bytes, source_prefix, &staged).inspect_err(|_| { + let _ = fs::remove_dir_all(&staged); + })?; + + let marker_existed = marker_path.exists(); + write_source_download_marker(target_path, marker_path)?; + let publish_phase = context.run_no_process_critical_phase(|| { + replace_dir_atomically( + &staged, + target_path, + &chrono::Utc::now() + .timestamp_nanos_opt() + .unwrap_or_default() + .to_string(), + "tools-download", + ".tools-download-backup", + ) + }); + match publish_phase { + Ok(_) => Ok(()), + Err(error) => { + let _ = fs::remove_dir_all(&staged); + if !marker_existed { + let _ = remove_path_if_exists(marker_path); + } + Err(AppError::Runtime(format!( + "failed to publish source directory '{}': {error}", + target_path.display() + ))) + } + } +} + +fn find_file_in_zip(bytes: &[u8], file_name: &str) -> Result, AppError> { + let mut archive = ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| AppError::Runtime(format!("failed to read zip archive: {error}")))?; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|error| AppError::Runtime(format!("failed to read zip entry: {error}")))?; + if !file.is_file() { + continue; + } + let Some(name) = Path::new(file.name()) + .file_name() + .and_then(|name| name.to_str()) + else { + continue; + }; + if name == file_name { + let mut bytes = Vec::new(); + io::copy(&mut file, &mut bytes).map_err(|error| { + AppError::Runtime(format!("failed to extract zip entry: {error}")) + })?; + return Ok(bytes); + } + } + Err(AppError::Runtime(format!( + "zip archive does not contain {file_name}" + ))) +} + +fn extract_zip_subdir( + bytes: &[u8], + source_prefix: &str, + target_path: &Path, +) -> Result<(), AppError> { + let mut archive = ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| AppError::Runtime(format!("failed to read zip archive: {error}")))?; + let mut extracted = false; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|error| AppError::Runtime(format!("failed to read zip entry: {error}")))?; + let Some(relative) = zip_relative_path(file.name(), source_prefix) else { + continue; + }; + if relative.as_os_str().is_empty() { + continue; + } + let target = target_path.join(relative); + if file.is_dir() { + ensure_dir(&target).map_err(io_error("failed to create extracted dir"))?; + } else { + if let Some(parent) = target.parent() { + ensure_dir(parent).map_err(io_error("failed to create extracted parent dir"))?; + } + let mut output = + File::create(&target).map_err(io_error("failed to create extracted file"))?; + io::copy(&mut file, &mut output).map_err(io_error("failed to extract zip file"))?; + extracted = true; + } + } + if !extracted { + return Err(AppError::Runtime(format!( + "source archive does not contain {source_prefix}" + ))); + } + Ok(()) +} + +fn zip_relative_path(name: &str, source_prefix: &str) -> Option { + let mut parts = name.splitn(2, '/'); + let _root = parts.next()?; + let inner = parts.next().unwrap_or_default(); + let relative = inner.strip_prefix(source_prefix)?; + if relative.contains('\\') { + return None; + } + let mut safe = PathBuf::new(); + for component in Path::new(relative).components() { + match component { + Component::Normal(value) => safe.push(value), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(safe) +} + +fn publish_bytes( + context: &ExecutionContext, + bytes: &[u8], + target_path: &Path, +) -> Result<(), AppError> { + if let Some(parent) = target_path.parent() { + ensure_dir(parent).map_err(io_error("failed to create target parent dir"))?; + } + let staged = target_path.with_extension(format!( + "download-{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + fs::write(&staged, bytes).map_err(io_error("failed to write staged file"))?; + let publish_phase = + context.run_no_process_critical_phase(|| publish_file_atomically(&staged, target_path)); + match publish_phase { + Ok(_) => Ok(()), + Err(error) => { + let _ = fs::remove_file(&staged); + Err(AppError::Runtime(format!( + "failed to publish downloaded file '{}': {error}", + target_path.display() + ))) + } + } +} + +fn publish_file_bytes_with_marker( + context: &ExecutionContext, + bytes: &[u8], + target_path: &Path, +) -> Result<(), AppError> { + let marker_path = file_download_marker_path(target_path); + let marker_existed = marker_path.exists(); + write_file_download_marker(target_path)?; + match publish_bytes(context, bytes, target_path) { + Ok(()) => Ok(()), + Err(error) => { + if !marker_existed { + let _ = remove_path_if_exists(&marker_path); + } + Err(error) + } + } +} + +fn should_download_file(path: &Path, force: bool) -> Result { + if path.exists() && path.is_dir() { + return Err(AppError::Validation(format!( + "download target is a directory: {}", + path.display() + ))); + } + if !path.exists() { + return Ok(true); + } + if force && !file_download_marker_path(path).exists() { + return Err(AppError::Validation(format!( + "download target already exists and is not managed by v8-runner: {}", + path.display() + ))); + } + Ok(force) +} + +fn should_download_source_dir( + path: &Path, + marker_path: &Path, + force: bool, +) -> Result { + if !path.exists() { + return Ok(true); + } + if !path.is_dir() { + return Err(AppError::Validation(format!( + "download target is not a directory: {}", + path.display() + ))); + } + if !marker_path.exists() { + return Err(AppError::Validation(format!( + "download target already exists and is not managed by v8-runner: {}", + path.display() + ))); + } + Ok(force) +} + +fn write_source_download_marker(target_path: &Path, marker_path: &Path) -> Result<(), AppError> { + let parent = marker_path.parent().ok_or_else(|| { + AppError::Runtime(format!( + "download marker path has no parent: {}", + marker_path.display() + )) + })?; + ensure_dir(parent).map_err(io_error("failed to create download marker parent"))?; + fs::write( + &marker_path, + format!( + "{{\n \"tool\": \"v8-runner\",\n \"target\": \"{}\"\n}}\n", + target_path.display() + ), + ) + .map_err(io_error("failed to write download marker")) +} + +fn write_file_download_marker(target_path: &Path) -> Result<(), AppError> { + let marker_path = file_download_marker_path(target_path); + let parent = marker_path.parent().ok_or_else(|| { + AppError::Runtime(format!( + "download marker path has no parent: {}", + marker_path.display() + )) + })?; + ensure_dir(parent).map_err(io_error("failed to create download marker parent"))?; + fs::write( + &marker_path, + format!( + "{{\n \"tool\": \"v8-runner\",\n \"target\": \"{}\"\n}}\n", + target_path.display() + ), + ) + .map_err(io_error("failed to write download marker")) +} + +fn source_download_marker_path(path: &Path) -> PathBuf { + sidecar_download_marker_path(path) +} + +fn file_download_marker_path(path: &Path) -> PathBuf { + sidecar_download_marker_path(path) +} + +fn sidecar_download_marker_path(path: &Path) -> PathBuf { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let name = path + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "download".to_owned()); + parent.join(format!(".{name}{DOWNLOAD_MARKER_FILE}")) +} + +fn relative_path(root: &Path, path: &Path) -> String { + if let Some(relative) = path + .strip_prefix(root) + .ok() + .filter(|relative| !relative.as_os_str().is_empty()) + { + return relative.display().to_string(); + } + + let root_components = normalized_components(root); + let path_components = normalized_components(path); + let common_len = root_components + .iter() + .zip(path_components.iter()) + .take_while(|(left, right)| left == right) + .count(); + + let mut relative = PathBuf::new(); + for _ in common_len..root_components.len() { + relative.push(".."); + } + for component in &path_components[common_len..] { + relative.push(component); + } + + if relative.as_os_str().is_empty() { + ".".to_owned() + } else { + relative.display().to_string() + } +} + +fn normalized_components(path: &Path) -> Vec { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => components.push(prefix.as_os_str().to_os_string()), + Component::RootDir | Component::CurDir => {} + Component::ParentDir => match components.last() { + Some(last) if last != ".." => { + components.pop(); + } + _ => components.push(OsString::from("..")), + }, + Component::Normal(part) => components.push(part.to_os_string()), + } + } + components +} + +fn update_config_for_download( + context: &ExecutionContext, + config_path: &Path, + local_config_path: &Path, + target: ToolDownloadTarget, + mode: ToolExtensionInstallMode, + destinations: &[ToolDownloadDestination], +) -> Result<(), AppError> { + match target { + ToolDownloadTarget::Yaxunit if mode == ToolExtensionInstallMode::Sources => { + add_yaxunit_source_set(context, config_path)?; + } + ToolDownloadTarget::Yaxunit => {} + ToolDownloadTarget::VanessaAutomationSingle => { + let local_overlay = render_vanessa_local_overlay(local_config_path, destinations)?; + publish_bytes(context, local_overlay.as_bytes(), local_config_path)?; + } + ToolDownloadTarget::ClientMcp => { + let local_overlay = + render_client_mcp_local_overlay(local_config_path, destinations, mode)?; + publish_bytes(context, local_overlay.as_bytes(), local_config_path)?; + } + } + Ok(()) +} + +fn add_yaxunit_source_set(context: &ExecutionContext, config_path: &Path) -> Result<(), AppError> { + let content = fs::read_to_string(config_path).map_err(io_error("failed to read config"))?; + let root: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|error| AppError::Runtime(format!("failed to parse config YAML: {error}")))?; + let mapping = root + .as_mapping() + .ok_or_else(|| AppError::Validation("expected a YAML mapping at config root".to_owned()))?; + let key = serde_yaml::Value::String("source-set".to_owned()); + let source_sets = mapping + .get(&key) + .and_then(serde_yaml::Value::as_sequence) + .ok_or_else(|| AppError::Validation("config must contain source-set list".to_owned()))?; + if source_sets + .iter() + .any(|item| yaml_field_eq(item, "name", "tests")) + { + return Ok(()); + } + + let rendered = insert_yaxunit_source_set_text(&content)?; + publish_bytes(context, rendered.as_bytes(), config_path) +} + +fn validate_yaxunit_source_set_config(config_path: &Path) -> Result<(), AppError> { + let content = fs::read_to_string(config_path).map_err(io_error("failed to read config"))?; + let root: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|error| AppError::Runtime(format!("failed to parse config YAML: {error}")))?; + let mapping = root + .as_mapping() + .ok_or_else(|| AppError::Validation("expected a YAML mapping at config root".to_owned()))?; + let source_sets = mapping + .get(serde_yaml::Value::String("source-set".to_owned())) + .and_then(serde_yaml::Value::as_sequence) + .ok_or_else(|| AppError::Validation("config must contain source-set list".to_owned()))?; + for source_set in source_sets + .iter() + .filter(|item| yaml_field_eq(item, "name", "tests")) + { + let is_expected = yaml_field_eq(source_set, "type", "EXTENSION") + && yaml_field_eq(source_set, "path", "tests"); + if !is_expected { + return Err(AppError::Validation( + "source-set 'tests' already exists but does not match tools download contract: expected type=EXTENSION and path=tests" + .to_owned(), + )); + } + } + Ok(()) +} + +fn insert_yaxunit_source_set_text(content: &str) -> Result { + let source_set_start = content + .lines() + .position(|line| line.trim_end() == "source-set:") + .ok_or_else(|| { + AppError::Validation( + "config source-set list must use block style before tools download can update it" + .to_owned(), + ) + })?; + + let mut insertion_offset = content.len(); + let mut offset = 0usize; + for (index, line) in content.split_inclusive('\n').enumerate() { + let line_start = offset; + offset += line.len(); + if index <= source_set_start { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let is_top_level = line + .chars() + .next() + .is_some_and(|first| !first.is_whitespace()); + if is_top_level { + insertion_offset = line_start; + break; + } + } + + let mut rendered = String::with_capacity(content.len() + 64); + rendered.push_str(&content[..insertion_offset]); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered.push_str(" - name: tests\n type: EXTENSION\n path: tests\n"); + rendered.push_str(&content[insertion_offset..]); + Ok(rendered) +} + +fn render_vanessa_local_overlay( + path: &Path, + destinations: &[ToolDownloadDestination], +) -> Result { + let mut root = read_local_overlay(path)?; + let vanessa_path = destinations + .iter() + .find(|destination| destination.tool == "vanessa-automation-single") + .ok_or_else(|| AppError::Runtime("missing Vanessa download destination".to_owned()))? + .path + .clone(); + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let vanessa_path = relative_path(config_dir, &vanessa_path); + + let root_mapping = root.as_mapping_mut().ok_or_else(|| { + AppError::Validation("expected a YAML mapping at local config root".to_owned()) + })?; + let tools = ensure_mapping(root_mapping, "tools")?; + let va = ensure_mapping(tools, "va")?; + va.insert( + serde_yaml::Value::String("epf_path".to_owned()), + serde_yaml::Value::String(vanessa_path), + ); + render_local_overlay(root) +} + +fn render_client_mcp_local_overlay( + path: &Path, + destinations: &[ToolDownloadDestination], + mode: ToolExtensionInstallMode, +) -> Result { + let mut root = read_local_overlay(path)?; + let client_path = destinations + .iter() + .find(|destination| destination.tool == "onec-client-mcp-devkit") + .ok_or_else(|| AppError::Runtime("missing client MCP download destination".to_owned()))? + .path + .clone(); + let config_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let client_path = relative_path(config_dir, &client_path); + + let root_mapping = root.as_mapping_mut().ok_or_else(|| { + AppError::Validation("expected a YAML mapping at local config root".to_owned()) + })?; + let tools = ensure_mapping(root_mapping, "tools")?; + let client_mcp = ensure_mapping(tools, "client_mcp")?; + let mut extension = serde_yaml::Mapping::new(); + extension.insert( + serde_yaml::Value::String("name".to_owned()), + serde_yaml::Value::String("client_mcp".to_owned()), + ); + match mode { + ToolExtensionInstallMode::Sources => { + let mut source = serde_yaml::Mapping::new(); + source.insert( + serde_yaml::Value::String("path".to_owned()), + serde_yaml::Value::String(client_path.clone()), + ); + source.insert( + serde_yaml::Value::String("format".to_owned()), + serde_yaml::Value::String("EDT".to_owned()), + ); + extension.insert( + serde_yaml::Value::String("source".to_owned()), + serde_yaml::Value::Mapping(source), + ); + } + ToolExtensionInstallMode::Artifacts => { + let mut artifact = serde_yaml::Mapping::new(); + artifact.insert( + serde_yaml::Value::String("path".to_owned()), + serde_yaml::Value::String(client_path), + ); + extension.insert( + serde_yaml::Value::String("artifact".to_owned()), + serde_yaml::Value::Mapping(artifact), + ); + } + } + client_mcp.insert( + serde_yaml::Value::String("extension".to_owned()), + serde_yaml::Value::Mapping(extension), + ); + + render_local_overlay(root) +} + +fn read_local_overlay(path: &Path) -> Result { + let mut root = if path.exists() { + let content = fs::read_to_string(path).map_err(io_error("failed to read local config"))?; + serde_yaml::from_str::(&content).map_err(|error| { + AppError::Runtime(format!("failed to parse local config YAML: {error}")) + })? + } else { + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) + }; + if root.is_null() { + root = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + Ok(root) +} + +fn render_local_overlay(root: serde_yaml::Value) -> Result { + let mut rendered = serde_yaml::to_string(&root).map_err(|error| { + AppError::Runtime(format!("failed to render local config YAML: {error}")) + })?; + rendered = with_local_schema_modeline(&rendered); + Ok(rendered) +} + +fn ensure_mapping<'a>( + parent: &'a mut serde_yaml::Mapping, + key: &str, +) -> Result<&'a mut serde_yaml::Mapping, AppError> { + let key_value = serde_yaml::Value::String(key.to_owned()); + if !parent.contains_key(&key_value) { + parent.insert( + key_value.clone(), + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + ); + } + parent + .get_mut(&key_value) + .and_then(serde_yaml::Value::as_mapping_mut) + .ok_or_else(|| { + AppError::Validation(format!("local config field '{key}' must be a mapping")) + }) +} + +fn yaml_field_eq(value: &serde_yaml::Value, field: &str, expected: &str) -> bool { + value + .as_mapping() + .and_then(|mapping| mapping.get(serde_yaml::Value::String(field.to_owned()))) + .and_then(serde_yaml::Value::as_str) + == Some(expected) +} + +fn with_local_schema_modeline(content: &str) -> String { + let content = content + .lines() + .filter(|line| { + !line + .trim_start() + .starts_with("# yaml-language-server: $schema=") + }) + .collect::>() + .join("\n"); + let mut rendered = format!("{LOCAL_CONFIG_SCHEMA_MODEL_LINE}\n{content}"); + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered +} + +fn destination( + tool: &str, + release: &GitHubRelease, + path: PathBuf, + config: &str, +) -> ToolDownloadDestination { + ToolDownloadDestination { + tool: tool.to_owned(), + tag: release.tag_name.clone(), + source: release.html_url.clone(), + path, + config: config.to_owned(), + } +} + +fn mode_label(mode: ToolExtensionInstallMode) -> &'static str { + match mode { + ToolExtensionInstallMode::Sources => "sources", + ToolExtensionInstallMode::Artifacts => "artifacts", + } +} + +fn download_mode_label(target: ToolDownloadTarget, mode: ToolExtensionInstallMode) -> &'static str { + match target { + ToolDownloadTarget::VanessaAutomationSingle => "epf", + ToolDownloadTarget::Yaxunit | ToolDownloadTarget::ClientMcp => mode_label(mode), + } +} + +fn target_label(target: ToolDownloadTarget) -> &'static str { + match target { + ToolDownloadTarget::Yaxunit => "yaxunit", + ToolDownloadTarget::VanessaAutomationSingle => "vanessa", + ToolDownloadTarget::ClientMcp => "client-mcp", + } +} + +fn source_archive_url(release: &GitHubRelease) -> String { + let Some(rest) = release + .zipball_url + .strip_prefix("https://api.github.com/repos/") + else { + return release.zipball_url.clone(); + }; + let Some((repo, tag)) = rest.split_once("/zipball/") else { + return release.zipball_url.clone(); + }; + format!("https://codeload.github.com/{repo}/zip/refs/tags/{tag}") +} + +fn io_error(context: &'static str) -> impl FnOnce(io::Error) -> AppError { + move |error| AppError::Runtime(format!("{context}: {error}")) +} + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + html_url: String, + assets: Vec, + zipball_url: String, +} + +impl GitHubRelease { + fn required_asset( + &self, + name_contains: &str, + extension: &str, + ) -> Result<&GitHubAsset, AppError> { + self.assets + .iter() + .find(|asset| asset.name.contains(name_contains) && asset.name.ends_with(extension)) + .ok_or_else(|| { + AppError::Runtime(format!( + "latest release '{}' does not contain asset matching '*{}*{}'", + self.tag_name, name_contains, extension + )) + }) + } +} + +#[derive(Debug, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zip_relative_path_accepts_safe_source_entry() { + assert_eq!( + zip_relative_path( + "bia-technologies-yaxunit/exts/yaxunit/src/Configuration/Configuration.mdo", + YAXUNIT_SOURCE_PREFIX, + ), + Some(PathBuf::from("src/Configuration/Configuration.mdo")) + ); + } + + #[test] + fn zip_relative_path_rejects_absolute_and_parent_entries() { + assert_eq!( + zip_relative_path("repo/exts/yaxunit//tmp/pwned", YAXUNIT_SOURCE_PREFIX), + None + ); + assert_eq!( + zip_relative_path("repo/exts/yaxunit/../pwned", YAXUNIT_SOURCE_PREFIX), + None + ); + assert_eq!( + zip_relative_path("repo/exts/yaxunit/C:\\temp\\pwned", YAXUNIT_SOURCE_PREFIX,), + None + ); + } + + #[test] + fn source_archive_url_uses_codeload_for_github_zipball() { + let release = GitHubRelease { + tag_name: "25.12".to_owned(), + html_url: "https://github.com/bia-technologies/yaxunit/releases/tag/25.12".to_owned(), + assets: Vec::new(), + zipball_url: "https://api.github.com/repos/bia-technologies/yaxunit/zipball/25.12" + .to_owned(), + }; + + assert_eq!( + source_archive_url(&release), + "https://codeload.github.com/bia-technologies/yaxunit/zip/refs/tags/25.12" + ); + } + + #[test] + fn source_archive_url_keeps_test_or_custom_urls() { + let release = GitHubRelease { + tag_name: "test".to_owned(), + html_url: "https://example.invalid/test".to_owned(), + assets: Vec::new(), + zipball_url: "http://127.0.0.1:1234/archive.zip".to_owned(), + }; + + assert_eq!( + source_archive_url(&release), + "http://127.0.0.1:1234/archive.zip" + ); + } +} diff --git a/tests/cli_artifacts.rs b/tests/cli_artifacts.rs index e9046fd..edba322 100644 --- a/tests/cli_artifacts.rs +++ b/tests/cli_artifacts.rs @@ -17,10 +17,9 @@ fn write_designer_script(path: &Path, fail: bool) { ); } -fn write_config(path: &Path, base_path: &Path, work_path: &Path, platform_path: &Path) { +fn write_config(path: &Path, work_path: &Path, platform_path: &Path) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n", work_path.display(), platform_path.display(), ); @@ -37,7 +36,7 @@ fn setup_project(fail: bool) -> (tempfile::TempDir, PathBuf, PathBuf) { fs::create_dir_all(base_path.join("main")).expect("main"); fs::create_dir_all(&work_path).expect("work"); write_designer_script(&binary_path, fail); - write_config(&config_path, &base_path, &work_path, &binary_path); + write_config(&config_path, &work_path, &binary_path); (dir, config_path, base_path) } diff --git a/tests/cli_bootstrap.rs b/tests/cli_bootstrap.rs index fc9be54..8f29b56 100644 --- a/tests/cli_bootstrap.rs +++ b/tests/cli_bootstrap.rs @@ -16,8 +16,7 @@ fn write_minimal_config(dir: &Path) -> PathBuf { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\n", work_path.display() ), ) @@ -263,20 +262,7 @@ fn artifacts_pre_dispatch_validation_in_json_mode_keeps_command_identity() { #[test] fn mcp_rejects_clean_before_execution_flag() { let dir = temp_workspace(); - let config_path = dir.path().join("v8project.yaml"); - let base_path = dir.path().join("project"); - let work_path = dir.path().join("work"); - fs::create_dir_all(&base_path).expect("base"); - fs::create_dir_all(&work_path).expect("work"); - fs::write( - &config_path, - format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), - work_path.display() - ), - ) - .expect("config"); + let config_path = write_minimal_config(dir.path()); let output = v8_runner_command() .args([ @@ -308,8 +294,7 @@ fn legacy_top_level_connection_is_rejected_in_json_mode() { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\nconnection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\nconnection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\n", work_path.display() ), ) @@ -347,8 +332,7 @@ fn legacy_top_level_credentials_is_rejected_in_json_mode() { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ncredentials:\n user: Admin\n password: secret\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ncredentials:\n user: Admin\n password: secret\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\n", work_path.display() ), ) @@ -386,8 +370,7 @@ fn top_level_execution_timeout_seconds_is_rejected_in_json_mode() { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout_seconds: 300\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout_seconds: 300\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\n", work_path.display() ), ) diff --git a/tests/cli_build.rs b/tests/cli_build.rs index 333b6be..c0409f8 100644 --- a/tests/cli_build.rs +++ b/tests/cli_build.rs @@ -124,15 +124,14 @@ fn write_config_with_builder( fn write_config_with_builder_and_infobase( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, builder: &str, infobase_yaml: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: {}\ninfobase:\n{}build:\n partialLoadThreshold: 20\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\n - name: ext\n type: EXTENSION\n path: ext\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: {}\ninfobase:\n{}build:\n partialLoadThreshold: 20\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\n - name: ext\n type: EXTENSION\n path: project/ext\ntools:\n platform:\n path: '{}'\n", work_path.display(), builder, infobase_yaml, @@ -307,8 +306,7 @@ fn setup_edt_ibcmd_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { write_edt_script(&edt_cli_path, &edt_calls_log); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: IBCMD\ninfobase:\n connection: 'File=/tmp/ib'\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: IBCMD\ninfobase:\n connection: 'File=/tmp/ib'\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: project/configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", work_path.display(), ibcmd_path.display(), edt_cli_path.display(), @@ -368,8 +366,7 @@ fn setup_edt_extension_project() -> (tempfile::TempDir, PathBuf, PathBuf) { write_edt_script(&edt_cli_path, &edt_calls_log); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: project/configuration\n - name: client_mcp\n type: EXTENSION\n path: project/exts/client-mcp\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", work_path.display(), platform_path.display(), edt_cli_path.display(), @@ -712,8 +709,7 @@ fn build_text_groups_tool_extension_stages_under_single_build_node() { write_edt_script(&edt_cli_path, &edt_calls_log); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n client_mcp:\n extension:\n name: client_mcp\n source:\n path: '{}'\n format: EDT\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: project/configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n client_mcp:\n extension:\n name: client_mcp\n source:\n path: '{}'\n format: EDT\n", work_path.display(), platform_path.display(), edt_cli_path.display(), @@ -855,10 +851,9 @@ fn build_ibcmd_full_rebuild_invokes_import_and_apply() { #[test] fn build_ibcmd_passes_credentials_to_import_and_apply() { - let (dir, config_path, binary_path, work_path, base_path, calls_log) = setup_ibcmd_project(); + let (dir, config_path, binary_path, work_path, _base_path, calls_log) = setup_ibcmd_project(); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: 'File=/tmp/ib'\n user: Admin\n password: secret\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: 'File=/tmp/ib'\n user: Admin\n password: secret\nbuild:\n partialLoadThreshold: 20\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n", work_path.display(), binary_path.display(), ); diff --git a/tests/cli_config_init.rs b/tests/cli_config_init.rs index 97b4a9f..eda20c1 100644 --- a/tests/cli_config_init.rs +++ b/tests/cli_config_init.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use support::{temp_workspace, v8_runner_command}; const V8_EXTERNAL_OBJECTS_NATURE: &str = "com._1c.g5.v8.dt.core.V8ExternalObjectsNature"; +const LOCAL_CONFIG_SCHEMA_MODEL_LINE: &str = "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.local.schema.json"; fn copy_dir_all(src: &Path, dst: &Path) { fs::create_dir_all(dst).expect("create dst"); @@ -83,12 +84,12 @@ fn config_init_creates_yaml_with_detected_designer_sources() { assert!(output.status.success()); let config = fs::read_to_string(dir.path().join("v8project.yaml")).expect("config"); - assert!(config.starts_with(&format!( - "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/refs/tags/v{}/docs/schemas/v8project.schema.json\n", - env!("CARGO_PKG_VERSION") - ))); + assert!(config.starts_with( + "# yaml-language-server: $schema=https://raw.githubusercontent.com/alkoleft/v8-runner-rust/master/docs/schemas/v8project.schema.json\n" + )); serde_yaml::from_str::(&config).expect("generated config remains YAML"); assert!(config.contains("format: DESIGNER")); + assert!(!config.contains("basePath:")); assert!(config.contains("workPath: 'build'")); assert!(config.contains("infobase:")); assert!(config.contains(" connection: 'File=build/ib'")); @@ -96,6 +97,13 @@ fn config_init_creates_yaml_with_detected_designer_sources() { assert!(config.contains("name: 'SalesAddon'")); assert!(config.contains("type: EXTENSION")); assert!(String::from_utf8_lossy(&output.stdout).contains("Config written")); + let local_config = + fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local config"); + assert!(local_config.starts_with(LOCAL_CONFIG_SCHEMA_MODEL_LINE)); + serde_yaml::from_str::(&local_config) + .expect("generated local config remains YAML"); + let gitignore = fs::read_to_string(dir.path().join(".gitignore")).expect("gitignore"); + assert!(gitignore.lines().any(|line| line == "v8project.local.yaml")); } #[test] @@ -123,11 +131,47 @@ fn config_init_uses_json_envelope_and_output_override() { let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["command"], "config init"); + assert_eq!( + payload["data"]["local_path"], + dir.path() + .join("v8project.local.yaml") + .display() + .to_string() + ); + assert_eq!( + payload["data"]["gitignore_path"], + dir.path().join(".gitignore").display().to_string() + ); assert_eq!(payload["data"]["source_sets"][0]["path"], "."); assert_eq!(payload["data"]["source_sets"][0]["type"], "CONFIGURATION"); let config = fs::read_to_string(config_path).expect("config"); assert!(config.contains("infobase:")); assert!(config.contains(" connection: 'File=/tmp/test-ib'")); + assert!(!config.contains("basePath:")); +} + +#[test] +fn config_init_creates_local_overlay_next_to_output_override() { + let dir = temp_workspace(); + fs::write(dir.path().join("Configuration.xml"), "").expect("xml"); + + let output = v8_runner_command() + .current_dir(dir.path()) + .args(["config", "init", "--output", "config/v8project.yaml"]) + .output() + .expect("run command"); + + assert!(output.status.success()); + let config = + fs::read_to_string(dir.path().join("config").join("v8project.yaml")).expect("config"); + assert!(!config.contains("basePath:")); + assert!(config.contains("path: '..'")); + let local_config = fs::read_to_string(dir.path().join("config").join("v8project.local.yaml")) + .expect("local config"); + assert!(local_config.starts_with(LOCAL_CONFIG_SCHEMA_MODEL_LINE)); + let gitignore = + fs::read_to_string(dir.path().join("config").join(".gitignore")).expect("gitignore"); + assert!(gitignore.lines().any(|line| line == "v8project.local.yaml")); } #[test] diff --git a/tests/cli_convert.rs b/tests/cli_convert.rs index 7690e69..99c81ce 100644 --- a/tests/cli_convert.rs +++ b/tests/cli_convert.rs @@ -261,7 +261,7 @@ exit 0"#, fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, edt_path: &Path, format: &str, @@ -269,8 +269,7 @@ fn write_config( platform_version: Option<&str>, ) { let mut config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: {format}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n", - base_path.display(), + "workPath: '{}'\nformat: {format}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n", work_path.display(), ); for source_set in source_sets { @@ -331,7 +330,7 @@ fn setup_project() -> ( let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let edt_cli_path = dir.path().join("edt").join("1cedtcli"); let calls_log = dir.path().join("edt-calls.log"); @@ -863,7 +862,7 @@ fn convert_output_root_mirrors_source_set_layout_and_stabilizes_edt_project_name let base_path = dir.path().join("designer"); let work_path = dir.path().join("work"); let output_root = dir.path().join("edt"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let edt_cli_path = dir.path().join("edt-cli").join("1cedtcli"); let calls_log = dir.path().join("edt-calls.log"); @@ -1099,7 +1098,7 @@ fn convert_output_root_rejects_base_path_child_before_workspace_lock() { assert!(payload["data"]["message"] .as_str() .expect("message") - .contains("must not be inside basePath")); + .contains("must not be inside project base path")); } #[test] diff --git a/tests/cli_dump.rs b/tests/cli_dump.rs index f1267e7..07dd738 100644 --- a/tests/cli_dump.rs +++ b/tests/cli_dump.rs @@ -114,14 +114,13 @@ fn write_config(path: &Path, base_path: &Path, work_path: &Path, platform_path: fn write_config_with_infobase( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, infobase_yaml: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}source-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}source-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n", work_path.display(), infobase_yaml, platform_path.display(), @@ -164,14 +163,13 @@ fn setup_project() -> ( fn write_edt_dump_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, edt_path: &Path, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n interactive-mode: false\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n interactive-mode: false\n", work_path.display(), platform_path.display(), edt_path.display(), diff --git a/tests/cli_extensions.rs b/tests/cli_extensions.rs index aae717e..d6d6a64 100644 --- a/tests/cli_extensions.rs +++ b/tests/cli_extensions.rs @@ -84,7 +84,7 @@ fn setup_extensions_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let ibcmd_path = dir.path().join("ibcmd"); let calls_log = dir.path().join("ibcmd.calls.log"); @@ -104,8 +104,7 @@ fn setup_extensions_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) ); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File={}'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\n - name: tests\n type: EXTENSION\n path: tests\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File={}'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\n - name: tests\n type: EXTENSION\n path: tests\ntools:\n platform:\n path: '{}'\n", work_path.display(), dir.path().join("ib").display(), ibcmd_path.display(), @@ -133,7 +132,7 @@ fn extensions_command_updates_all_extension_properties() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("│")); assert!(stdout.contains("● client_mcp: disable_safety")); - assert!(stdout.contains("│ updating extension properties")); + assert!(stdout.contains("updating extension properties")); assert!(stdout.contains("│ безопасный режим")); assert!(stdout.contains("● tests: disable_safety")); assert!(stdout.contains("● Extension properties updated successfully")); diff --git a/tests/cli_help.rs b/tests/cli_help.rs index 7ac83c0..430d433 100644 --- a/tests/cli_help.rs +++ b/tests/cli_help.rs @@ -50,6 +50,37 @@ fn build_help_exposes_source_set_selector() { assert!(stdout.contains("--json-message")); } +#[test] +fn tools_download_help_exposes_tool_commands() { + let output = v8_runner_command() + .args(["tools", "download", "--help"]) + .output() + .expect("run command"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Global options:")); + assert!(stdout.contains("Commands:")); + assert!(stdout.contains("yaxunit")); + assert!(stdout.contains("vanessa")); + assert!(stdout.contains("client-mcp")); + assert!(!stdout.contains("--extensions")); +} + +#[test] +fn tools_download_extension_help_exposes_sources_flag() { + let output = v8_runner_command() + .args(["tools", "download", "yaxunit", "--help"]) + .output() + .expect("run command"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Command options:")); + assert!(stdout.contains("--sources")); + assert!(stdout.contains("--force")); +} + #[test] fn launch_help_uses_output_path_name_and_global_json_selector() { let output = v8_runner_command() diff --git a/tests/cli_init.rs b/tests/cli_init.rs index 86ce9b9..7f7103f 100644 --- a/tests/cli_init.rs +++ b/tests/cli_init.rs @@ -82,7 +82,7 @@ fn setup_designer_init_project_with_body( let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let v8_path = dir.path().join("1cv8"); let infobase_path = dir.path().join("ib"); @@ -94,8 +94,7 @@ fn setup_designer_init_project_with_body( ); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File={}'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File={}'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", work_path.display(), infobase_path.display(), v8_path.display(), @@ -120,7 +119,7 @@ fn setup_edt_init_project( let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let platform_path = dir .path() .join(if builder == "IBCMD" { "ibcmd" } else { "1cv8" }); @@ -158,8 +157,7 @@ fn setup_edt_init_project( ); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: {}\nbuilder: {}\ninfobase:\n connection: '{}'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\n - name: ext\n type: EXTENSION\n path: ext\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: {}\nbuilder: {}\ninfobase:\n connection: '{}'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\n - name: ext\n type: EXTENSION\n path: ext\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", work_path.display(), format, builder, @@ -185,7 +183,7 @@ fn setup_ibcmd_server_init_project( let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let ibcmd_path = dir.path().join("ibcmd"); let calls_log = dir.path().join("ibcmd.calls.log"); @@ -201,8 +199,7 @@ fn setup_ibcmd_server_init_project( ); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: 'Srvr=cluster:1541;Ref=demo'\n user: Admin\n password: secret\n dbms:\n kind: PostgreSQL\n server: localhost\n name: demo\n user: postgres\n password: pg-secret\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n connection: 'Srvr=cluster:1541;Ref=demo'\n user: Admin\n password: secret\n dbms:\n kind: PostgreSQL\n server: localhost\n name: demo\n user: postgres\n password: pg-secret\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", work_path.display(), ibcmd_path.display(), ); @@ -254,6 +251,40 @@ fn init_designer_non_zero_create_exit_stays_fatal_even_when_marker_appears() { .contains("designer create failed")); } +#[test] +fn init_text_reports_infobase_failure_before_continuing_edt_import() { + let (_dir, config_path, _work_path, _base_path, platform_path, edt_calls_log) = + setup_edt_init_project("EDT", "DESIGNER", "__AUTO_FILE__"); + write_script( + &platform_path, + "printf 'designer create failed\\n' >&2\nexit 1", + ); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--no-color", + "init", + ]) + .output() + .expect("run command"); + + assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let failed_step = stdout + .find("✗ infobase: create") + .expect("live failed infobase status"); + let edt_import = stdout + .find("importing source-set project") + .expect("continued edt import"); + let final_summary = stdout.find("Init failed").expect("final summary"); + assert!(failed_step < edt_import); + assert!(failed_step < final_summary); + assert!(stdout.contains("✓ edt_workspace: import")); + assert!(edt_calls_log.exists()); +} + #[test] fn init_ibcmd_creates_infobase_and_imports_edt_projects_in_order() { let (_dir, config_path, work_path, _base_path, _platform_path, edt_calls_log) = @@ -331,6 +362,10 @@ fn init_edt_with_ibcmd_creates_infobase_and_imports_projects_in_order() { .trim_matches('\''); assert!(Path::new(infobase_dir).join("1Cv8.1CD").exists()); assert!(work_path.join("edt-workspace").exists()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("importing source-set project 'main'")); + assert!(stdout.contains("importing source-set project 'ext'")); + assert!(stdout.contains("imported EDT projects: main, ext")); let calls = fs::read_to_string(edt_calls_log).expect("calls"); let lines: Vec<_> = calls.lines().collect(); assert_eq!(lines.len(), 2); diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index a79549f..1496ddb 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -22,14 +22,13 @@ fn write_logging_script(path: &Path, args_log: &Path) { fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, platform_version: Option<&str>, ) { let mut config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\ntools:\n platform:\n path: '{}'\n", work_path.display(), platform_path.display(), ); @@ -126,8 +125,7 @@ fn setup_mcp_va_project_with_options( ) }; let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n client_mcp:\n port: 9874\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\ntools:\n client_mcp:\n port: 9874\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", work_path.display(), va_params.display(), features_dir.display(), @@ -384,7 +382,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { "--mcp-config", "/tmp/mcp conf.json", "--mcp-transport", - "legacy", + "mcp", "--raw-key", "/WA-", ]) @@ -400,7 +398,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { ); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!(payload["data"]["mode"], "mcp"); - assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["transport"], "mcp"); assert_eq!(payload["data"]["mcp_port"], 9874); assert_eq!( payload["data"]["binary"].as_str().expect("binary"), @@ -432,7 +430,14 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { assert!(params_json["WorkspaceRoot"] .as_str() .expect("WorkspaceRoot") - .contains("/project")); + .contains( + config_path + .parent() + .expect("config dir") + .display() + .to_string() + .as_str() + )); assert_eq!(params_json["ОстановкаПриВозникновенииОшибки"], false); assert_eq!(params_json["СписокФичДляВыполнения"][0], "login"); assert_eq!(params_json["СписокТеговОтбор"][0], "smoke"); @@ -709,7 +714,7 @@ fn setup_mcp_project_with_logging_thin() -> (tempfile::TempDir, PathBuf, PathBuf } #[test] -fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { +fn launch_mcp_transport_emits_runmcp_payload_and_mcp_envelope() { let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); let output = v8_runner_command() .args([ @@ -719,7 +724,7 @@ fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { "launch", "mcp", "--mcp-transport", - "legacy", + "mcp", "--mcp-port", "9999", ]) @@ -731,7 +736,7 @@ fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { String::from_utf8_lossy(&output.stderr) ); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); - assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["transport"], "mcp"); assert_eq!(payload["data"]["mcp_port"], 9999); assert!(payload["data"]["client_uid"].is_null()); @@ -832,7 +837,7 @@ fn launch_mcp_ws_required_fails_when_manager_unreachable() { } #[test] -fn launch_mcp_auto_falls_back_to_legacy_when_manager_unreachable() { +fn launch_mcp_auto_falls_back_to_mcp_when_manager_unreachable() { let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let port = listener.local_addr().expect("addr").port(); @@ -854,7 +859,7 @@ fn launch_mcp_auto_falls_back_to_legacy_when_manager_unreachable() { .expect("run command"); assert!(output.status.success()); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); - assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["transport"], "mcp"); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("/C\"runMcp\"")); assert!(!args.contains("mcpMode=ws")); diff --git a/tests/cli_load.rs b/tests/cli_load.rs index 4a585a6..2fafb3e 100644 --- a/tests/cli_load.rs +++ b/tests/cli_load.rs @@ -56,14 +56,13 @@ fn write_edt_configuration_source(path: &Path, project_name: &str) { fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, format: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: {}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: {}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", work_path.display(), format, platform_path.display(), @@ -75,7 +74,7 @@ fn setup_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, PathBuf) { let dir = temp_workspace(); let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let binary_path = dir.path().join("1cv8"); let calls_log = dir.path().join("calls.log"); diff --git a/tests/cli_syntax.rs b/tests/cli_syntax.rs index efbc9a4..6fab50a 100644 --- a/tests/cli_syntax.rs +++ b/tests/cli_syntax.rs @@ -48,7 +48,7 @@ fn write_edt_configuration_source(path: &Path, project_name: &str) { fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, format: &str, @@ -58,8 +58,7 @@ fn write_config( .map(|path| format!(" edt_cli:\n path: '{}'\n", path.display())) .unwrap_or_default(); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: {}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n path: '{}'\n{}", - base_path.display(), + "workPath: '{}'\nformat: {}\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n path: '{}'\n{}", work_path.display(), format, platform_path.display(), @@ -73,7 +72,7 @@ fn setup_project(script_body: &str) -> (tempfile::TempDir, PathBuf) { let base_path = dir.path().join("project"); let work_path = dir.path().join("work"); let install_dir = dir.path().join("platform"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); fs::create_dir_all(&base_path).expect("base"); fs::create_dir_all(&work_path).expect("work"); @@ -96,7 +95,7 @@ fn setup_edt_project(script_body: &str) -> (tempfile::TempDir, PathBuf) { let work_path = dir.path().join("work"); let install_dir = dir.path().join("platform"); let edt_cli = dir.path().join("edt").join("1cedtcli"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); fs::create_dir_all(&base_path).expect("base"); write_edt_configuration_source(&base_path, "main"); diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 51b08f4..823da66 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -141,7 +141,7 @@ fn write_va_test_script( fn write_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, install_dir: &Path, timeout_seconds: u64, @@ -159,8 +159,7 @@ fn write_config( ) }; let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: {}\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n{}", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: {}\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n{}", work_path.display(), timeout_seconds, install_dir.display(), @@ -204,7 +203,7 @@ fn setup_project_with_additional_launch_keys( let base_path = dir.path().join("project"); let work_path = dir.path().join(work_dir_name); let install_dir = dir.path().join("platform"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let build_calls = dir.path().join("build.calls.log"); let test_calls = dir.path().join("test.calls.log"); let captured_config = dir.path().join("captured-config.json"); @@ -259,7 +258,7 @@ fn setup_va_project_with_work_name( let base_path = dir.path().join("project"); let work_path = dir.path().join(work_dir_name); let install_dir = dir.path().join("platform"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let build_calls = dir.path().join("build.calls.log"); let test_calls = dir.path().join("test.calls.log"); let captured_params = dir.path().join("captured-va-params.json"); @@ -302,8 +301,7 @@ fn setup_va_project_with_work_name( ) }; let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: 5\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\n ignore_tags:\n - '@draft'\n scenario_filter:\n - Проверка логина\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: 5\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\n ignore_tags:\n - '@draft'\n scenario_filter:\n - Проверка логина\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", work_path.display(), va_params.display(), features_dir.display(), @@ -1000,7 +998,7 @@ fn test_module_edt_extension_build_uses_full_load_before_enterprise_launch() { let work_path = dir.path().join("work"); let install_dir = dir.path().join("platform"); let edt_cli_path = dir.path().join("edt").join("1cedtcli"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let build_calls = dir.path().join("build.calls.log"); let test_calls = dir.path().join("test.calls.log"); let edt_calls = dir.path().join("edt.calls.log"); @@ -1057,8 +1055,7 @@ fn test_module_edt_extension_build_uses_full_load_before_enterprise_launch() { write_edt_script(&edt_cli_path, &edt_calls); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\n - name: client_mcp\n type: EXTENSION\n path: exts/client-mcp\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n", work_path.display(), install_dir.display(), edt_cli_path.display(), @@ -1114,7 +1111,7 @@ fn repeated_test_skips_unchanged_source_backed_tool_extension_build() { let work_path = dir.path().join("work"); let install_dir = dir.path().join("platform"); let edt_cli_path = dir.path().join("edt").join("1cedtcli"); - let config_path = dir.path().join("v8project.yaml"); + let config_path = base_path.join("v8project.yaml"); let build_calls = dir.path().join("build.calls.log"); let test_calls = dir.path().join("test.calls.log"); let edt_calls = dir.path().join("edt.calls.log"); @@ -1148,8 +1145,7 @@ fn repeated_test_skips_unchanged_source_backed_tool_extension_build() { write_edt_script(&edt_cli_path, &edt_calls); let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n client_mcp:\n extension:\n name: client_mcp\n source:\n path: '{}'\n format: EDT\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: configuration\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n client_mcp:\n extension:\n name: client_mcp\n source:\n path: '{}'\n format: EDT\n", work_path.display(), install_dir.display(), edt_cli_path.display(), diff --git a/tests/cli_tools_download.rs b/tests/cli_tools_download.rs new file mode 100644 index 0000000..5cc724e --- /dev/null +++ b/tests/cli_tools_download.rs @@ -0,0 +1,841 @@ +#![cfg(unix)] + +mod support; + +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use support::{free_tcp_port, temp_workspace, v8_runner_command, wait_until}; + +fn write_minimal_config(root: &Path) -> PathBuf { + write_minimal_config_with_builder(root, "DESIGNER") +} + +fn write_minimal_config_with_builder(root: &Path, builder: &str) -> PathBuf { + let base_path = root.join("project"); + let work_path = root.join("work"); + fs::create_dir_all(&base_path).expect("base"); + fs::create_dir_all(base_path.join("configuration")).expect("configuration"); + fs::create_dir_all(&work_path).expect("work"); + let config_path = root.join("v8project.yaml"); + fs::write( + &config_path, + format!( + "# yaml-language-server: $schema=./docs/schemas/v8project.schema.json\nworkPath: '{}'\nformat: DESIGNER\nbuilder: {builder}\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: configuration\n type: CONFIGURATION\n path: project/configuration\ntools:\n edt_cli:\n path: /tmp/edt\n", + work_path.display(), + ), + ) + .expect("config"); + config_path +} + +fn write_config_with_pending_va(root: &Path) -> PathBuf { + let config_path = write_minimal_config(root); + let mut config = fs::read_to_string(&config_path).expect("config"); + config.push_str( + "tests:\n va:\n params_path: missing/params.json\n profile: smoke\n profiles:\n smoke:\n feature_path: missing/features\n", + ); + fs::write(&config_path, config).expect("pending va config"); + config_path +} + +fn write_config_with_execution_timeout(root: &Path, timeout_ms: u64) -> PathBuf { + let config_path = write_minimal_config(root); + let mut config = fs::read_to_string(&config_path).expect("config"); + config.push_str(&format!("execution_timeout: {timeout_ms}\n")); + fs::write(&config_path, config).expect("timeout config"); + config_path +} + +fn fixture_server(root: &Path, port: u16) -> Child { + let script = format!( + "import http.server, socketserver\nclass Handler(http.server.SimpleHTTPRequestHandler):\n def do_GET(self):\n redirects = [('/redirect302/', 302), ('/redirect/', 301)]\n for prefix, status in redirects:\n if self.path.startswith(prefix):\n self.send_response(status)\n self.send_header('Location', self.path[len(prefix) - 1:])\n self.end_headers()\n return\n super().do_GET()\nsocketserver.TCPServer.allow_reuse_address = True\nwith socketserver.TCPServer(('127.0.0.1', {port}), Handler) as httpd:\n httpd.serve_forever()\n" + ); + Command::new("python3") + .arg("-c") + .arg(script) + .current_dir(root) + .spawn() + .expect("fixture server") +} + +fn sleeping_server(port: u16) -> Child { + let script = format!( + "import http.server, socketserver, time\nclass Handler(http.server.BaseHTTPRequestHandler):\n def do_GET(self):\n time.sleep(5)\n self.send_response(200)\n self.end_headers()\n self.wfile.write(b'{{}}')\nsocketserver.TCPServer.allow_reuse_address = True\nwith socketserver.TCPServer(('127.0.0.1', {port}), Handler) as httpd:\n httpd.serve_forever()\n" + ); + Command::new("python3") + .arg("-c") + .arg(script) + .spawn() + .expect("sleeping server") +} + +struct FixtureServer { + child: Child, +} + +impl FixtureServer { + fn start(root: &Path, port: u16) -> Self { + Self { + child: fixture_server(root, port), + } + } +} + +impl Drop for FixtureServer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn write_http_fixture(root: &Path, port: u16) { + write_http_fixture_with_redirect_prefix(root, port, ""); +} + +fn write_http_fixture_with_redirects(root: &Path, port: u16, redirects: bool) { + let prefix = if redirects { "/redirect" } else { "" }; + write_http_fixture_with_redirect_prefix(root, port, prefix); +} + +fn write_http_fixture_with_redirect_prefix(root: &Path, port: u16, prefix: &str) { + let api = root.join("repos"); + write_release( + &api.join("bia-technologies") + .join("yaxunit") + .join("releases"), + "25.12", + &format!("http://127.0.0.1:{port}{prefix}/archives/yaxunit.zip"), + &[( + "YAxUnit-25.12.cfe", + &format!("http://127.0.0.1:{port}{prefix}/assets/YAxUnit-25.12.cfe"), + )], + ); + write_release( + &api.join("Pr-Mex") + .join("vanessa-automation-single") + .join("releases"), + "1.2.043.1", + &format!("http://127.0.0.1:{port}{prefix}/archives/vanessa-source.zip"), + &[( + "vanessa-automation-single.1.2.043.1.zip", + &format!( + "http://127.0.0.1:{port}{prefix}/assets/vanessa-automation-single.1.2.043.1.zip" + ), + )], + ); + write_release( + &api.join("1c-neurofish") + .join("onec-client-mcp-devkit") + .join("releases"), + "v0.6.4", + &format!("http://127.0.0.1:{port}{prefix}/archives/client-mcp.zip"), + &[( + "client_mcp.cfe", + &format!("http://127.0.0.1:{port}{prefix}/assets/client_mcp.cfe"), + )], + ); + + fs::create_dir_all(root.join("assets")).expect("assets"); + fs::write(root.join("assets").join("YAxUnit-25.12.cfe"), "yaxunit cfe").expect("yax asset"); + fs::write(root.join("assets").join("client_mcp.cfe"), "client cfe").expect("client asset"); + make_zip( + &root + .join("assets") + .join("vanessa-automation-single.1.2.043.1.zip"), + &[("vanessa-automation-single.epf", "va epf")], + ); + + fs::create_dir_all(root.join("archives")).expect("archives"); + make_zip( + &root.join("archives").join("yaxunit.zip"), + &[( + "bia-technologies-yaxunit/exts/yaxunit/src/Configuration/Configuration.mdo", + "yaxunit source", + )], + ); + make_zip( + &root.join("archives").join("client-mcp.zip"), + &[( + "1c-neurofish-onec-client-mcp-devkit/exts/client-mcp/src/Configuration/Configuration.mdo", + "client source", + )], + ); + make_zip( + &root.join("archives").join("vanessa-source.zip"), + &[("unused/readme.txt", "unused")], + ); +} + +fn write_release(path: &Path, tag: &str, zipball_url: &str, assets: &[(&str, &str)]) { + fs::create_dir_all(path).expect("release dir"); + let assets_json = assets + .iter() + .map(|(name, url)| format!(r#"{{"name":"{name}","browser_download_url":"{url}"}}"#)) + .collect::>() + .join(","); + fs::write( + path.join("latest"), + format!( + r#"{{"tag_name":"{tag}","html_url":"https://example.invalid/{tag}","zipball_url":"{zipball_url}","assets":[{assets_json}]}}"# + ), + ) + .expect("release json"); +} + +fn make_zip(path: &Path, entries: &[(&str, &str)]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("zip parent"); + } + let status = Command::new("python3") + .arg("-c") + .arg( + "import sys, zipfile\nwith zipfile.ZipFile(sys.argv[1], 'w') as z:\n for pair in sys.argv[2:]:\n name, value = pair.split('=', 1)\n z.writestr(name, value)\n", + ) + .arg(path) + .args(entries.iter().map(|(name, value)| format!("{name}={value}"))) + .status() + .expect("zip"); + assert!(status.success()); +} + +#[test] +fn tools_download_sources_writes_source_set_and_local_tool_settings() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "tools", + "download", + "yaxunit", + "--sources", + ]) + .output() + .expect("run command"); + let repeat = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "yaxunit", + "--sources", + ]) + .output() + .expect("run command again"); + let client_mcp = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "client-mcp", + "--sources", + ]) + .output() + .expect("run client-mcp command"); + let vanessa = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "vanessa", + ]) + .output() + .expect("run vanessa command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + repeat.status.success(), + "status={:?}\nstdout={}\nstderr={}", + repeat.status.code(), + String::from_utf8_lossy(&repeat.stdout), + String::from_utf8_lossy(&repeat.stderr) + ); + assert!( + client_mcp.status.success(), + "status={:?}\nstdout={}\nstderr={}", + client_mcp.status.code(), + String::from_utf8_lossy(&client_mcp.stdout), + String::from_utf8_lossy(&client_mcp.stderr) + ); + assert!( + vanessa.status.success(), + "status={:?}\nstdout={}\nstderr={}", + vanessa.status.code(), + String::from_utf8_lossy(&vanessa.stdout), + String::from_utf8_lossy(&vanessa.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["command"], "tools download"); + assert_eq!(payload["data"]["tool"], "yaxunit"); + assert_eq!(payload["data"]["mode"], "sources"); + + let config = fs::read_to_string(&config_path).expect("config"); + assert!(config.starts_with("# yaml-language-server:")); + assert!(config.contains("name: tests")); + assert!(config.contains("type: EXTENSION")); + assert!(config.contains("path: tests")); + assert!(dir + .path() + .join("tests/src/Configuration/Configuration.mdo") + .exists()); + assert!(!dir + .path() + .join("tests/.v8-runner-tools-download.json") + .exists()); + assert!(!dir + .path() + .join(".tests.v8-runner-tools-download.json") + .exists()); + assert!(dir + .path() + .join("build/.tests.v8-runner-tools-download.json") + .exists()); + assert!(dir + .path() + .join("build/tools/vanessa-automation-single.epf") + .exists()); + assert!(dir + .path() + .join("build/tools/onec-client-mcp-devkit/exts/client-mcp/src/Configuration/Configuration.mdo") + .exists()); + assert!(!dir + .path() + .join("build/tools/onec-client-mcp-devkit/exts/client-mcp/.v8-runner-tools-download.json") + .exists()); + assert!(dir + .path() + .join("build/tools/onec-client-mcp-devkit/exts/.client-mcp.v8-runner-tools-download.json") + .exists()); + + let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); + assert!(local.contains("epf_path:")); + assert!(local.contains("epf_path: build/tools/vanessa-automation-single.epf")); + assert!(local.contains("client_mcp:")); + assert!(local.contains("source:")); + assert!(local.contains("path: build/tools/onec-client-mcp-devkit/exts/client-mcp")); + assert!(local.contains("format: EDT")); + assert!(!local.contains(&dir.path().display().to_string())); +} + +#[test] +fn tools_download_sources_rejects_legacy_tests_markers_outside_build() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + fs::create_dir_all(dir.path().join("tests")).expect("tests"); + fs::write( + dir.path().join(".tests.v8-runner-tools-download.json"), + "{}\n", + ) + .expect("legacy root marker"); + fs::write( + dir.path().join("tests/.v8-runner-tools-download.json"), + "{}\n", + ) + .expect("legacy nested marker"); + + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "yaxunit", + "--sources", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(combined.contains("download target already exists and is not managed by v8-runner")); + assert!(!dir + .path() + .join("build/.tests.v8-runner-tools-download.json") + .exists()); +} + +#[test] +fn tools_download_repairs_pending_vanessa_configuration() { + let dir = temp_workspace(); + let config_path = write_config_with_pending_va(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "vanessa", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); + assert!(local.contains("epf_path:")); + assert!(local.contains("epf_path: build/tools/vanessa-automation-single.epf")); + assert!(!local.contains(&dir.path().display().to_string())); +} + +#[test] +fn tools_download_follows_latest_release_and_asset_redirects() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture_with_redirects(&server_root, port, true); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}/redirect"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "client-mcp", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(dir.path().join("build/tools/client_mcp.cfe").exists()); + let local = fs::read_to_string(dir.path().join("v8project.local.yaml")).expect("local"); + assert!(local.contains("artifact:")); + assert!(local.contains("client_mcp.cfe")); +} + +#[test] +fn tools_download_follows_302_redirects() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture_with_redirect_prefix(&server_root, port, "/redirect302"); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}/redirect302"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "client-mcp", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(dir.path().join("build/tools/client_mcp.cfe").exists()); +} + +#[test] +fn tools_download_artifacts_keeps_yaxunit_out_of_source_sets() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "yaxunit", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let config = fs::read_to_string(&config_path).expect("config"); + assert!(!config.contains("name: tests")); + assert!(dir.path().join("build/tools/YAxUnit-25.12.cfe").exists()); + assert!(!dir.path().join("v8project.local.yaml").exists()); +} + +#[test] +fn tools_download_artifacts_handles_large_assets_without_pipe_deadlock() { + let dir = temp_workspace(); + let config_path = write_config_with_execution_timeout(dir.path(), 3_000); + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + fs::write( + server_root.join("assets").join("YAxUnit-25.12.cfe"), + vec![b'x'; 8 * 1024 * 1024], + ) + .expect("large yax asset"); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "yaxunit", + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::metadata(dir.path().join("build/tools/YAxUnit-25.12.cfe")) + .expect("large asset") + .len(), + 8 * 1024 * 1024 + ); +} + +#[test] +fn tools_download_sources_refuses_to_replace_unmanaged_tests_dir() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let user_file = dir.path().join("tests/custom.feature"); + fs::create_dir_all(user_file.parent().expect("tests parent")).expect("tests dir"); + fs::write(&user_file, "user content").expect("user test"); + + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "yaxunit", + "--sources", + "--force", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(&user_file).expect("user file"), + "user content" + ); +} + +#[test] +fn tools_download_artifacts_requires_designer_builder() { + let dir = temp_workspace(); + let config_path = write_minimal_config_with_builder(dir.path(), "IBCMD"); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "client-mcp", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(combined.contains("requires builder=DESIGNER")); +} + +#[test] +fn tools_download_force_refuses_to_replace_unmanaged_tool_file() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let user_file = dir.path().join("build/tools/vanessa-automation-single.epf"); + fs::create_dir_all(user_file.parent().expect("tools parent")).expect("tools dir"); + fs::write(&user_file, "user epf").expect("user epf"); + + let server_root = dir.path().join("server"); + let port = free_tcp_port(); + write_http_fixture(&server_root, port); + let _server = FixtureServer::start(&server_root, port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "vanessa", + "--force", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(&user_file).expect("user file"), + "user epf" + ); +} + +#[test] +fn tools_download_respects_execution_timeout_during_http_download() { + let dir = temp_workspace(); + let config_path = write_config_with_execution_timeout(dir.path(), 200); + let port = free_tcp_port(); + let mut server = sleeping_server(port); + assert!(wait_until( + std::time::Duration::from_secs(5), + std::time::Duration::from_millis(50), + || std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() + )); + + let started = std::time::Instant::now(); + let output = v8_runner_command() + .env( + "V8TR_GITHUB_API_BASE_URL", + format!("http://127.0.0.1:{port}"), + ) + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "yaxunit", + ]) + .output() + .expect("run command"); + let elapsed = started.elapsed(); + let _ = server.kill(); + let _ = server.wait(); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + elapsed < std::time::Duration::from_secs(2), + "elapsed={elapsed:?}" + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(combined.contains("timed out")); +} + +#[test] +fn tools_download_sources_rejects_conflicting_tests_source_set() { + let dir = temp_workspace(); + let config_path = write_minimal_config(dir.path()); + let mut config = fs::read_to_string(&config_path).expect("config"); + config = config.replace( + "tools:\n", + " - name: tests\n type: CONFIGURATION\n path: custom-tests\ntools:\n", + ); + fs::write(&config_path, config).expect("config"); + + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "tools", + "download", + "yaxunit", + "--sources", + ]) + .output() + .expect("run command"); + + assert!( + !output.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(combined.contains("source-set 'tests' already exists")); + assert!(!dir.path().join("tests").exists()); +} diff --git a/tests/mcp_http.rs b/tests/mcp_http.rs index fbf68c1..b670bbd 100644 --- a/tests/mcp_http.rs +++ b/tests/mcp_http.rs @@ -97,7 +97,7 @@ fn write_interactive_edt_script( fn write_http_designer_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, bind_address: &str, @@ -106,8 +106,7 @@ fn write_http_designer_config( idle_ttl_secs: u64, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: {}\n max_sessions: {}\n idle_ttl_secs: {}\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: {}\n max_sessions: {}\n idle_ttl_secs: {}\ntools:\n platform:\n path: '{}'\n", work_path.display(), bind_address, stateful_sessions, @@ -120,7 +119,7 @@ fn write_http_designer_config( fn write_http_ibcmd_config_with_infobase( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, ibcmd_path: &Path, bind_address: &str, @@ -129,8 +128,7 @@ fn write_http_ibcmd_config_with_infobase( infobase_yaml: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}source-set:\n - name: main\n type: CONFIGURATION\n path: main\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: true\n max_sessions: {}\n idle_ttl_secs: {}\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}source-set:\n - name: main\n type: CONFIGURATION\n path: project/main\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: true\n max_sessions: {}\n idle_ttl_secs: {}\ntools:\n platform:\n path: '{}'\n", work_path.display(), infobase_yaml, bind_address, @@ -195,7 +193,7 @@ fn write_edt_configuration_source(path: &Path, project_name: &str) { fn write_http_edt_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, edt_path: &Path, bind_address: &str, @@ -205,8 +203,7 @@ fn write_http_edt_config( command_timeout_ms: u64, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main-edt\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: true\n max_sessions: {}\n idle_ttl_secs: {}\n execution:\n max_concurrent_calls: {}\ntools:\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", - base_path.display(), + "workPath: '{}'\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main-edt\nmcp:\n http:\n bind_address: {}\n path: /mcp\n stateful_sessions: true\n max_sessions: {}\n idle_ttl_secs: {}\n execution:\n max_concurrent_calls: {}\ntools:\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", work_path.display(), bind_address, max_sessions, diff --git a/tests/mcp_stdio.rs b/tests/mcp_stdio.rs index f9fc344..4ddb35a 100644 --- a/tests/mcp_stdio.rs +++ b/tests/mcp_stdio.rs @@ -64,10 +64,9 @@ fn run_cli_json_with_status(config_path: &Path, args: &[&str]) -> (bool, Value) ) } -fn write_config(path: &Path, base_path: &Path, work_path: &Path, platform_path: &Path) { +fn write_config(path: &Path, _base_path: &Path, work_path: &Path, platform_path: &Path) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout: 300000\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout: 300000\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\ntools:\n platform:\n path: '{}'\n", work_path.display(), platform_path.display(), ); @@ -76,15 +75,14 @@ fn write_config(path: &Path, base_path: &Path, work_path: &Path, platform_path: fn write_edt_config_with_options( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, edt_path: &Path, command_timeout_ms: u64, max_concurrent_calls: usize, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout: {}\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main-edt\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout: {}\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main-edt\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", work_path.display(), command_timeout_ms, max_concurrent_calls, @@ -96,15 +94,14 @@ fn write_edt_config_with_options( fn write_designer_config_with_options( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, command_timeout_ms: u64, max_concurrent_calls: usize, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout: 300000\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n platform:\n path: '{}'\n edt_cli:\n command_timeout_ms: {}\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout: 300000\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n platform:\n path: '{}'\n edt_cli:\n command_timeout_ms: {}\n", work_path.display(), max_concurrent_calls, platform_path.display(), @@ -115,7 +112,7 @@ fn write_designer_config_with_options( fn write_edt_config_with_platform( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, edt_path: &Path, @@ -123,8 +120,7 @@ fn write_edt_config_with_platform( max_concurrent_calls: usize, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nexecution_timeout: {}\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main-edt\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", - base_path.display(), + "workPath: '{}'\nexecution_timeout: {}\nformat: EDT\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main-edt\nmcp:\n execution:\n max_concurrent_calls: {}\ntools:\n platform:\n path: '{}'\n edt_cli:\n path: '{}'\n interactive-mode: true\n command_timeout_ms: {}\n", work_path.display(), command_timeout_ms, max_concurrent_calls, @@ -287,13 +283,12 @@ fn setup_hybrid_edt_project_with_options( fn write_designer_suite_config( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, platform_path: &Path, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nmcp:\n execution:\n max_concurrent_calls: 1\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\ntests:\n execution_timeout_seconds: 5\nmcp:\n execution:\n max_concurrent_calls: 1\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n", work_path.display(), platform_path.display(), ); @@ -345,14 +340,13 @@ fn setup_designer_suite_project() -> (tempfile::TempDir, PathBuf, PathBuf, PathB fn write_ibcmd_config_with_infobase( path: &Path, - base_path: &Path, + _base_path: &Path, work_path: &Path, ibcmd_path: &Path, infobase_yaml: &str, ) { let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}mcp:\n execution:\n max_concurrent_calls: 1\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n platform:\n path: '{}'\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: IBCMD\ninfobase:\n{}mcp:\n execution:\n max_concurrent_calls: 1\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project/main\ntools:\n platform:\n path: '{}'\n", work_path.display(), infobase_yaml, ibcmd_path.display(), @@ -616,8 +610,7 @@ fn mcp_unsupported_main_config_shape_reports_error_on_stderr() { fs::write( &config_path, format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: .\ntools:\n platform:\n typo: value\n", - base_path.display(), + "workPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\nsource-set:\n - name: main\n type: CONFIGURATION\n path: project\ntools:\n platform:\n typo: value\n", work_path.display() ), ) diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 9954d4e..d63d407 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -93,6 +93,14 @@ pub fn read_line_count(path: &Path) -> usize { .unwrap_or(0) } +pub fn free_tcp_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind free port") + .local_addr() + .expect("local addr") + .port() +} + pub async fn wait_until_async(attempts: usize, interval: Duration, mut condition: F) -> bool where F: FnMut() -> bool, diff --git a/v8-runner/SKILL.md b/v8-runner/SKILL.md index e76caea..0df7bab 100644 --- a/v8-runner/SKILL.md +++ b/v8-runner/SKILL.md @@ -23,7 +23,7 @@ Use the available `v8-runner` binary directly. If it is not on `PATH`, ask for t `v8project.yaml` is the default project config name. A sibling `v8project.local.yaml` is loaded automatically for machine-local paths, credentials, tools, tests, and MCP settings. Do not pass `--config v8project.yaml` unless the user explicitly wants a non-default command shape or the active config path differs from the default; never pass `v8project.local.yaml` as `--config`. -Generated `v8project.yaml` files include a `yaml-language-server` modeline that points to the versioned JSON Schema for the current `v8-runner` release. For `v8project.local.yaml`, use the matching `docs/schemas/v8project.local.schema.json` raw GitHub tag URL in editor settings when schema-assisted editing matters. +Generated `v8project.yaml` files include a `yaml-language-server` modeline that points to the published `master` JSON Schema artifact. `config init` also creates sibling `v8project.local.yaml` with the local overlay schema modeline and adds it to `.gitignore` when needed. Use JSON output only when another tool, script, or final answer needs structured results: @@ -46,7 +46,7 @@ Useful global flags: 1. Check whether `v8project.yaml` exists in the 1C project root. 2. If it is missing, run the narrowest `v8-runner config init ...` command that fits the project shape. -3. Inspect the generated config before running mutating commands. +3. Inspect generated `v8project.yaml` and keep machine-local overrides in generated `v8project.local.yaml`. 4. Run `v8-runner init` only when the file infobase or EDT workspace needs to be created. 5. Run the narrowest validation command that answers the user's goal. @@ -57,6 +57,9 @@ v8-runner config init v8-runner config init --connection "File=build/ib" v8-runner config init --format edt v8-runner config init --builder IBCMD +v8-runner tools download yaxunit --sources +v8-runner tools download vanessa +v8-runner tools download client-mcp --sources v8-runner init ``` @@ -66,7 +69,14 @@ v8-runner init - Only one source-set changed: use commands that accept `--source-set ` instead of rebuilding or materializing everything. - Branch switch, rebase, large object moves, stale source-backed tool extension state, or suspicious incremental state: run `v8-runner build --full-rebuild`. - Syntax check: inspect `format` and `builder`, then choose `syntax designer-modules`, `syntax designer-config`, or `syntax edt`. -- Behavior validation: run the relevant `v8-runner test ...` command; tests build first. +- Behavior validation: run the relevant `v8-runner test ...` command; tests build first with a + static `/UpdateDBCfg`. Use `v8-runner build --dynamic` before tests when dynamic preparation is + intentional. +- Missing local YAxUnit, Vanessa Automation, or onec-client-mcp-devkit setup: run + `v8-runner tools download yaxunit --sources`, `v8-runner tools download vanessa`, and + `v8-runner tools download client-mcp --sources` for source-backed setup. Omit + `--sources` on `yaxunit` or `client-mcp` to download `.cfe` artifacts when + `builder=DESIGNER`. - Vanessa Automation debugging or scenario authoring: use `v8-runner launch mcp va ...` to start the client MCP server with VA loaded. - Extension properties need synchronization: use `v8-runner extensions` or `extensions --name `. - Infobase changes need to become Git-visible files: check `git status`, then run the relevant `v8-runner dump ...` command. @@ -75,7 +85,7 @@ v8-runner init - Release artifacts need to be exported or external artifacts published: use `v8-runner make ...` or the `artifacts` alias. - Need a 1C UI session: use `v8-runner launch designer`, `launch thin`, `launch thick`, or `launch ordinary`. - Need onec-client-mcp-devkit launched inside 1C without VA authoring: use `v8-runner launch mcp ...`. -- Pair the launched 1С-client with a running [v8-client-session-manager](https://github.com/SteelMorgan/v8-client-session-manager) over WebSocket: rely on `--mcp-transport=auto` (default — TCP-probes `manager_url` for 200 ms). Force WS with `--mcp-transport=ws` (fails if manager is down) or skip WS entirely with `--mcp-transport=legacy`. WS-only flags: `--manager-url`, `--client-uid`, `--corr-id`, `--mcp-log-level`, `--mcp-ws-timeout-ms`. The internal `kind` mapping (`v8_runner_client` / `vanessa_test_client` / `yaxunit_runner` / `vanessa_test_client`) is fixed by entry-point and **not** overridable from CLI. Read `references/project-workflows.md` (section «WS-режим к session-manager») for the full payload, defaults, and `--json-message` shape. +- Pair the launched 1С-client with a running [v8-client-session-manager](https://github.com/SteelMorgan/v8-client-session-manager) over WebSocket: rely on `--mcp-transport=auto` (default — TCP-probes `manager_url` for 200 ms). Force WS with `--mcp-transport=ws` (fails if manager is down) or use local HTTP MCP with `--mcp-transport=mcp`. WS-only flags: `--manager-url`, `--client-uid`, `--corr-id`, `--mcp-log-level`, `--mcp-ws-timeout-ms`. The internal `kind` mapping (`v8_runner_client` / `vanessa_test_client` / `yaxunit_runner` / `vanessa_test_client`) is fixed by entry-point and **not** overridable from CLI. Read `references/project-workflows.md` (section «WS-режим к session-manager») for the full payload, defaults, and `--json-message` shape. ## Guardrails diff --git a/v8-runner/references/bootstrap.md b/v8-runner/references/bootstrap.md index 29b6288..557659b 100644 --- a/v8-runner/references/bootstrap.md +++ b/v8-runner/references/bootstrap.md @@ -39,13 +39,13 @@ If the format is unambiguously one or the other, you may pass `--format=designer Defaults to `DESIGNER`. Switch to `IBCMD` only when: - the project is `EDT` and the team is on a platform version where `ibcmd` is supported and faster (≥ 8.3.20); **and** -- there is no Designer-only feature on the critical path (some legacy project tasks still need the Designer GUI). +- there is no Designer-only feature on the critical path (some older project tasks still need the Designer GUI). Ask the user only if the choice is ambiguous and you cannot tell from `tools.platform.version`. Default to `DESIGNER` when in doubt — it is the safer baseline. ### 4. Decide infobase connection -`--connection` is the connection string written into `tools.connection`. Three common shapes: +`--connection` is the connection string written into `infobase.connection`. Three common shapes: | Shape | Example | When to use | |---|---|---| diff --git a/v8-runner/references/command-selection.md b/v8-runner/references/command-selection.md index 63935fb..a989f5b 100644 --- a/v8-runner/references/command-selection.md +++ b/v8-runner/references/command-selection.md @@ -166,8 +166,8 @@ WS-mode flags (when v8-client-session-manager is reachable): ```bash v8-runner launch mcp --mcp-transport=ws --manager-url ws://127.0.0.1:4000/sessions -v8-runner launch mcp --mcp-transport=legacy # force legacy without probe +v8-runner launch mcp --mcp-transport=mcp # force local MCP without probe v8-runner launch mcp --mcp-log-level=debug --client-uid --corr-id ``` -`--mcp-transport=auto` (default) probes `manager_url` for 200 ms and chooses `ws` on success, `legacy` on failure. The same WS-flags work on `test yaxunit ...` and `test va ...`. See `project-workflows.md` for the full WS-режим section, internal `kind` mapping, and `--json-message` output shape. +`--mcp-transport=auto` (default) probes `manager_url` for 200 ms and chooses `ws` on success, `mcp` on failure. The same WS-flags work on `test yaxunit ...` and `test va ...`. See `project-workflows.md` for the full WS-режим section, internal `kind` mapping, and `--json-message` output shape. diff --git a/v8-runner/references/config-and-backends.md b/v8-runner/references/config-and-backends.md index 545b5c8..3d9e512 100644 --- a/v8-runner/references/config-and-backends.md +++ b/v8-runner/references/config-and-backends.md @@ -6,11 +6,12 @@ settings before CLI overrides. ## Fields To Check First -- `basePath`: root of 1C source files; defaults to the directory containing the primary config when omitted. - `workPath`: generated state, temp files, and workspace location. - `format`: `DESIGNER` or `EDT`. - `builder`: `DESIGNER` or `IBCMD`. - `infobase.connection`: often `File=build/ib` for local automation. +- `infobase.unlock_code`: optional infobase locking code. Non-empty values are propagated to DESIGNER as `/UC `; an empty string omits `/UC`. Required when the configuration was sealed with "Установить пароль"; masked in logs. Place this in `v8project.local.yaml` together with `infobase.password`. +- `build.dynamicUpdate`: project-wide default for `/UpdateDBCfg -Dynamic+`. Off by default. CLI `build --dynamic` overrides it for a single invocation. - `source-set`: ordered configuration and extension sources. - `tools.platform.path` or `tools.platform.version`: 1C platform discovery hints. - `tools.edt_cli.path`, `version`, and `interactive-mode`: EDT CLI discovery and execution mode. @@ -36,6 +37,7 @@ settings before CLI overrides. ## Source-Set Notes `source-set.name` is the stable identity for ordering, diagnostics, runtime contexts, generated directories, and command selection. +Relative `source-set.path` values are resolved from the directory containing the primary `v8project.yaml`. Supported `source-set.type` values: @@ -53,3 +55,5 @@ Prefer `--source-set ` for narrow build, dump, convert, and artifact flows `v8project.local.yaml` is an automatic local overlay only. It may override only `workPath`, `infobase.*`, `tools.*`, `tests.*`, and `mcp.*`; it must not define `source-set`, `format`, or `builder`, and it must not be used as `--config`. `--workdir` wins over both config files. +`config init` creates the sibling local overlay as an empty mapping with a schema modeline and adds +`v8project.local.yaml` to `.gitignore` when needed. diff --git a/v8-runner/references/file-and-artifact-workflows.md b/v8-runner/references/file-and-artifact-workflows.md index 477a344..5ee3217 100644 --- a/v8-runner/references/file-and-artifact-workflows.md +++ b/v8-runner/references/file-and-artifact-workflows.md @@ -47,7 +47,7 @@ It is not a dump alias: - it does not use `builder`; - direction is derived from configured `format`; - without `--output`, results are published under `workPath/convert/out///`; -- `--output` is a target root and mirrors `source-set.path` relative to `basePath`. +- `--output` is a target root and mirrors `source-set.path` relative to the primary config directory. `convert` is a CLI file workflow and does not run through an infobase. diff --git a/v8-runner/references/project-workflows.md b/v8-runner/references/project-workflows.md index 9786ef1..513236c 100644 --- a/v8-runner/references/project-workflows.md +++ b/v8-runner/references/project-workflows.md @@ -135,17 +135,17 @@ For `launch mcp va`, read `testing.md`; it is part of the Vanessa Automation deb ## WS-режим к session-manager -Когда рядом с проектом запущен [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager), 1С-клиент может подключаться к нему по WebSocket вместо локального HTTP MCP-сервера (legacy `runMcp`-режим). v8-runner делает выбор автоматически. +Когда рядом с проектом запущен [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager), 1С-клиент может подключаться к нему по WebSocket вместо локального HTTP MCP-сервера (`runMcp`-режим). v8-runner делает выбор автоматически. ### Транспорт и автоопределение `tools.client_mcp.transport`: -- `auto` (по умолчанию) — короткий TCP-probe (200 ms) на хост:порт из `manager_url`. Слышим listener → WS, нет → legacy. +- `auto` (по умолчанию) — короткий TCP-probe (200 ms) на IP:порт из `manager_url`. Слышим listener → WS, нет → MCP. - `ws` — строго WS, при недоступности менеджера запуск падает с `session-manager unreachable at `. -- `legacy` — старый HTTP-режим без probe. +- `mcp` — локальный HTTP MCP-режим без probe. -Override через `--mcp-transport={ws|legacy|auto}`. CLI приоритет конфига. +Override через `--mcp-transport={ws|mcp|auto}`. CLI приоритет конфига. ### Что v8-runner подставляет в `/C` в WS-ветке @@ -187,13 +187,13 @@ WS-ветка: ```json { "transport": "ws", "client_uid": "...", "kind": "...", "manager_url": "...", "corr_id": "..." } ``` -Legacy-ветка: +MCP-ветка: ```json -{ "transport": "legacy", "mcp_port": 9874 } +{ "transport": "mcp", "mcp_port": 9874 } ``` Внешний оркестратор (CI, AI-агент) использует `client_uid` для поиска сессии в `session_list` менеджера. ### Менеджер не запускается из v8-runner -v8-runner только подключается к запущенному менеджеру. Подъём менеджера — отдельный шаг (`cargo run --release` в репо `v8-client-session-manager`, либо systemd-юнит `systemd/v8-session-manager.service`, либо Docker-compose). Если менеджер не нужен — `--mcp-transport=legacy` форсирует старый flow. +v8-runner только подключается к запущенному менеджеру. Подъём менеджера — отдельный шаг (`cargo run --release` в репо `v8-client-session-manager`, либо systemd-юнит `systemd/v8-session-manager.service`, либо Docker-compose). Если менеджер не нужен — `--mcp-transport=mcp` форсирует локальный MCP flow. diff --git a/v8-runner/references/troubleshooting.md b/v8-runner/references/troubleshooting.md index f584b7d..6891ef1 100644 --- a/v8-runner/references/troubleshooting.md +++ b/v8-runner/references/troubleshooting.md @@ -14,7 +14,7 @@ Inspect `v8project.yaml` fields that affect the failing command: - `format` - `builder` - `connection` -- `basePath` +- primary config directory - `workPath` - `source-set` - `tools.platform` @@ -25,6 +25,8 @@ Inspect `v8project.yaml` fields that affect the failing command: Missing 1C platform, EDT CLI, IBCMD, or test runner utilities are environment/setup issues. Report the missing utility and the config fields used for discovery. +`tools download` errors that mention the maximum download size mean the selected GitHub release asset or source archive exceeded the 512 MiB response-body limit. Do not retry with `--force`; pick a smaller artifact or install the tool manually and point `v8project.local.yaml` to that local path. + Stale incremental state after branch switches, rebases, or large source moves usually calls for: ```bash