Auto-build: codex (powershell) from 7409bac

This commit is contained in:
github-actions[bot]
2026-08-24 08:03:25 +00:00
commit 060230c7db
336 changed files with 186226 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"name": "1c-skills",
"version": "2026.8.24+7409bac",
"description": "[PowerShell] Навыки для разработки на 1С:Предприятие 8.3 — абстракции над XML-форматами и CLI конфигуратора, плюс глаза и руки для тестирования через веб-клиент.",
"author": {
"name": "Nikolay Shirokov"
},
"homepage": "https://github.com/Nikolay-Shirokov/cc-1c-skills",
"repository": "https://github.com/Nikolay-Shirokov/cc-1c-skills",
"license": "MIT",
"keywords": [
"1c",
"1c-dev",
"cf",
"cfe",
"epf",
"erf",
"metadata",
"configuration",
"extension",
"form",
"report",
"skd",
"data-processor",
"mxl",
"web-client",
"testing",
"test-automation"
],
"skills": "./.codex/skills/",
"interface": {
"displayName": "1C Skills (PowerShell)",
"shortDescription": "PowerShell runtime (Windows-first)",
"category": "Development"
}
}
+1
View File
@@ -0,0 +1 @@
__pycache__/
+60
View File
@@ -0,0 +1,60 @@
---
name: cf-edit
description: Точечное редактирование конфигурации 1С. Используй когда нужно изменить свойства конфигурации, добавить или удалить объект из состава, настроить роли по умолчанию, поменять раскладку панелей, настроить начальную страницу
argument-hint: -ConfigPath <path> -Operation <op> -Value <value>
allowed-tools:
- Bash
- Read
- Write
- Glob
---
# /cf-edit — редактирование конфигурации 1С
Точечное редактирование Configuration.xml: свойства, состав ChildObjects, роли по умолчанию.
## Параметры и команда
| Параметр | Описание |
|----------|----------|
| `ConfigPath` | Путь к Configuration.xml или каталогу выгрузки |
| `Operation` | Операция (см. таблицу) |
| `Value` | Значение для операции (batch через `;;`) |
| `DefinitionFile` | JSON-файл с массивом операций |
| `NoValidate` | Пропустить авто-валидацию |
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cf-edit/scripts/cf-edit.ps1" -ConfigPath '<path>' -Operation modify-property -Value 'Version=1.0.0.1'
```
## Операции
| Операция | Формат Value | Описание |
|----------|-------------|----------|
| `modify-property` | `Ключ=Значение` (batch `;;`) | Изменить свойство |
| `add-childObject` | `Type.Name` (batch `;;`) | Зарегистрировать уже существующий файл объекта в ChildObjects. Для создания нового объекта используй `/meta-compile`, `/role-compile`, `/subsystem-compile` — они регистрируют автоматически |
| `remove-childObject` | `Type.Name` (batch `;;`) | Удалить объект из ChildObjects |
| `add-defaultRole` | `Role.Name` или `Name` | Добавить роль по умолчанию |
| `remove-defaultRole` | `Role.Name` или `Name` | Удалить роль по умолчанию |
| `set-defaultRoles` | Имена через `;;` | Заменить список ролей по умолчанию |
| `set-panels` | JSON-объект (см. [reference.md](reference.md)) | Перезаписать `Ext/ClientApplicationInterface.xml` (раскладка панелей) |
| `set-home-page` | JSON-объект (см. [reference.md](reference.md)) | Перезаписать `Ext/HomePageWorkArea.xml` (начальная страница) |
Допустимые значения свойств, формат DefinitionFile (JSON), каноничный порядок: [reference.md](reference.md)
## Примеры
```powershell
# Изменить версию и поставщика
... -ConfigPath src -Operation modify-property -Value "Version=1.0.0.1 ;; Vendor=Фирма 1С"
# Добавить объекты
... -ConfigPath src -Operation add-childObject -Value "Catalog.Товары ;; Document.Заказ"
# Удалить объект
... -ConfigPath src -Operation remove-childObject -Value "Catalog.Устаревший"
# Роли по умолчанию
... -ConfigPath src -Operation add-defaultRole -Value "ПолныеПрава"
... -ConfigPath src -Operation set-defaultRoles -Value "ПолныеПрава ;; Администратор"
```
+150
View File
@@ -0,0 +1,150 @@
# cf-edit — справочник операций
## modify-property
Свойства для редактирования:
### Скалярные
`Name`, `Version`, `Vendor`, `Comment`, `NamePrefix`, `UpdateCatalogAddress`
### LocalString (многоязычные)
`Synonym`, `BriefInformation`, `DetailedInformation`, `Copyright`, `VendorInformationAddress`, `ConfigurationInformationAddress`
### Enum
| Свойство | Допустимые значения |
|----------|---------------------|
| `CompatibilityMode` | `Version8_3_20` ... `Version8_3_28`, `Version8_5_1`, `DontUse` |
| `ConfigurationExtensionCompatibilityMode` | то же |
| `DefaultRunMode` | `ManagedApplication`, `OrdinaryApplication`, `Auto` |
| `ScriptVariant` | `Russian`, `English` |
| `DataLockControlMode` | `Managed`, `Automatic`, `AutomaticAndManaged` |
| `ObjectAutonumerationMode` | `NotAutoFree`, `AutoFree` |
| `ModalityUseMode` | `DontUse`, `Use`, `UseWithWarnings` |
| `SynchronousPlatformExtensionAndAddInCallUseMode` | `DontUse`, `Use`, `UseWithWarnings` |
| `InterfaceCompatibilityMode` | `Version8_2`, `Version8_2EnableTaxi`, `Taxi`, `TaxiEnableVersion8_2`, `TaxiEnableVersion8_5`, `Version8_5EnableTaxi`, `Version8_5` |
| `DatabaseTablespacesUseMode` | `DontUse`, `Use` |
| `MainClientApplicationWindowMode` | `Normal`, `Fullscreen`, `Kiosk` |
### Ref
`DefaultLanguage` — значение вида `Language.Русский`
### Формат batch
`"Version=1.0.0.1 ;; Vendor=Фирма 1С ;; Synonym=Тестовая конфигурация"`
## add-childObject / remove-childObject
Формат: `Type.Name` — XML-тип и имя объекта через точку.
**Важно про `add-childObject`**: регистрирует в `<ChildObjects>` объект, **файл которого уже существует на диске**. Если файла нет — exit 1. Для создания нового объекта используй профильный навык — `/meta-compile` (Catalog, Document, Enum, Report, регистры и т.д.), `/role-compile` (Role), `/subsystem-compile` (Subsystem). Они создают файл И регистрируют его за один вызов.
Batch: `"Catalog.Товары ;; Document.Заказ ;; Enum.ВидыОплат"`
## add-defaultRole / remove-defaultRole / set-defaultRoles
Имя роли: `ПолныеПрава` или `Role.ПолныеПрава` (префикс `Role.` добавляется автоматически).
`set-defaultRoles` полностью заменяет список ролей.
## set-panels
Перезаписывает `Ext/ClientApplicationInterface.xml` — раскладку панелей рабочего пространства Taxi. Файл создаётся с нуля; то, что не упомянуто в `value`, отсутствует на экране.
`value` — объект с ключами `top`, `left`, `right`, `bottom`. Каждый ключ — массив записей. Ключ можно опустить (= пустая сторона).
**Запись** — одна из:
- Строка-алиас (одна панель в этом слоте)
- Объект `{"group": [...]}` (стек: панели/подгруппы внутри располагаются друг под другом)
**Алиасы панелей:**
| Алиас | Панель |
|-------|--------|
| `sections` | Панель разделов |
| `open` | Панель открытых |
| `favorites` | Панель избранного |
| `history` | Панель истории |
| `functions` | Панель функций текущего раздела |
**Семантика:**
- Несколько записей в одной стороне → отдельные слоты «рядом» (несколько тегов `<top>`/...)
- `{"group":[...]}` → один тег с `<group>`-обёрткой, элементы внутри идут стеком
**Пример** (DefinitionFile):
```json
[
{
"operation": "set-panels",
"value": {
"top": ["open"],
"left": ["sections"],
"right": [{ "group": ["favorites", "history"] }],
"bottom": ["functions"]
}
}
]
```
Через `-Value` (CLI): передай объект как JSON-строку — `... -Operation set-panels -Value '{"top":["open"]}'`.
## set-home-page
Перезаписывает `Ext/HomePageWorkArea.xml` — раскладка форм на начальной странице (рабочая область). Файл создаётся с нуля; то, что не упомянуто в `value`, отсутствует.
`value` — объект:
| Ключ | Канонич. (XML) | Описание |
|------|----------------|----------|
| `template` | `WorkingAreaTemplate` | `OneColumn` / `TwoColumnsEqualWidth` (дефолт) / `TwoColumnsVariableWidth` |
| `left` | `LeftColumn` | массив записей форм |
| `right` | `RightColumn` | массив записей форм (запрещён при `OneColumn`) |
Принимаются и короткие и канонич. ключи (XML-имена) — оба работают.
**Запись формы** — одна из:
- Строка `"<form>"` — только имя формы, дефолты `height=10`, `visibility=true`
- Объект `{form, height?, visibility?, roles?}`
| Поле | Канонич. | Дефолт | Описание |
|------|----------|--------|----------|
| `form` | `Form` | — | `CommonForm.X` или `Type.Object.Form.Name` (или UUID) |
| `height` | `Height` | `10` | Высота |
| `visibility` | `Visibility` | `true` | Общая видимость (`<xr:Common>`) |
| `roles` | — | — | `{"Role.Имя": true|false, ...}` — переопределения по ролям |
**Семантика visibility:** `visibility` = общее правило, `roles` — точечные исключения. Скрыть для всех кроме одной роли: `{"visibility": false, "roles": {"Role.Опер": true}}`.
**Пример:**
```json
[
{
"operation": "set-home-page",
"value": {
"template": "TwoColumnsVariableWidth",
"left": [
"CommonForm.НачалоРаботы",
{ "form": "CommonForm.СписокЗадач", "height": 100, "visibility": false },
{ "form": "Catalog.Контрагенты.Form.ФормаСписка", "height": 50 },
{
"form": "CommonForm.РабочийСтолОператора",
"visibility": false,
"roles": { "Role.Оператор": true, "Role.ПолныеПрава": false }
}
],
"right": [
{ "form": "DataProcessor.Поиск.Form.ФормаПоиска", "height": 30 }
]
}
}
]
```
## DefinitionFile (JSON)
```json
[
{ "operation": "modify-property", "value": "Version=2.0.0.1 ;; Vendor=Test" },
{ "operation": "add-childObject", "value": "Catalog.Товары ;; Document.Заказ" },
{ "operation": "add-defaultRole", "value": "ПолныеПрава" }
]
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
---
name: cf-info
description: Анализ структуры конфигурации 1С — свойства, состав, счётчики объектов. Используй для обзора конфигурации — какие объекты есть, сколько их, какие настройки
argument-hint: <ConfigPath> [-Mode overview|brief|full] [-Section home-page]
allowed-tools:
- Bash
- Read
- Glob
---
# /cf-info — Структура конфигурации 1С
Читает Configuration.xml из выгрузки конфигурации и выводит компактное описание структуры.
## Параметры и команда
| Параметр | Описание |
|----------|----------|
| `ConfigPath` | Путь к Configuration.xml или каталогу выгрузки |
| `Mode` | Режим: `overview` (default), `brief`, `full` |
| `Section` | Drill-down по разделу (alias: `Name`). Сейчас: `home-page` |
| `Limit` / `Offset` | Пагинация (по умолчанию 150 строк) |
| `OutFile` | Записать результат в файл (UTF-8 BOM) |
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cf-info/scripts/cf-info.ps1" -ConfigPath "<путь>"
```
## Три режима
| Режим | Что показывает |
|---|---|
| `overview` *(default)* | Заголовок + ключевые свойства + таблица счётчиков объектов по типам |
| `brief` | Одна строка: Имя — "Синоним" vВерсия \| N объектов \| совместимость |
| `full` | Все свойства по категориям + полный список ChildObjects + DefaultRoles + мобильные функциональности |
## Примеры
```powershell
# Обзор пустой конфигурации
... -ConfigPath src
# Краткая сводка реальной конфигурации
... -ConfigPath src -Mode brief
# Полная информация
... -ConfigPath src -Mode full
# С пагинацией
... -ConfigPath src -Mode full -Limit 50 -Offset 100
# Drill-down: только начальная страница (раскладка форм с ролями)
... -ConfigPath src -Section home-page
```
+656
View File
@@ -0,0 +1,656 @@
# cf-info v1.7 — Compact summary of 1C configuration root
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$true, Position=0)][Alias('Path')][string]$ConfigPath,
[ValidateSet("overview","brief","full")]
[string]$Mode = "overview",
[Alias('Name')]
[ValidateSet("home-page")]
[string]$Section,
[int]$Limit = 150,
[int]$Offset = 0,
[string]$OutFile
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Output helper (always collect, paginate at the end) ---
$script:lines = @()
function Out([string]$text) { $script:lines += $text }
# --- Resolve path ---
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
$ConfigPath = Join-Path (Get-Location).Path $ConfigPath
}
# Directory -> find Configuration.xml
if (Test-Path $ConfigPath -PathType Container) {
$candidate = Join-Path $ConfigPath "Configuration.xml"
if (Test-Path $candidate) {
$ConfigPath = $candidate
} else {
Write-Host "[ERROR] No Configuration.xml found in directory: $ConfigPath"
exit 1
}
}
if (-not (Test-Path $ConfigPath)) {
Write-Host "[ERROR] File not found: $ConfigPath"
exit 1
}
# --- Load XML ---
[xml]$xmlDoc = Get-Content -Path $ConfigPath -Encoding UTF8
$ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$ns.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$ns.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
$ns.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable")
$ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
$ns.AddNamespace("xs", "http://www.w3.org/2001/XMLSchema")
$ns.AddNamespace("app", "http://v8.1c.ru/8.2/managed-application/core")
$mdRoot = $xmlDoc.SelectSingleNode("/md:MetaDataObject", $ns)
if (-not $mdRoot) {
Write-Host "[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)"
exit 1
}
$cfgNode = $mdRoot.SelectSingleNode("md:Configuration", $ns)
if (-not $cfgNode) {
Write-Host "[ERROR] No <Configuration> element found"
exit 1
}
$version = $mdRoot.GetAttribute("version")
$propsNode = $cfgNode.SelectSingleNode("md:Properties", $ns)
$childObjNode = $cfgNode.SelectSingleNode("md:ChildObjects", $ns)
# --- Helpers ---
function Get-MLText($node) {
if (-not $node) { return "" }
$item = $node.SelectSingleNode("v8:item/v8:content", $ns)
if ($item -and $item.InnerText) { return $item.InnerText }
return ""
}
function Get-PropText([string]$propName) {
$n = $propsNode.SelectSingleNode("md:$propName", $ns)
if ($n -and $n.InnerText) { return $n.InnerText }
return ""
}
function Get-PropML([string]$propName) {
$n = $propsNode.SelectSingleNode("md:$propName", $ns)
return (Get-MLText $n)
}
# --- Type name maps (canonical order, 44 types) ---
$typeOrder = @(
"Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister",
"ChartOfCharacteristicTypes","ChartOfAccounts","AccountingRegister",
"ChartOfCalculationTypes","CalculationRegister",
"BusinessProcess","Task","IntegrationService"
)
$typeRuNames = @{
"Language"="Языки"; "Subsystem"="Подсистемы"; "StyleItem"="Элементы стиля"; "Style"="Стили"
"CommonPicture"="Общие картинки"; "SessionParameter"="Параметры сеанса"; "Role"="Роли"
"CommonTemplate"="Общие макеты"; "FilterCriterion"="Критерии отбора"; "CommonModule"="Общие модули"
"Bot"="Боты"
"CommonAttribute"="Общие реквизиты"; "ExchangePlan"="Планы обмена"; "XDTOPackage"="XDTO-пакеты"
"WebService"="Веб-сервисы"; "HTTPService"="HTTP-сервисы"; "WSReference"="WS-ссылки"
"EventSubscription"="Подписки на события"; "ScheduledJob"="Регламентные задания"
"SettingsStorage"="Хранилища настроек"; "FunctionalOption"="Функциональные опции"
"FunctionalOptionsParameter"="Параметры ФО"; "DefinedType"="Определяемые типы"
"CommonCommand"="Общие команды"; "CommandGroup"="Группы команд"; "Constant"="Константы"
"CommonForm"="Общие формы"; "Catalog"="Справочники"; "Document"="Документы"
"DocumentNumerator"="Нумераторы"; "Sequence"="Последовательности"; "DocumentJournal"="Журналы документов"
"Enum"="Перечисления"; "Report"="Отчёты"; "DataProcessor"="Обработки"
"InformationRegister"="Регистры сведений"; "AccumulationRegister"="Регистры накопления"
"ChartOfCharacteristicTypes"="ПВХ"; "ChartOfAccounts"="Планы счетов"
"AccountingRegister"="Регистры бухгалтерии"; "ChartOfCalculationTypes"="ПВР"
"CalculationRegister"="Регистры расчёта"; "BusinessProcess"="Бизнес-процессы"
"Task"="Задачи"; "IntegrationService"="Сервисы интеграции"
}
# --- Read panel layout (Ext/ClientApplicationInterface.xml) ---
$script:panelNames = @{
"cbab57f2-a0f3-4f0a-89ea-4cb19570ab75" = "Открытых"
"b553047f-c9aa-4157-978d-448ecad24248" = "Разделов"
"13322b22-3960-4d68-93a6-fe2dd7f28ca3" = "Избранного"
"c933ac92-92cd-459d-81cc-e0c8a83ced99" = "История"
"b2735bd3-d822-4430-ba59-c9e869693b24" = "Функций"
}
function Get-PanelsLayout {
$configDir = [System.IO.Path]::GetDirectoryName($ConfigPath)
$caiPath = Join-Path (Join-Path $configDir "Ext") "ClientApplicationInterface.xml"
if (-not (Test-Path $caiPath)) { return $null }
try { [xml]$caiDoc = Get-Content -Path $caiPath -Encoding UTF8 } catch { return $null }
if (-not $caiDoc.DocumentElement) { return $null }
$caiNs = New-Object System.Xml.XmlNamespaceManager($caiDoc.NameTable)
$caiNs.AddNamespace("ca", "http://v8.1c.ru/8.2/managed-application/core")
$layout = [ordered]@{ top=@(); left=@(); right=@(); bottom=@(); declared=@() }
foreach ($side in @("top","left","right","bottom")) {
foreach ($sideEl in $caiDoc.DocumentElement.SelectNodes("ca:$side", $caiNs)) {
$slot = @()
foreach ($u in $sideEl.SelectNodes(".//ca:panel/ca:uuid", $caiNs)) {
$key = $u.InnerText.Trim()
$nm = if ($script:panelNames.Contains($key)) { $script:panelNames[$key] } else { "?$key" }
$slot += $nm
}
if ($slot.Count -gt 0) { $layout[$side] += ,$slot }
}
}
foreach ($pd in $caiDoc.DocumentElement.SelectNodes("ca:panelDef", $caiNs)) {
$key = $pd.GetAttribute("id")
$nm = if ($script:panelNames.Contains($key)) { $script:panelNames[$key] } else { "?$key" }
$layout.declared += $nm
}
return $layout
}
function Format-LayoutSlots($slots) {
# slots is array of arrays (each inner array = one side-tag's panels, may be 1+)
# Single inner array, single panel -> just name
# Single inner array, multiple panels -> "Стек(a, b)"
# Multiple inner arrays -> separate entries joined by " | "
if (-not $slots -or $slots.Count -eq 0) { return "" }
$parts = @()
foreach ($slot in $slots) {
if ($slot.Count -eq 1) { $parts += $slot[0] }
else { $parts += ("Стек(" + ($slot -join ", ") + ")") }
}
return ($parts -join " | ")
}
$script:panelLayout = Get-PanelsLayout
# --- Read home page layout (Ext/HomePageWorkArea.xml) ---
function Get-HomePageLayout {
$configDir = [System.IO.Path]::GetDirectoryName($ConfigPath)
$hpPath = Join-Path (Join-Path $configDir "Ext") "HomePageWorkArea.xml"
if (-not (Test-Path $hpPath)) { return $null }
try { [xml]$hpDoc = Get-Content -Path $hpPath -Encoding UTF8 } catch { return $null }
if (-not $hpDoc.DocumentElement) { return $null }
$hpNs = New-Object System.Xml.XmlNamespaceManager($hpDoc.NameTable)
$hpNs.AddNamespace("hp", "http://v8.1c.ru/8.3/xcf/extrnprops")
$hpNs.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable")
$result = [ordered]@{ template = ""; left = @(); right = @() }
$tmplNode = $hpDoc.DocumentElement.SelectSingleNode("hp:WorkingAreaTemplate", $hpNs)
if ($tmplNode) { $result.template = $tmplNode.InnerText.Trim() }
foreach ($colName in @("LeftColumn","RightColumn")) {
$colNode = $hpDoc.DocumentElement.SelectSingleNode("hp:$colName", $hpNs)
if (-not $colNode) { continue }
$items = @()
foreach ($item in $colNode.SelectNodes("hp:Item", $hpNs)) {
$f = $item.SelectSingleNode("hp:Form", $hpNs)
$h = $item.SelectSingleNode("hp:Height", $hpNs)
$visNode = $item.SelectSingleNode("hp:Visibility", $hpNs)
$common = $true
$roles = @()
if ($visNode) {
$cn = $visNode.SelectSingleNode("xr:Common", $hpNs)
if ($cn) { $common = ($cn.InnerText.Trim() -eq "true") }
foreach ($v in $visNode.SelectNodes("xr:Value", $hpNs)) {
$roles += @{ name = $v.GetAttribute("name"); value = ($v.InnerText.Trim() -eq "true") }
}
}
$items += [ordered]@{
form = if ($f) { $f.InnerText.Trim() } else { "" }
height = if ($h) { [int]$h.InnerText.Trim() } else { 10 }
common = $common
roles = $roles
}
}
if ($colName -eq "LeftColumn") { $result.left = $items } else { $result.right = $items }
}
return $result
}
$script:homePage = Get-HomePageLayout
# --- Support state (Ext/ParentConfigurations.bin) ---
# Decodes the 1C support-state file. See docs/1c-support-state-spec.md.
# Returns $null on absent/error; else hashtable: State='absent'|'removed'|'parsed',
# G (0=editing on, 1=off), K (vendor configs), Vendors @(@{Vendor;Name;Version}),
# Counts @(locked, editable, removed) by f1 — record tally (K>1 counts each
# vendor block separately); only computed when G=0.
function Read-SupportState([string]$binPath) {
try {
if (-not (Test-Path $binPath)) { return @{ State = 'absent' } }
$bytes = [System.IO.File]::ReadAllBytes($binPath)
if ($bytes.Length -le 32) { return @{ State = 'removed' } }
$startIdx = 0
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $startIdx = 3 }
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $startIdx, $bytes.Length - $startIdx)
$h = [regex]::Match($text, '^\{6,(\d+),(\d+),')
if (-not $h.Success) { return $null }
$G = [int]$h.Groups[1].Value
$K = [int]$h.Groups[2].Value
if ($K -eq 0) { return @{ State = 'removed' } }
# Vendor descriptors: ...,"ver","vendor","name",count,
$vendors = @()
$vRe = [regex]'"((?:[^"]|"")*)","((?:[^"]|"")*)","((?:[^"]|"")*)",\d+,'
foreach ($m in $vRe.Matches($text)) {
$vendors += @{
Version = ($m.Groups[1].Value -replace '""','"')
Vendor = ($m.Groups[2].Value -replace '""','"')
Name = ($m.Groups[3].Value -replace '""','"')
}
}
# Per-object counts only matter when editing is enabled (G=0); when G=1 the
# whole config is read-only and stored f1 values are the inactive default.
$counts = $null
if ($G -eq 0) {
$counts = @(0, 0, 0)
# Object records: f1,0,uuidLocal[,uuidVendor] — flags precede the uuid.
$rRe = [regex]'([0-2]),0,[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
foreach ($m in $rRe.Matches($text)) {
$counts[[int]$m.Groups[1].Value]++
}
}
return @{ State = 'parsed'; G = $G; K = $K; Vendors = $vendors; Counts = $counts }
} catch { return $null }
}
function Get-SupportLines {
$configDir = [System.IO.Path]::GetDirectoryName($ConfigPath)
$binPath = Join-Path (Join-Path $configDir "Ext") "ParentConfigurations.bin"
$st = Read-SupportState $binPath
$out = @()
if (-not $st -or $st.State -eq 'absent') {
if ($cfgExtPurpose) { $out += "Поддержка: расширение (CFE), правки свободны" }
else { $out += "Поддержка: не на поддержке (своя конфигурация)" }
return $out
}
if ($st.State -eq 'removed') {
$out += "Поддержка: снята с поддержки полностью"
return $out
}
$out += "Поддержка: на поддержке"
if ($st.G -eq 0) {
$out += " Возможность изменения: включена"
$out += " Объектов: на замке $($st.Counts[0]) / редактируется $($st.Counts[1]) / снято $($st.Counts[2])"
} else {
$out += " Возможность изменения: выключена — вся конфигурация read-only (правки заблокированы)"
}
$out += " Конфигураций поставщика: $($st.K)"
if ($st.K -gt 1) {
foreach ($v in $st.Vendors) { $out += " Поставщик: $($v.Vendor)$($v.Name) $($v.Version)" }
}
return $out
}
function Format-HomePageItem($it, [bool]$detailed) {
$badges = @()
$badges += "h=$($it.height)"
if (-not $it.common) { $badges += "скрыта" }
if ($it.roles.Count -gt 0) {
if ($detailed) { $badges += "роли: $($it.roles.Count)" }
else { $badges += "+$($it.roles.Count) ролей" }
}
$tail = if ($badges.Count -gt 0) { " (" + ($badges -join ", ") + ")" } else { "" }
return " $($it.form)$tail"
}
# --- Count objects in ChildObjects ---
$objectCounts = [ordered]@{}
$totalObjects = 0
if ($childObjNode) {
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
$typeName = $child.LocalName
if (-not $objectCounts.Contains($typeName)) {
$objectCounts[$typeName] = 0
}
$objectCounts[$typeName] = $objectCounts[$typeName] + 1
$totalObjects++
}
}
# --- Read key properties ---
$cfgName = Get-PropText "Name"
$cfgSynonym = Get-PropML "Synonym"
$cfgVersion = Get-PropText "Version"
$cfgVendor = Get-PropText "Vendor"
$cfgCompat = Get-PropText "CompatibilityMode"
$cfgExtCompat = Get-PropText "ConfigurationExtensionCompatibilityMode"
$cfgExtPurpose = Get-PropText "ConfigurationExtensionPurpose"
$cfgDefaultRun = Get-PropText "DefaultRunMode"
$cfgScript = Get-PropText "ScriptVariant"
$cfgDefaultLang = Get-PropText "DefaultLanguage"
$cfgDataLock = Get-PropText "DataLockControlMode"
$dash = [char]0x2014
$cfgModality = Get-PropText "ModalityUseMode"
$cfgIntfCompat = Get-PropText "InterfaceCompatibilityMode"
$cfgAutoNum = Get-PropText "ObjectAutonumerationMode"
$cfgSyncCalls = Get-PropText "SynchronousPlatformExtensionAndAddInCallUseMode"
$cfgDbSpaces = Get-PropText "DatabaseTablespacesUseMode"
$cfgWindowMode = Get-PropText "MainClientApplicationWindowMode"
# --- BRIEF mode ---
if ($Mode -eq "brief" -and -not $Section) {
$synPart = if ($cfgSynonym) { " $dash `"$cfgSynonym`"" } else { "" }
$verPart = if ($cfgVersion) { " v$cfgVersion" } else { "" }
$compatPart = if ($cfgCompat) { " | $cfgCompat" } else { "" }
Out "Конфигурация: ${cfgName}${synPart}${verPart} | $totalObjects объектов${compatPart}"
}
# --- OVERVIEW mode ---
if ($Mode -eq "overview" -and -not $Section) {
$synPart = if ($cfgSynonym) { " $dash `"$cfgSynonym`"" } else { "" }
$verPart = if ($cfgVersion) { " v$cfgVersion" } else { "" }
Out "=== Конфигурация: ${cfgName}${synPart}${verPart} ==="
Out ""
# Key properties
Out "Формат: $version"
if ($cfgVendor) { Out "Поставщик: $cfgVendor" }
if ($cfgVersion) { Out "Версия: $cfgVersion" }
foreach ($l in (Get-SupportLines)) { Out $l }
Out "Совместимость: $cfgCompat"
Out "Режим запуска: $cfgDefaultRun"
Out "Язык скриптов: $cfgScript"
Out "Язык: $cfgDefaultLang"
Out "Блокировки: $cfgDataLock"
Out "Модальность: $cfgModality"
Out "Интерфейс: $cfgIntfCompat"
Out ""
# Panel layout (if file exists)
if ($script:panelLayout) {
$hasPlaced = $false
foreach ($s in @("top","left","right","bottom")) {
if ($script:panelLayout[$s].Count -gt 0) { $hasPlaced = $true; break }
}
if ($hasPlaced) {
Out "--- Раскладка панелей ---"
foreach ($s in @("top","left","right","bottom")) {
if ($script:panelLayout[$s].Count -gt 0) {
Out " $($s.PadRight(7)) $(Format-LayoutSlots $script:panelLayout[$s])"
}
}
Out ""
}
}
# Home page layout (brief summary)
if ($script:homePage) {
$ln = $script:homePage.left.Count
$rn = $script:homePage.right.Count
Out "--- Начальная страница ---"
Out " Шаблон: $($script:homePage.template)"
Out " LeftColumn: $ln, RightColumn: $rn (детали: -Section home-page)"
Out ""
}
# Object counts table
Out "--- Состав ($totalObjects объектов) ---"
Out ""
$maxTypeLen = 0
foreach ($typeName in $typeOrder) {
if ($objectCounts.Contains($typeName)) {
$ruName = $typeRuNames[$typeName]
if ($ruName.Length -gt $maxTypeLen) { $maxTypeLen = $ruName.Length }
}
}
if ($maxTypeLen -lt 10) { $maxTypeLen = 10 }
foreach ($typeName in $typeOrder) {
if ($objectCounts.Contains($typeName)) {
$count = $objectCounts[$typeName]
$ruName = $typeRuNames[$typeName]
$padded = $ruName.PadRight($maxTypeLen)
Out " $padded $count"
}
}
}
# --- Drill-down: -Section home-page ---
if ($Section -eq "home-page") {
if (-not $script:homePage) {
Out "Файл Ext/HomePageWorkArea.xml не найден"
} else {
Out "=== Начальная страница: $cfgName ==="
Out ""
Out "Шаблон: $($script:homePage.template)"
Out ""
foreach ($side in @(@("LeftColumn","left"), @("RightColumn","right"))) {
$items = $script:homePage[$side[1]]
$lbl = $side[0]
if ($items.Count -eq 0) { Out "${lbl}: —"; Out ""; continue }
Out "${lbl} ($($items.Count)):"
foreach ($it in $items) {
Out (Format-HomePageItem $it $true)
foreach ($r in $it.roles) {
$rval = if ($r.value) { "true" } else { "false" }
Out " $($r.name): $rval"
}
}
Out ""
}
}
}
# --- FULL mode ---
if ($Mode -eq "full" -and -not $Section) {
$synPart = if ($cfgSynonym) { " $dash `"$cfgSynonym`"" } else { "" }
$verPart = if ($cfgVersion) { " v$cfgVersion" } else { "" }
Out "=== Конфигурация: ${cfgName}${synPart}${verPart} ==="
Out ""
# --- Section: Identification ---
Out "--- Идентификация ---"
Out "UUID: $($cfgNode.GetAttribute('uuid'))"
Out "Имя: $cfgName"
if ($cfgSynonym) { Out "Синоним: $cfgSynonym" }
$cfgComment = Get-PropText "Comment"
if ($cfgComment) { Out "Комментарий: $cfgComment" }
$cfgPrefix = Get-PropText "NamePrefix"
if ($cfgPrefix) { Out "Префикс: $cfgPrefix" }
if ($cfgVendor) { Out "Поставщик: $cfgVendor" }
if ($cfgVersion) { Out "Версия: $cfgVersion" }
foreach ($l in (Get-SupportLines)) { Out $l }
$cfgUpdateAddr = Get-PropText "UpdateCatalogAddress"
if ($cfgUpdateAddr) { Out "Каталог обн.: $cfgUpdateAddr" }
Out ""
# --- Section: Modes ---
Out "--- Режимы работы ---"
Out "Формат: $version"
Out "Совместимость: $cfgCompat"
Out "Совм. расширений: $cfgExtCompat"
Out "Режим запуска: $cfgDefaultRun"
Out "Язык скриптов: $cfgScript"
Out "Блокировки: $cfgDataLock"
Out "Автонумерация: $cfgAutoNum"
Out "Модальность: $cfgModality"
Out "Синхр. вызовы: $cfgSyncCalls"
Out "Интерфейс: $cfgIntfCompat"
Out "Табл. пространства: $cfgDbSpaces"
Out "Режим окна: $cfgWindowMode"
Out ""
# --- Section: Language, roles, purposes ---
Out "--- Назначение ---"
Out "Язык по умолч.: $cfgDefaultLang"
# UsePurposes
$purposeNode = $propsNode.SelectSingleNode("md:UsePurposes", $ns)
if ($purposeNode) {
$purposes = @()
foreach ($val in $purposeNode.SelectNodes("v8:Value", $ns)) {
$purposes += $val.InnerText
}
if ($purposes.Count -gt 0) { Out "Назначения: $($purposes -join ', ')" }
}
# DefaultRoles
$rolesNode = $propsNode.SelectSingleNode("md:DefaultRoles", $ns)
if ($rolesNode) {
$roles = @()
foreach ($item in $rolesNode.SelectNodes("xr:Item", $ns)) {
$roles += $item.InnerText
}
if ($roles.Count -gt 0) {
Out "Роли по умолч.: $($roles.Count)"
foreach ($r in $roles) { Out " - $r" }
}
}
# Booleans
$useMF = Get-PropText "UseManagedFormInOrdinaryApplication"
$useOF = Get-PropText "UseOrdinaryFormInManagedApplication"
Out "Управл.формы в обычн.: $useMF"
Out "Обычн.формы в управл.: $useOF"
Out ""
# --- Section: Panel layout ---
if ($script:panelLayout) {
Out "--- Раскладка панелей ---"
foreach ($s in @("top","left","right","bottom")) {
$slots = $script:panelLayout[$s]
if ($slots.Count -gt 0) {
Out " $($s.PadRight(7)) $(Format-LayoutSlots $slots)"
} else {
Out " $($s.PadRight(7))"
}
}
if ($script:panelLayout.declared.Count -gt 0) {
Out " объявлено: $($script:panelLayout.declared -join ', ')"
}
Out ""
}
# --- Section: Home page (brief summary) ---
if ($script:homePage) {
$ln = $script:homePage.left.Count
$rn = $script:homePage.right.Count
Out "--- Начальная страница ---"
Out " Шаблон: $($script:homePage.template)"
Out " LeftColumn: $ln, RightColumn: $rn (детали: -Section home-page)"
Out ""
}
# --- Section: Storages & default forms ---
Out "--- Хранилища и формы по умолчанию ---"
$storageProps = @("CommonSettingsStorage","ReportsUserSettingsStorage","ReportsVariantsStorage","FormDataSettingsStorage","DynamicListsUserSettingsStorage","URLExternalDataStorage")
foreach ($sp in $storageProps) {
$val = Get-PropText $sp
if ($val) { Out " ${sp}: $val" }
}
$formProps = @("DefaultReportForm","DefaultReportVariantForm","DefaultReportSettingsForm","DefaultReportAppearanceTemplate","DefaultDynamicListSettingsForm","DefaultSearchForm","DefaultDataHistoryChangeHistoryForm","DefaultDataHistoryVersionDataForm","DefaultDataHistoryVersionDifferencesForm","DefaultCollaborationSystemUsersChoiceForm","DefaultConstantsForm","DefaultInterface","DefaultStyle")
foreach ($fp in $formProps) {
$val = Get-PropText $fp
if ($val) { Out " ${fp}: $val" }
}
Out ""
# --- Section: Info ---
$cfgBrief = Get-PropML "BriefInformation"
$cfgDetail = Get-PropML "DetailedInformation"
$cfgCopyright = Get-PropML "Copyright"
$cfgVendorAddr = Get-PropML "VendorInformationAddress"
$cfgInfoAddr = Get-PropML "ConfigurationInformationAddress"
if ($cfgBrief -or $cfgDetail -or $cfgCopyright -or $cfgVendorAddr -or $cfgInfoAddr) {
Out "--- Информация ---"
if ($cfgBrief) { Out "Краткая: $cfgBrief" }
if ($cfgDetail) { Out "Подробная: $cfgDetail" }
if ($cfgCopyright) { Out "Copyright: $cfgCopyright" }
if ($cfgVendorAddr) { Out "Сайт поставщика: $cfgVendorAddr" }
if ($cfgInfoAddr) { Out "Адрес информ.: $cfgInfoAddr" }
Out ""
}
# --- Section: Mobile functionalities ---
$mobileFunc = $propsNode.SelectSingleNode("md:UsedMobileApplicationFunctionalities", $ns)
if ($mobileFunc) {
$enabledFuncs = @()
$disabledFuncs = @()
foreach ($func in $mobileFunc.SelectNodes("app:functionality", $ns)) {
$fName = $func.SelectSingleNode("app:functionality", $ns)
$fUse = $func.SelectSingleNode("app:use", $ns)
if ($fName -and $fUse) {
if ($fUse.InnerText -eq "true") {
$enabledFuncs += $fName.InnerText
} else {
$disabledFuncs += $fName.InnerText
}
}
}
$totalFunc = $enabledFuncs.Count + $disabledFuncs.Count
Out "--- Мобильные функциональности ($totalFunc, включено: $($enabledFuncs.Count)) ---"
if ($enabledFuncs.Count -gt 0) {
foreach ($f in $enabledFuncs) { Out " [+] $f" }
}
foreach ($f in $disabledFuncs) { Out " [-] $f" }
Out ""
}
# --- Section: InternalInfo ---
$internalInfo = $cfgNode.SelectSingleNode("md:InternalInfo", $ns)
if ($internalInfo) {
$contained = $internalInfo.SelectNodes("xr:ContainedObject", $ns)
Out "--- InternalInfo ($($contained.Count) ContainedObject) ---"
foreach ($co in $contained) {
$classId = $co.SelectSingleNode("xr:ClassId", $ns).InnerText
$objectId = $co.SelectSingleNode("xr:ObjectId", $ns).InnerText
Out " $classId -> $objectId"
}
Out ""
}
# --- Section: ChildObjects (full list) ---
Out "--- Состав ($totalObjects объектов) ---"
Out ""
foreach ($typeName in $typeOrder) {
if (-not $objectCounts.Contains($typeName)) { continue }
$count = $objectCounts[$typeName]
$ruName = $typeRuNames[$typeName]
Out " $ruName ($typeName): $count"
# Collect names for this type
$names = @()
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq $typeName) {
$names += $child.InnerText
}
}
foreach ($n in $names) { Out " $n" }
}
}
# --- Pagination and output ---
$total = $script:lines.Count
if ($Offset -gt 0 -or $Limit -lt $total) {
$start = [Math]::Min($Offset, $total)
$end = [Math]::Min($start + $Limit, $total)
$page = $script:lines[$start..($end - 1)]
$result = ($page -join "`n")
if ($end -lt $total) {
$result += "`n`n... ($end of $total lines, use -Offset $end to continue)"
}
} else {
$result = ($script:lines -join "`n")
}
Write-Host $result
if ($OutFile) {
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($OutFile, $result, $utf8Bom)
Write-Host "`nWritten to: $OutFile"
}
+656
View File
@@ -0,0 +1,656 @@
#!/usr/bin/env python3
# cf-info v1.7 — Compact summary of 1C configuration root
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
import re
import sys
from collections import OrderedDict
from lxml import etree
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
# --- Argument parsing ---
parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False)
parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory")
parser.add_argument("-Mode", choices=["overview", "brief", "full"], default="overview", help="Output mode")
parser.add_argument("-Section", "-Name", choices=["home-page"], default=None, help="Drill-down section (alias: -Name)")
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
parser.add_argument("-Offset", type=int, default=0, help="Lines to skip")
parser.add_argument("-OutFile", default="", help="Write output to file")
args = ci_parse_args(parser)
# --- Output helper (collect all, paginate at the end) ---
lines_buf = []
def out(text=""):
lines_buf.append(text)
# --- Resolve path ---
config_path = args.ConfigPath
if not os.path.isabs(config_path):
config_path = os.path.join(os.getcwd(), config_path)
# Directory -> find Configuration.xml
if os.path.isdir(config_path):
candidate = os.path.join(config_path, "Configuration.xml")
if os.path.isfile(candidate):
config_path = candidate
else:
print(f"[ERROR] No Configuration.xml found in directory: {config_path}")
sys.exit(1)
if not os.path.isfile(config_path):
print(f"[ERROR] File not found: {config_path}")
sys.exit(1)
# --- Load XML ---
tree = etree.parse(config_path, etree.XMLParser(remove_blank_text=False))
xml_root = tree.getroot()
NS = {
"md": "http://v8.1c.ru/8.3/MDClasses",
"v8": "http://v8.1c.ru/8.1/data/core",
"xr": "http://v8.1c.ru/8.3/xcf/readable",
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
"xs": "http://www.w3.org/2001/XMLSchema",
"app": "http://v8.1c.ru/8.2/managed-application/core",
}
md_root = xml_root # root is MetaDataObject itself
if etree.QName(md_root.tag).localname != "MetaDataObject":
print("[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)")
sys.exit(1)
cfg_node = md_root.find("md:Configuration", NS)
if cfg_node is None:
print("[ERROR] No <Configuration> element found")
sys.exit(1)
version = md_root.get("version", "")
props_node = cfg_node.find("md:Properties", NS)
child_obj_node = cfg_node.find("md:ChildObjects", NS)
# --- Helpers ---
def get_ml_text(node):
if node is None:
return ""
item = node.find("v8:item/v8:content", NS)
if item is not None and item.text:
return item.text
return ""
def get_prop_text(prop_name):
n = props_node.find(f"md:{prop_name}", NS)
if n is not None and n.text:
return n.text
return ""
def get_prop_ml(prop_name):
n = props_node.find(f"md:{prop_name}", NS)
return get_ml_text(n)
# --- Type name maps (canonical order, 44 types) ---
type_order = [
"Language", "Subsystem", "StyleItem", "Style",
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan",
"XDTOPackage", "WebService", "HTTPService", "WSReference",
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
"Constant", "CommonForm", "Catalog", "Document",
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
"ChartOfCharacteristicTypes", "ChartOfAccounts", "AccountingRegister",
"ChartOfCalculationTypes", "CalculationRegister",
"BusinessProcess", "Task", "IntegrationService",
]
type_ru_names = {
"Language": "Языки", "Subsystem": "Подсистемы", "StyleItem": "Элементы стиля", "Style": "Стили",
"CommonPicture": "Общие картинки", "SessionParameter": "Параметры сеанса", "Role": "Роли",
"CommonTemplate": "Общие макеты", "FilterCriterion": "Критерии отбора", "CommonModule": "Общие модули",
"Bot": "Боты",
"CommonAttribute": "Общие реквизиты", "ExchangePlan": "Планы обмена", "XDTOPackage": "XDTO-пакеты",
"WebService": "Веб-сервисы", "HTTPService": "HTTP-сервисы", "WSReference": "WS-ссылки",
"EventSubscription": "Подписки на события", "ScheduledJob": "Регламентные задания",
"SettingsStorage": "Хранилища настроек", "FunctionalOption": "Функциональные опции",
"FunctionalOptionsParameter": "Параметры ФО", "DefinedType": "Определяемые типы",
"CommonCommand": "Общие команды", "CommandGroup": "Группы команд", "Constant": "Константы",
"CommonForm": "Общие формы", "Catalog": "Справочники", "Document": "Документы",
"DocumentNumerator": "Нумераторы", "Sequence": "Последовательности", "DocumentJournal": "Журналы документов",
"Enum": "Перечисления", "Report": "Отчёты", "DataProcessor": "Обработки",
"InformationRegister": "Регистры сведений", "AccumulationRegister": "Регистры накопления",
"ChartOfCharacteristicTypes": "ПВХ", "ChartOfAccounts": "Планы счетов",
"AccountingRegister": "Регистры бухгалтерии", "ChartOfCalculationTypes": "ПВР",
"CalculationRegister": "Регистры расчёта", "BusinessProcess": "Бизнес-процессы",
"Task": "Задачи", "IntegrationService": "Сервисы интеграции",
}
# --- Read panel layout (Ext/ClientApplicationInterface.xml) ---
PANEL_NAMES = {
"cbab57f2-a0f3-4f0a-89ea-4cb19570ab75": "Открытых",
"b553047f-c9aa-4157-978d-448ecad24248": "Разделов",
"13322b22-3960-4d68-93a6-fe2dd7f28ca3": "Избранного",
"c933ac92-92cd-459d-81cc-e0c8a83ced99": "История",
"b2735bd3-d822-4430-ba59-c9e869693b24": "Функций",
}
CAI_NS = "http://v8.1c.ru/8.2/managed-application/core"
def get_panels_layout():
cfg_dir = os.path.dirname(config_path)
cai_path = os.path.join(cfg_dir, "Ext", "ClientApplicationInterface.xml")
if not os.path.isfile(cai_path):
return None
try:
cai_tree = etree.parse(cai_path)
except Exception:
return None
cai_root = cai_tree.getroot()
layout = {"top": [], "left": [], "right": [], "bottom": [], "declared": []}
for side in ("top", "left", "right", "bottom"):
for side_el in cai_root.findall(f"{{{CAI_NS}}}{side}"):
slot = []
for u in side_el.iter(f"{{{CAI_NS}}}uuid"):
key = (u.text or "").strip()
slot.append(PANEL_NAMES.get(key, f"?{key}"))
if slot:
layout[side].append(slot)
for pd in cai_root.findall(f"{{{CAI_NS}}}panelDef"):
key = pd.get("id", "")
layout["declared"].append(PANEL_NAMES.get(key, f"?{key}"))
return layout
def format_layout_slots(slots):
if not slots:
return ""
parts = []
for slot in slots:
if len(slot) == 1:
parts.append(slot[0])
else:
parts.append("Стек(" + ", ".join(slot) + ")")
return " | ".join(parts)
panel_layout = get_panels_layout()
# --- Read home page layout (Ext/HomePageWorkArea.xml) ---
HP_NS = "http://v8.1c.ru/8.3/xcf/extrnprops"
XR_NS_HP = "http://v8.1c.ru/8.3/xcf/readable"
def get_home_page_layout():
cfg_dir = os.path.dirname(config_path)
hp_path = os.path.join(cfg_dir, "Ext", "HomePageWorkArea.xml")
if not os.path.isfile(hp_path):
return None
try:
hp_tree = etree.parse(hp_path)
except Exception:
return None
hp_root = hp_tree.getroot()
result = {"template": "", "left": [], "right": []}
tn = hp_root.find(f"{{{HP_NS}}}WorkingAreaTemplate")
if tn is not None and tn.text:
result["template"] = tn.text.strip()
for col_name, key in (("LeftColumn", "left"), ("RightColumn", "right")):
col = hp_root.find(f"{{{HP_NS}}}{col_name}")
if col is None:
continue
items = []
for it in col.findall(f"{{{HP_NS}}}Item"):
f = it.find(f"{{{HP_NS}}}Form")
h = it.find(f"{{{HP_NS}}}Height")
vis = it.find(f"{{{HP_NS}}}Visibility")
common = True
roles = []
if vis is not None:
cn = vis.find(f"{{{XR_NS_HP}}}Common")
if cn is not None and cn.text:
common = cn.text.strip() == "true"
for v in vis.findall(f"{{{XR_NS_HP}}}Value"):
roles.append({"name": v.get("name", ""), "value": (v.text or "").strip() == "true"})
items.append({
"form": (f.text or "").strip() if f is not None else "",
"height": int((h.text or "10").strip()) if h is not None else 10,
"common": common,
"roles": roles,
})
result[key] = items
return result
home_page = get_home_page_layout()
# --- Support state (Ext/ParentConfigurations.bin) ---
# Decodes the 1C support-state file. See docs/1c-support-state-spec.md.
# Returns None on absent/error; else dict: state='absent'|'removed'|'parsed',
# g (0=editing on, 1=off), k (vendor configs), vendors [{vendor,name,version}],
# counts [locked, editable, removed] by f1 — record tally (k>1 counts each
# vendor block separately); only computed when g==0.
def read_support_state(bin_path):
try:
if not os.path.isfile(bin_path):
return {"state": "absent"}
data = open(bin_path, "rb").read()
if len(data) <= 32:
return {"state": "removed"}
if data[:3] == b"\xef\xbb\xbf":
data = data[3:]
text = data.decode("utf-8", "replace")
h = re.match(r"\{6,(\d+),(\d+),", text)
if not h:
return None
g = int(h.group(1))
k = int(h.group(2))
if k == 0:
return {"state": "removed"}
vendors = []
for m in re.finditer(r'"((?:[^"]|"")*)","((?:[^"]|"")*)","((?:[^"]|"")*)",\d+,', text):
vendors.append({
"version": m.group(1).replace('""', '"'),
"vendor": m.group(2).replace('""', '"'),
"name": m.group(3).replace('""', '"'),
})
counts = None
if g == 0:
counts = [0, 0, 0]
for m in re.finditer(r"([0-2]),0,[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", text):
counts[int(m.group(1))] += 1
return {"state": "parsed", "g": g, "k": k, "vendors": vendors, "counts": counts}
except Exception:
return None
def get_support_lines():
config_dir = os.path.dirname(config_path)
bin_path = os.path.join(config_dir, "Ext", "ParentConfigurations.bin")
st = read_support_state(bin_path)
res = []
if not st or st["state"] == "absent":
if cfg_ext_purpose:
res.append("Поддержка: расширение (CFE), правки свободны")
else:
res.append("Поддержка: не на поддержке (своя конфигурация)")
return res
if st["state"] == "removed":
res.append("Поддержка: снята с поддержки полностью")
return res
res.append("Поддержка: на поддержке")
if st["g"] == 0:
res.append(" Возможность изменения: включена")
res.append(f" Объектов: на замке {st['counts'][0]} / редактируется {st['counts'][1]} / снято {st['counts'][2]}")
else:
res.append(" Возможность изменения: выключена — вся конфигурация read-only (правки заблокированы)")
res.append(f" Конфигураций поставщика: {st['k']}")
if st["k"] > 1:
for v in st["vendors"]:
res.append(f" Поставщик: {v['vendor']}{v['name']} {v['version']}")
return res
def format_home_page_item(it, detailed):
badges = [f"h={it['height']}"]
if not it["common"]:
badges.append("скрыта")
if it["roles"]:
badges.append(f"роли: {len(it['roles'])}" if detailed else f"+{len(it['roles'])} ролей")
tail = f" ({', '.join(badges)})" if badges else ""
return f" {it['form']}{tail}"
# --- Count objects in ChildObjects ---
object_counts = OrderedDict()
total_objects = 0
if child_obj_node is not None:
for child in child_obj_node:
if not isinstance(child.tag, str):
continue # skip comments/PIs
type_name = etree.QName(child.tag).localname
if type_name not in object_counts:
object_counts[type_name] = 0
object_counts[type_name] += 1
total_objects += 1
# --- Read key properties ---
cfg_name = get_prop_text("Name")
cfg_synonym = get_prop_ml("Synonym")
cfg_version = get_prop_text("Version")
cfg_vendor = get_prop_text("Vendor")
cfg_compat = get_prop_text("CompatibilityMode")
cfg_ext_compat = get_prop_text("ConfigurationExtensionCompatibilityMode")
cfg_ext_purpose = get_prop_text("ConfigurationExtensionPurpose")
cfg_default_run = get_prop_text("DefaultRunMode")
cfg_script = get_prop_text("ScriptVariant")
cfg_default_lang = get_prop_text("DefaultLanguage")
cfg_data_lock = get_prop_text("DataLockControlMode")
dash = "\u2014"
cfg_modality = get_prop_text("ModalityUseMode")
cfg_intf_compat = get_prop_text("InterfaceCompatibilityMode")
cfg_auto_num = get_prop_text("ObjectAutonumerationMode")
cfg_sync_calls = get_prop_text("SynchronousPlatformExtensionAndAddInCallUseMode")
cfg_db_spaces = get_prop_text("DatabaseTablespacesUseMode")
cfg_window_mode = get_prop_text("MainClientApplicationWindowMode")
# --- BRIEF mode ---
if args.Mode == "brief" and not args.Section:
syn_part = f' {dash} "{cfg_synonym}"' if cfg_synonym else ""
ver_part = f" v{cfg_version}" if cfg_version else ""
compat_part = f" | {cfg_compat}" if cfg_compat else ""
out(f"Конфигурация: {cfg_name}{syn_part}{ver_part} | {total_objects} объектов{compat_part}")
# --- OVERVIEW mode ---
if args.Mode == "overview" and not args.Section:
syn_part = f' {dash} "{cfg_synonym}"' if cfg_synonym else ""
ver_part = f" v{cfg_version}" if cfg_version else ""
out(f"=== Конфигурация: {cfg_name}{syn_part}{ver_part} ===")
out()
# Key properties
out(f"Формат: {version}")
if cfg_vendor:
out(f"Поставщик: {cfg_vendor}")
if cfg_version:
out(f"Версия: {cfg_version}")
for ln in get_support_lines():
out(ln)
out(f"Совместимость: {cfg_compat}")
out(f"Режим запуска: {cfg_default_run}")
out(f"Язык скриптов: {cfg_script}")
out(f"Язык: {cfg_default_lang}")
out(f"Блокировки: {cfg_data_lock}")
out(f"Модальность: {cfg_modality}")
out(f"Интерфейс: {cfg_intf_compat}")
out()
if panel_layout and any(panel_layout[s] for s in ("top", "left", "right", "bottom")):
out("--- Раскладка панелей ---")
for s in ("top", "left", "right", "bottom"):
if panel_layout[s]:
out(f" {s.ljust(7)} {format_layout_slots(panel_layout[s])}")
out()
# Home page (brief summary)
if home_page:
out("--- Начальная страница ---")
out(f" Шаблон: {home_page['template']}")
out(f" LeftColumn: {len(home_page['left'])}, RightColumn: {len(home_page['right'])} (детали: -Section home-page)")
out()
# Object counts table
out(f"--- Состав ({total_objects} объектов) ---")
out()
max_type_len = 0
for type_name in type_order:
if type_name in object_counts:
ru_name = type_ru_names.get(type_name, type_name)
if len(ru_name) > max_type_len:
max_type_len = len(ru_name)
if max_type_len < 10:
max_type_len = 10
for type_name in type_order:
if type_name in object_counts:
count = object_counts[type_name]
ru_name = type_ru_names.get(type_name, type_name)
padded = ru_name.ljust(max_type_len)
out(f" {padded} {count}")
# --- FULL mode ---
# --- Drill-down: -Section home-page ---
if args.Section == "home-page":
if not home_page:
out("Файл Ext/HomePageWorkArea.xml не найден")
else:
out(f"=== Начальная страница: {cfg_name} ===")
out()
out(f"Шаблон: {home_page['template']}")
out()
for col_lbl, col_key in (("LeftColumn", "left"), ("RightColumn", "right")):
items = home_page[col_key]
if not items:
out(f"{col_lbl}: —")
out()
continue
out(f"{col_lbl} ({len(items)}):")
for it in items:
out(format_home_page_item(it, True))
for r in it["roles"]:
rval = "true" if r["value"] else "false"
out(f" {r['name']}: {rval}")
out()
if args.Mode == "full" and not args.Section:
syn_part = f' {dash} "{cfg_synonym}"' if cfg_synonym else ""
ver_part = f" v{cfg_version}" if cfg_version else ""
out(f"=== Конфигурация: {cfg_name}{syn_part}{ver_part} ===")
out()
# --- Section: Identification ---
out("--- Идентификация ---")
out(f"UUID: {cfg_node.get('uuid', '')}")
out(f"Имя: {cfg_name}")
if cfg_synonym:
out(f"Синоним: {cfg_synonym}")
cfg_comment = get_prop_text("Comment")
if cfg_comment:
out(f"Комментарий: {cfg_comment}")
cfg_prefix = get_prop_text("NamePrefix")
if cfg_prefix:
out(f"Префикс: {cfg_prefix}")
if cfg_vendor:
out(f"Поставщик: {cfg_vendor}")
if cfg_version:
out(f"Версия: {cfg_version}")
for ln in get_support_lines():
out(ln)
cfg_update_addr = get_prop_text("UpdateCatalogAddress")
if cfg_update_addr:
out(f"Каталог обн.: {cfg_update_addr}")
out()
# --- Section: Modes ---
out("--- Режимы работы ---")
out(f"Формат: {version}")
out(f"Совместимость: {cfg_compat}")
out(f"Совм. расширений: {cfg_ext_compat}")
out(f"Режим запуска: {cfg_default_run}")
out(f"Язык скриптов: {cfg_script}")
out(f"Блокировки: {cfg_data_lock}")
out(f"Автонумерация: {cfg_auto_num}")
out(f"Модальность: {cfg_modality}")
out(f"Синхр. вызовы: {cfg_sync_calls}")
out(f"Интерфейс: {cfg_intf_compat}")
out(f"Табл. пространства: {cfg_db_spaces}")
out(f"Режим окна: {cfg_window_mode}")
out()
# --- Section: Language, roles, purposes ---
out("--- Назначение ---")
out(f"Язык по умолч.: {cfg_default_lang}")
# UsePurposes
purpose_node = props_node.find("md:UsePurposes", NS)
if purpose_node is not None:
purposes = []
for val in purpose_node.findall("v8:Value", NS):
if val.text:
purposes.append(val.text)
if purposes:
out(f"Назначения: {', '.join(purposes)}")
# DefaultRoles
roles_node = props_node.find("md:DefaultRoles", NS)
if roles_node is not None:
roles = []
for item in roles_node.findall("xr:Item", NS):
if item.text:
roles.append(item.text)
if roles:
out(f"Роли по умолч.: {len(roles)}")
for r in roles:
out(f" - {r}")
# Booleans
use_mf = get_prop_text("UseManagedFormInOrdinaryApplication")
use_of = get_prop_text("UseOrdinaryFormInManagedApplication")
out(f"Управл.формы в обычн.: {use_mf}")
out(f"Обычн.формы в управл.: {use_of}")
out()
# --- Section: Panel layout ---
if panel_layout:
out("--- Раскладка панелей ---")
for s in ("top", "left", "right", "bottom"):
slots = panel_layout[s]
if slots:
out(f" {s.ljust(7)} {format_layout_slots(slots)}")
else:
out(f" {s.ljust(7)}")
if panel_layout["declared"]:
out(f" объявлено: {', '.join(panel_layout['declared'])}")
out()
# --- Section: Home page (brief summary) ---
if home_page:
out("--- Начальная страница ---")
out(f" Шаблон: {home_page['template']}")
out(f" LeftColumn: {len(home_page['left'])}, RightColumn: {len(home_page['right'])} (детали: -Section home-page)")
out()
# --- Section: Storages & default forms ---
out("--- Хранилища и формы по умолчанию ---")
storage_props = [
"CommonSettingsStorage", "ReportsUserSettingsStorage", "ReportsVariantsStorage",
"FormDataSettingsStorage", "DynamicListsUserSettingsStorage", "URLExternalDataStorage",
]
for sp in storage_props:
val = get_prop_text(sp)
if val:
out(f" {sp}: {val}")
form_props = [
"DefaultReportForm", "DefaultReportVariantForm", "DefaultReportSettingsForm",
"DefaultReportAppearanceTemplate", "DefaultDynamicListSettingsForm", "DefaultSearchForm",
"DefaultDataHistoryChangeHistoryForm", "DefaultDataHistoryVersionDataForm",
"DefaultDataHistoryVersionDifferencesForm", "DefaultCollaborationSystemUsersChoiceForm",
"DefaultConstantsForm", "DefaultInterface", "DefaultStyle",
]
for fp in form_props:
val = get_prop_text(fp)
if val:
out(f" {fp}: {val}")
out()
# --- Section: Info ---
cfg_brief = get_prop_ml("BriefInformation")
cfg_detail = get_prop_ml("DetailedInformation")
cfg_copyright = get_prop_ml("Copyright")
cfg_vendor_addr = get_prop_ml("VendorInformationAddress")
cfg_info_addr = get_prop_ml("ConfigurationInformationAddress")
if cfg_brief or cfg_detail or cfg_copyright or cfg_vendor_addr or cfg_info_addr:
out("--- Информация ---")
if cfg_brief:
out(f"Краткая: {cfg_brief}")
if cfg_detail:
out(f"Подробная: {cfg_detail}")
if cfg_copyright:
out(f"Copyright: {cfg_copyright}")
if cfg_vendor_addr:
out(f"Сайт поставщика: {cfg_vendor_addr}")
if cfg_info_addr:
out(f"Адрес информ.: {cfg_info_addr}")
out()
# --- Section: Mobile functionalities ---
mobile_func = props_node.find("md:UsedMobileApplicationFunctionalities", NS)
if mobile_func is not None:
enabled_funcs = []
disabled_funcs = []
for func in mobile_func.findall("app:functionality", NS):
f_name = func.find("app:functionality", NS)
f_use = func.find("app:use", NS)
if f_name is not None and f_use is not None:
if f_use.text == "true":
enabled_funcs.append(f_name.text or "")
else:
disabled_funcs.append(f_name.text or "")
total_func = len(enabled_funcs) + len(disabled_funcs)
out(f"--- Мобильные функциональности ({total_func}, включено: {len(enabled_funcs)}) ---")
for f in enabled_funcs:
out(f" [+] {f}")
for f in disabled_funcs:
out(f" [-] {f}")
out()
# --- Section: InternalInfo ---
internal_info = cfg_node.find("md:InternalInfo", NS)
if internal_info is not None:
contained = internal_info.findall("xr:ContainedObject", NS)
out(f"--- InternalInfo ({len(contained)} ContainedObject) ---")
for co in contained:
class_id_node = co.find("xr:ClassId", NS)
object_id_node = co.find("xr:ObjectId", NS)
class_id = class_id_node.text if class_id_node is not None else ""
object_id = object_id_node.text if object_id_node is not None else ""
out(f" {class_id} -> {object_id}")
out()
# --- Section: ChildObjects (full list) ---
out(f"--- Состав ({total_objects} объектов) ---")
out()
for type_name in type_order:
if type_name not in object_counts:
continue
count = object_counts[type_name]
ru_name = type_ru_names.get(type_name, type_name)
out(f" {ru_name} ({type_name}): {count}")
# Collect names for this type
if child_obj_node is not None:
for child in child_obj_node:
if not isinstance(child.tag, str):
continue
if etree.QName(child.tag).localname == type_name:
out(f" {child.text or ''}")
# --- Pagination and output ---
total = len(lines_buf)
if args.Offset > 0 or args.Limit < total:
start = min(args.Offset, total)
end = min(start + args.Limit, total)
page = lines_buf[start:end]
result = "\n".join(page)
if end < total:
result += f"\n\n... ({end} of {total} lines, use -Offset {end} to continue)"
else:
result = "\n".join(lines_buf)
print(result)
if args.OutFile:
out_file = args.OutFile
if not os.path.isabs(out_file):
out_file = os.path.join(os.getcwd(), out_file)
with open(out_file, "w", encoding="utf-8-sig") as f:
f.write(result)
print(f"\nWritten to: {out_file}")
+64
View File
@@ -0,0 +1,64 @@
---
name: cf-init
description: Создать пустую конфигурацию 1С (scaffold XML-исходников). Используй когда нужно начать новую конфигурацию с нуля
argument-hint: <Name> [-Synonym <name>] [-OutputDir src]
allowed-tools:
- Bash
- Read
- Glob
---
# /cf-init — Создание пустой конфигурации 1С
Создаёт scaffold исходников пустой конфигурации 1С: `Configuration.xml`, `Languages/Русский.xml`.
## Параметры и команда
| Параметр | Описание |
|----------|----------|
| `Name` | Имя конфигурации (обязат.) |
| `Synonym` | Синоним (= Name если не указан) |
| `OutputDir` | Каталог для создания (default: `src`) |
| `Version` | Версия конфигурации |
| `Vendor` | Поставщик |
| `CompatibilityMode` | Режим совместимости (default: `Version8_3_24`) |
| `FormatVersion` | Версия формата выгрузки (default: `2.17`) |
Оба параметра задаются от **целевой платформы** — той, на которой конфигурация будет работать, — но по
разным правилам.
`FormatVersion`**не выше** версии формата целевой платформы: 8.3.24 — `2.17`, 8.3.25 — `2.18`,
8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно: платформа читает свой формат и любой
более старый, поэтому дефолт `2.17` подходит для всей линейки 8.3.24 и выше.
`CompatibilityMode` влияет на доступную функциональность. Если в задаче не оговорено иное — **равен
целевой платформе** (`Version8_3_27` для 8.3.27); это то же самое, что «Не использовать» в
Конфигураторе, и именно такое значение платформа проставляет новой базе. Более низкий режим берут
осознанно — когда конфигурация должна работать и на старых платформах; он отключает возможности,
появившиеся позже. Выше целевой платформы — нельзя: такая конфигурация загрузится, но работать на ней
не будет.
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cf-init/scripts/cf-init.ps1" -Name "МояКонфигурация"
```
## Примеры
```powershell
# Базовая конфигурация
... -Name МояКонфигурация -Synonym "Моя конфигурация" -OutputDir test-tmp/cf
# С версией и поставщиком
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
# Под платформу 8.3.27 — версия формата и режим совместимости вместе
... -Name TestCfg -FormatVersion 2.20 -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
```
## Верификация
```
/cf-init TestConfig -OutputDir test-tmp/cf
/cf-info test-tmp/cf — проверить созданное
/cf-validate test-tmp/cf — валидировать
```
+340
View File
@@ -0,0 +1,340 @@
# cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory)]
[string]$Name,
[string]$Synonym = $Name,
[string]$OutputDir = "src",
[string]$Version,
[string]$Vendor,
[string]$CompatibilityMode = "Version8_3_24",
# Версия формата выгрузки (MDClasses). Её задаёт ПЛАТФОРМА, которой выгружают, и от режима
# совместимости она не зависит. Дефолт 2.17 — нижняя граница проверенного диапазона.
[string]$FormatVersion = "2.17"
)
$ErrorActionPreference = "Stop"
# --- Format version ---
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
# на нечисловое значение: это опечатка, а не версия.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
$formatRank = Get-FormatRank $FormatVersion
function Esc-XmlText {
param([string]$s)
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
if ($formatRank -eq 0) {
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
exit 1
}
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
}
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
# расхождение портов началось бы прямо здесь.
if ($CompatibilityMode -and $CompatibilityMode.ToLowerInvariant() -eq 'dontuse') {
[Console]::Error.WriteLine("WARNING: CompatibilityMode 'DontUse' is not `"no restrictions`" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).")
}
# --- Resolve output dir ---
if (-not [System.IO.Path]::IsPathRooted($OutputDir)) {
$OutputDir = Join-Path (Get-Location).Path $OutputDir
}
# --- Check existing ---
$cfgFile = Join-Path $OutputDir "Configuration.xml"
if (Test-Path $cfgFile) {
Write-Error "Configuration.xml already exists: $cfgFile"
exit 1
}
# --- Generate UUIDs ---
$uuidCfg = [guid]::NewGuid().ToString()
$uuidLang = [guid]::NewGuid().ToString()
# 7 ContainedObject ObjectIds
$co1 = [guid]::NewGuid().ToString()
$co2 = [guid]::NewGuid().ToString()
$co3 = [guid]::NewGuid().ToString()
$co4 = [guid]::NewGuid().ToString()
$co5 = [guid]::NewGuid().ToString()
$co6 = [guid]::NewGuid().ToString()
$co7 = [guid]::NewGuid().ToString()
# --- Mobile functionalities ---
# Версия формата как число — по ней ниже включаются вставки 2.21.
$is221 = ($formatRank -ge 221)
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
$is218 = ($formatRank -ge 218)
$mobileFuncs = @(
@("Biometrics","true"), @("Location","false"), @("BackgroundLocation","false"),
@("BluetoothPrinters","false"), @("WiFiPrinters","false"), @("Contacts","false"),
@("Calendars","false"), @("PushNotifications","false"), @("LocalNotifications","false"),
@("InAppPurchases","false"), @("PersonalComputerFileExchange","false"), @("Ads","false"),
@("NumberDialing","false"), @("CallProcessing","false"), @("CallLog","false"),
@("AutoSendSMS","false"), @("ReceiveSMS","false"), @("SMSLog","false"),
@("Camera","false"), @("Microphone","false"), @("MusicLibrary","false"),
@("PictureAndVideoLibraries","false"), @("AudioPlaybackAndVibration","false"),
@("BackgroundAudioPlaybackAndVibration","false"), @("InstallPackages","false"),
@("OSBackup","true"), @("ApplicationUsageStatistics","false"),
@("BarcodeScanning","false"), @("BackgroundAudioRecording","false"),
@("AllFilesAccess","false"), @("Videoconferences","false"), @("NFC","false"),
@("DocumentScanning","false"), @("SpeechToText","false"), @("Geofences","false"),
@("IncomingShareRequests","false"), @("AllIncomingShareRequestsTypesProcessing","false")
)
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
if ($is218) { $mobileFuncs += ,@("TextToSpeech","false") }
$mobileXml = ""
foreach ($mf in $mobileFuncs) {
$mobileXml += "`r`n`t`t`t`t<app:functionality>`r`n`t`t`t`t`t<app:functionality>$($mf[0])</app:functionality>`r`n`t`t`t`t`t<app:use>$($mf[1])</app:use>`r`n`t`t`t`t</app:functionality>"
}
# --- Synonym XML ---
$synonymXml = ""
if ($Synonym) {
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
}
# --- Optional properties ---
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
# пишет <Vendor/>, а не <Vendor></Vendor>.
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
# --- Свойства и пространство имён формата 2.21 (платформа 8.5) ---
# Значения и ПОЗИЦИИ сняты с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники,
# выгруженные с 8.3.27 и с 8.5.1, различаются ровно этим. Порядок важен — вставки идут
# на своё место, а не в конец.
$nl = "`r`n"
$f221AuxForms = ""; $f221WindowVariant = ""; $f221OpenVariant = ""; $f221Captions = ""; $f221Migration = ""
$palNs = ""
if ($is221) {
$palNs = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
# Скобки вокруг -join обязательны: без них `$nl + (массив) -join $nl` разбирается как
# `($nl + массив) -join $nl`, массив склеивается пробелами и все теги уезжают в одну строку.
$f221AuxForms = $nl + ((@(
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"
) | ForEach-Object { "`t`t`t$_" }) -join $nl)
$f221WindowVariant = $nl + "`t`t`t<MainClientApplicationWindowInterfaceVariant>NavigationLeft</MainClientApplicationWindowInterfaceVariant>" +
$nl + "`t`t`t<ClientApplicationTheme>Auto</ClientApplicationTheme>"
$f221OpenVariant = $nl + "`t`t`t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs</ClientApplicationWindowsOpenVariant>"
$f221Captions = $nl + "`t`t`t<Caption/>" + $nl + "`t`t`t<ShortCaption/>"
$f221Migration = $nl + "`t`t`t<Version85InterfaceMigrationMode>DontUse</Version85InterfaceMigrationMode>"
}
# --- Configuration.xml ---
$cfgXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
<Configuration uuid="$uuidCfg">
<InternalInfo>
<xr:ContainedObject>
<xr:ClassId>9cd510cd-abfc-11d4-9434-004095e12fc7</xr:ClassId>
<xr:ObjectId>$co1</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>9fcd25a0-4822-11d4-9414-008048da11f9</xr:ClassId>
<xr:ObjectId>$co2</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>e3687481-0a87-462c-a166-9f34594f9bba</xr:ClassId>
<xr:ObjectId>$co3</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>9de14907-ec23-4a07-96f0-85521cb6b53b</xr:ClassId>
<xr:ObjectId>$co4</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>51f2d5d8-ea4d-4064-8892-82951750031e</xr:ClassId>
<xr:ObjectId>$co5</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>e68182ea-4237-4383-967f-90c1e3370bc7</xr:ClassId>
<xr:ObjectId>$co6</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>fb282519-d103-4dd3-bc12-cb271d631dfc</xr:ClassId>
<xr:ObjectId>$co7</xr:ObjectId>
</xr:ContainedObject>
</InternalInfo>
<Properties>
<Name>$(Esc-XmlText ($Name))</Name>
<Synonym>$synonymXml</Synonym>
<Comment/>
<NamePrefix/>
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
<DefaultRunMode>ManagedApplication</DefaultRunMode>
<UsePurposes>
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
</UsePurposes>
<ScriptVariant>Russian</ScriptVariant>
<DefaultRoles/>
$vendorEl
$versionEl
<UpdateCatalogAddress/>
<IncludeHelpInContents>false</IncludeHelpInContents>
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
<AdditionalFullTextSearchDictionaries/>
<CommonSettingsStorage/>
<ReportsUserSettingsStorage/>
<ReportsVariantsStorage/>
<FormDataSettingsStorage/>
<DynamicListsUserSettingsStorage/>
<URLExternalDataStorage/>
<Content/>
<DefaultReportForm/>
<DefaultReportVariantForm/>
<DefaultReportSettingsForm/>
<DefaultReportAppearanceTemplate/>
<DefaultDynamicListSettingsForm/>
<DefaultSearchForm/>
<DefaultDataHistoryChangeHistoryForm/>
<DefaultDataHistoryVersionDataForm/>
<DefaultDataHistoryVersionDifferencesForm/>
<DefaultCollaborationSystemUsersChoiceForm/>$f221AuxForms
<RequiredMobileApplicationPermissions/>
<UsedMobileApplicationFunctionalities>$mobileXml
</UsedMobileApplicationFunctionalities>
<StandaloneConfigurationRestrictionRoles/>
<MobileApplicationURLs/>
<AllowedIncomingShareRequestTypes/>$f221WindowVariant
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>$f221OpenVariant
<DefaultInterface/>$f221Captions
<DefaultStyle/>
<DefaultLanguage>Language.Русский</DefaultLanguage>
<BriefInformation/>
<DetailedInformation/>
<Copyright/>
<VendorInformationAddress/>
<ConfigurationInformationAddress/>
<DataLockControlMode>Managed</DataLockControlMode>
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
<ModalityUseMode>DontUse</ModalityUseMode>
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>$f221Migration
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
<CompatibilityMode>$CompatibilityMode</CompatibilityMode>
<DefaultConstantsForm/>
</Properties>
<ChildObjects>
<Language>Русский</Language>
</ChildObjects>
</Configuration>
</MetaDataObject>
"@
# --- Languages/Русский.xml ---
$langXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
<Language uuid="$uuidLang">
<Properties>
<Name>Русский</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Русский</v8:content>
</v8:item>
</Synonym>
<Comment/>
<LanguageCode>ru</LanguageCode>
</Properties>
</Language>
</MetaDataObject>
"@
# --- Ext/ClientApplicationInterface.xml (default ERP-style panel layout) ---
# Open panel on top, Sections panel on left; Functions/Favorites/History declared
# via panelDef but not placed by default. Without this file the web client renders
# section icons without labels (icon-only mode).
$openPanelInst = [guid]::NewGuid().ToString()
$sectionsPanelInst = [guid]::NewGuid().ToString()
$caiXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
<top>
<panel id="$openPanelInst">
<uuid>cbab57f2-a0f3-4f0a-89ea-4cb19570ab75</uuid>
</panel>
</top>
<left>
<panel id="$sectionsPanelInst">
<uuid>b553047f-c9aa-4157-978d-448ecad24248</uuid>
</panel>
</left>
<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>
<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>
<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>
<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>
<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>
</ClientApplicationInterface>
"@
# --- Create directories ---
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}
$langDir = Join-Path $OutputDir "Languages"
if (-not (Test-Path $langDir)) {
New-Item -ItemType Directory -Path $langDir -Force | Out-Null
}
$extDir = Join-Path $OutputDir "Ext"
if (-not (Test-Path $extDir)) {
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
}
# --- Write files with UTF-8 BOM ---
$enc = New-Object System.Text.UTF8Encoding($true)
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile $cfgFile $cfgXml $enc
$langFile = Join-Path $langDir "Русский.xml"
Write-XmlFile $langFile $langXml $enc
$caiFile = Join-Path $extDir "ClientApplicationInterface.xml"
Write-XmlFile $caiFile $caiXml $enc
# --- Output ---
Write-Host "[OK] Создана конфигурация: $Name"
Write-Host " Каталог: $OutputDir"
Write-Host " Configuration.xml: $cfgFile"
Write-Host " Languages: $langFile"
Write-Host " Ext/CAI: $caiFile"
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
# cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C configuration."""
import sys, os, argparse, re, uuid
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def esc_xml_text(s):
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def new_uuid():
return str(uuid.uuid4())
def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description='Create empty 1C configuration scaffold', allow_abbrev=False)
parser.add_argument('-Name', dest='Name', required=True)
parser.add_argument('-Synonym', dest='Synonym', default=None)
parser.add_argument('-OutputDir', dest='OutputDir', default='src')
parser.add_argument('-Version', dest='Version', default='')
parser.add_argument('-Vendor', dest='Vendor', default='')
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
# Версия формата выгрузки (MDClasses) — её задаёт ПЛАТФОРМА, а не режим совместимости.
# Дефолт 2.17 — нижняя граница проверенного диапазона.
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
args = ci_parse_args(parser)
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
format_rank_value = format_rank(args.FormatVersion)
if format_rank_value == 0:
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
sys.exit(1)
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
f"but was not verified on that platform", file=sys.stderr)
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
# расхождение портов началось бы прямо здесь.
if (args.CompatibilityMode or "").lower() == "dontuse":
print("WARNING: CompatibilityMode 'DontUse' is not \"no restrictions\" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).", file=sys.stderr)
name = args.Name
synonym = args.Synonym if args.Synonym else name
output_dir = args.OutputDir
version = args.Version
vendor = args.Vendor
compat = args.CompatibilityMode
# --- Resolve output dir ---
if not os.path.isabs(output_dir):
output_dir = os.path.join(os.getcwd(), output_dir)
# --- Check existing ---
cfg_file = os.path.join(output_dir, "Configuration.xml")
if os.path.exists(cfg_file):
print(f"Configuration.xml already exists: {cfg_file}", file=sys.stderr)
sys.exit(1)
# --- Generate UUIDs ---
uuid_cfg = new_uuid()
uuid_lang = new_uuid()
co = [new_uuid() for _ in range(7)]
# --- Mobile functionalities ---
# Версия формата как число — по ней ниже включаются вставки 2.21.
is_221 = format_rank_value >= 221
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
is_218 = format_rank_value >= 218
mobile_funcs = [
("Biometrics","true"), ("Location","false"), ("BackgroundLocation","false"),
("BluetoothPrinters","false"), ("WiFiPrinters","false"), ("Contacts","false"),
("Calendars","false"), ("PushNotifications","false"), ("LocalNotifications","false"),
("InAppPurchases","false"), ("PersonalComputerFileExchange","false"), ("Ads","false"),
("NumberDialing","false"), ("CallProcessing","false"), ("CallLog","false"),
("AutoSendSMS","false"), ("ReceiveSMS","false"), ("SMSLog","false"),
("Camera","false"), ("Microphone","false"), ("MusicLibrary","false"),
("PictureAndVideoLibraries","false"), ("AudioPlaybackAndVibration","false"),
("BackgroundAudioPlaybackAndVibration","false"), ("InstallPackages","false"),
("OSBackup","true"), ("ApplicationUsageStatistics","false"),
("BarcodeScanning","false"), ("BackgroundAudioRecording","false"),
("AllFilesAccess","false"), ("Videoconferences","false"), ("NFC","false"),
("DocumentScanning","false"), ("SpeechToText","false"), ("Geofences","false"),
("IncomingShareRequests","false"), ("AllIncomingShareRequestsTypesProcessing","false"),
]
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
if is_218:
mobile_funcs.append(("TextToSpeech", "false"))
mobile_xml = ""
for func_name, func_use in mobile_funcs:
mobile_xml += f"\r\n\t\t\t\t<app:functionality>\r\n\t\t\t\t\t<app:functionality>{func_name}</app:functionality>\r\n\t\t\t\t\t<app:use>{func_use}</app:use>\r\n\t\t\t\t</app:functionality>"
# --- Synonym XML ---
synonym_xml = ""
if synonym:
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
# пишет <Vendor/>, а не <Vendor></Vendor>.
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
class_ids = [
"9cd510cd-abfc-11d4-9434-004095e12fc7",
"9fcd25a0-4822-11d4-9414-008048da11f9",
"e3687481-0a87-462c-a166-9f34594f9bba",
"9de14907-ec23-4a07-96f0-85521cb6b53b",
"51f2d5d8-ea4d-4064-8892-82951750031e",
"e68182ea-4237-4383-967f-90c1e3370bc7",
"fb282519-d103-4dd3-bc12-cb271d631dfc",
]
# Свойства и пространство имён формата 2.21 (платформа 8.5). Значения и ПОЗИЦИИ сняты
# с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники, выгруженные с 8.3.27 и
# с 8.5.1, различаются ровно этим. Порядок важен — вставки идут на своё место.
pal_ns = ""
f221_aux_forms = f221_window_variant = f221_open_variant = f221_captions = f221_migration = ""
if is_221:
pal_ns = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
f221_aux_forms = "\r\n" + "\r\n".join(
f"\t\t\t{t}" for t in (
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"))
f221_window_variant = ("\r\n\t\t\t<MainClientApplicationWindowInterfaceVariant>NavigationLeft"
"</MainClientApplicationWindowInterfaceVariant>"
"\r\n\t\t\t<ClientApplicationTheme>Auto</ClientApplicationTheme>")
f221_open_variant = ("\r\n\t\t\t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs"
"</ClientApplicationWindowsOpenVariant>")
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
f221_migration = ("\r\n\t\t\t<Version85InterfaceMigrationMode>DontUse"
"</Version85InterfaceMigrationMode>")
contained_objects = ""
for i in range(7):
contained_objects += f"""\t\t\t<xr:ContainedObject>
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
\t\t\t\t<xr:ObjectId>{co[i]}</xr:ObjectId>
\t\t\t</xr:ContainedObject>\n"""
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
\t<Configuration uuid="{uuid_cfg}">
\t\t<InternalInfo>
{contained_objects}\t\t</InternalInfo>
\t\t<Properties>
\t\t\t<Name>{esc_xml_text(name)}</Name>
\t\t\t<Synonym>{synonym_xml}</Synonym>
\t\t\t<Comment/>
\t\t\t<NamePrefix/>
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
\t\t\t<UsePurposes>
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
\t\t\t</UsePurposes>
\t\t\t<ScriptVariant>Russian</ScriptVariant>
\t\t\t<DefaultRoles/>
\t\t\t{vendor_el}
\t\t\t{version_el}
\t\t\t<UpdateCatalogAddress/>
\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>
\t\t\t<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
\t\t\t<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
\t\t\t<AdditionalFullTextSearchDictionaries/>
\t\t\t<CommonSettingsStorage/>
\t\t\t<ReportsUserSettingsStorage/>
\t\t\t<ReportsVariantsStorage/>
\t\t\t<FormDataSettingsStorage/>
\t\t\t<DynamicListsUserSettingsStorage/>
\t\t\t<URLExternalDataStorage/>
\t\t\t<Content/>
\t\t\t<DefaultReportForm/>
\t\t\t<DefaultReportVariantForm/>
\t\t\t<DefaultReportSettingsForm/>
\t\t\t<DefaultReportAppearanceTemplate/>
\t\t\t<DefaultDynamicListSettingsForm/>
\t\t\t<DefaultSearchForm/>
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
\t\t\t<DefaultDataHistoryVersionDataForm/>
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>{f221_aux_forms}
\t\t\t<RequiredMobileApplicationPermissions/>
\t\t\t<UsedMobileApplicationFunctionalities>{mobile_xml}
\t\t\t</UsedMobileApplicationFunctionalities>
\t\t\t<StandaloneConfigurationRestrictionRoles/>
\t\t\t<MobileApplicationURLs/>
\t\t\t<AllowedIncomingShareRequestTypes/>{f221_window_variant}
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>{f221_open_variant}
\t\t\t<DefaultInterface/>{f221_captions}
\t\t\t<DefaultStyle/>
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
\t\t\t<BriefInformation/>
\t\t\t<DetailedInformation/>
\t\t\t<Copyright/>
\t\t\t<VendorInformationAddress/>
\t\t\t<ConfigurationInformationAddress/>
\t\t\t<DataLockControlMode>Managed</DataLockControlMode>
\t\t\t<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
\t\t\t<ModalityUseMode>DontUse</ModalityUseMode>
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>{f221_migration}
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
\t\t\t<CompatibilityMode>{compat}</CompatibilityMode>
\t\t\t<DefaultConstantsForm/>
\t\t</Properties>
\t\t<ChildObjects>
\t\t\t<Language>Русский</Language>
\t\t</ChildObjects>
\t</Configuration>
</MetaDataObject>'''
# --- Languages/Русский.xml ---
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
\t<Language uuid="{uuid_lang}">
\t\t<Properties>
\t\t\t<Name>Русский</Name>
\t\t\t<Synonym>
\t\t\t\t<v8:item>
\t\t\t\t\t<v8:lang>ru</v8:lang>
\t\t\t\t\t<v8:content>Русский</v8:content>
\t\t\t\t</v8:item>
\t\t\t</Synonym>
\t\t\t<Comment/>
\t\t\t<LanguageCode>ru</LanguageCode>
\t\t</Properties>
\t</Language>
</MetaDataObject>'''
# --- Ext/ClientApplicationInterface.xml (default ERP-style panel layout) ---
# Open panel on top, Sections panel on left; Functions/Favorites/History declared
# via panelDef but not placed by default. Without this file the web client renders
# section icons without labels (icon-only mode).
open_panel_inst = new_uuid()
sections_panel_inst = new_uuid()
cai_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
\t<top>
\t\t<panel id="{open_panel_inst}">
\t\t\t<uuid>cbab57f2-a0f3-4f0a-89ea-4cb19570ab75</uuid>
\t\t</panel>
\t</top>
\t<left>
\t\t<panel id="{sections_panel_inst}">
\t\t\t<uuid>b553047f-c9aa-4157-978d-448ecad24248</uuid>
\t\t</panel>
\t</left>
\t<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>
\t<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>
\t<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>
\t<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>
\t<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>
</ClientApplicationInterface>'''
# --- Create directories ---
os.makedirs(output_dir, exist_ok=True)
lang_dir = os.path.join(output_dir, "Languages")
os.makedirs(lang_dir, exist_ok=True)
ext_dir = os.path.join(output_dir, "Ext")
os.makedirs(ext_dir, exist_ok=True)
# --- Write files ---
write_xml_file(cfg_file, cfg_xml)
lang_file = os.path.join(lang_dir, "Русский.xml")
write_xml_file(lang_file, lang_xml)
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
write_xml_file(cai_file, cai_xml)
print(f"[OK] Создана конфигурация: {name}")
print(f" Каталог: {output_dir}")
print(f" Configuration.xml: {cfg_file}")
print(f" Languages: {lang_file}")
print(f" Ext/CAI: {cai_file}")
if __name__ == '__main__':
main()
+29
View File
@@ -0,0 +1,29 @@
---
name: cf-validate
description: Валидация конфигурации 1С. Используй после создания или модификации конфигурации для проверки корректности
argument-hint: <ConfigPath> [-Detailed] [-MaxErrors 30]
allowed-tools:
- Bash
- Read
- Glob
---
# /cf-validate — валидация конфигурации 1С
Проверяет Configuration.xml на структурные ошибки: XML well-formedness, InternalInfo, свойства, enum-значения, ChildObjects, DefaultLanguage, файлы языков, каталоги объектов.
## Параметры
| Параметр | Обяз. | Умолч. | Описание |
|------------|:-----:|---------|-------------------------------------------------|
| ConfigPath | да | — | Путь к Configuration.xml или каталогу выгрузки |
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
| MaxErrors | нет | 30 | Остановиться после N ошибок |
| OutFile | нет | — | Записать результат в файл (UTF-8 BOM) |
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cf-validate/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cf-validate/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty/Configuration.xml"
```
@@ -0,0 +1,631 @@
# cf-validate v1.8 — Validate 1C configuration root structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory, Position=0)]
[Alias('Path')]
[string]$ConfigPath,
[switch]$Detailed,
[int]$MaxErrors = 30,
[string]$OutFile
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Resolve path ---
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
$ConfigPath = Join-Path (Get-Location).Path $ConfigPath
}
if (Test-Path $ConfigPath -PathType Container) {
$candidate = Join-Path $ConfigPath "Configuration.xml"
if (Test-Path $candidate) {
$ConfigPath = $candidate
} else {
Write-Host "[ERROR] No Configuration.xml found in directory: $ConfigPath"
exit 1
}
}
if (-not (Test-Path $ConfigPath)) {
Write-Host "[ERROR] File not found: $ConfigPath"
exit 1
}
$resolvedPath = (Resolve-Path $ConfigPath).Path
$configDir = Split-Path $resolvedPath -Parent
# --- Output infrastructure ---
$script:errors = 0
$script:warnings = 0
$script:okCount = 0
$script:stopped = $false
$script:output = New-Object System.Text.StringBuilder 8192
function Out-Line {
param([string]$msg)
$script:output.AppendLine($msg) | Out-Null
}
function Report-OK {
param([string]$msg)
$script:okCount++
if ($Detailed) { Out-Line "[OK] $msg" }
}
function Report-Error {
param([string]$msg)
$script:errors++
Out-Line "[ERROR] $msg"
if ($script:errors -ge $MaxErrors) {
$script:stopped = $true
}
}
function Report-Warn {
param([string]$msg)
$script:warnings++
Out-Line "[WARN] $msg"
}
$finalize = {
$checks = $script:okCount + $script:errors + $script:warnings
if ($script:errors -eq 0 -and $script:warnings -eq 0 -and -not $Detailed) {
$result = "=== Validation OK: Configuration.$objName ($checks checks) ==="
} else {
Out-Line ""
Out-Line "=== Result: $($script:errors) errors, $($script:warnings) warnings ($checks checks) ==="
$result = $script:output.ToString()
}
Write-Host $result
if ($OutFile) {
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($OutFile, $result, $utf8Bom)
Write-Host "Written to: $OutFile"
}
}
# --- Format version ---
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# --- Reference tables ---
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
# 7 fixed ClassIds for Configuration
$validClassIds = @(
"9cd510cd-abfc-11d4-9434-004095e12fc7", # managed application module
"9fcd25a0-4822-11d4-9414-008048da11f9", # ordinary application module
"e3687481-0a87-462c-a166-9f34594f9bba", # session module
"9de14907-ec23-4a07-96f0-85521cb6b53b", # external connection module
"51f2d5d8-ea4d-4064-8892-82951750031e", # command interface
"e68182ea-4237-4383-967f-90c1e3370bc7", # main section command interface
"fb282519-d103-4dd3-bc12-cb271d631dfc" # home page / client app interface
)
# 45 types in canonical order
$childObjectTypes = @(
"Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister",
"ChartOfCharacteristicTypes","ChartOfAccounts","AccountingRegister",
"ChartOfCalculationTypes","CalculationRegister",
"BusinessProcess","Task","IntegrationService"
)
# Type -> directory mapping
$childTypeDirMap = @{
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
"Bot"="Bots"
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
"SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"
"CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"; "Constant"="Constants"
"CommonForm"="CommonForms"; "Catalog"="Catalogs"; "Document"="Documents"
"DocumentNumerator"="DocumentNumerators"; "Sequence"="Sequences"
"DocumentJournal"="DocumentJournals"; "Enum"="Enums"; "Report"="Reports"
"DataProcessor"="DataProcessors"; "InformationRegister"="InformationRegisters"
"AccumulationRegister"="AccumulationRegisters"
"ChartOfCharacteristicTypes"="ChartsOfCharacteristicTypes"
"ChartOfAccounts"="ChartsOfAccounts"; "AccountingRegister"="AccountingRegisters"
"ChartOfCalculationTypes"="ChartsOfCalculationTypes"
"CalculationRegister"="CalculationRegisters"
"BusinessProcess"="BusinessProcesses"; "Task"="Tasks"
"IntegrationService"="IntegrationServices"
}
# Valid enum values for Configuration properties
$validEnumValues = @{
"ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
"DefaultRunMode" = @("ManagedApplication","OrdinaryApplication","Auto")
"ScriptVariant" = @("Russian","English")
"DataLockControlMode" = @("Automatic","Managed","AutomaticAndManaged")
"ObjectAutonumerationMode" = @("NotAutoFree","AutoFree")
"ModalityUseMode" = @("DontUse","Use","UseWithWarnings")
"SynchronousPlatformExtensionAndAddInCallUseMode" = @("DontUse","Use","UseWithWarnings")
"InterfaceCompatibilityMode" = @("Version8_2","Version8_2EnableTaxi","Taxi","TaxiEnableVersion8_2","TaxiEnableVersion8_5","Version8_5EnableTaxi","Version8_5")
"DatabaseTablespacesUseMode" = @("DontUse","Use")
"MainClientApplicationWindowMode" = @("Normal","Fullscreen","Kiosk")
"CompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
}
# --- 1. Parse XML ---
Out-Line ""
$xmlDoc = $null
try {
$xmlDoc = New-Object System.Xml.XmlDocument
$xmlDoc.PreserveWhitespace = $false
$xmlDoc.Load($resolvedPath)
} catch {
Out-Line "=== Validation: Configuration (parse failed) ==="
Out-Line ""
Report-Error "1. XML parse failed: $($_.Exception.Message)"
& $finalize
exit 1
}
# --- Register namespaces ---
$ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$ns.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$ns.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
$ns.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable")
$ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
$ns.AddNamespace("xs", "http://www.w3.org/2001/XMLSchema")
$ns.AddNamespace("app", "http://v8.1c.ru/8.2/managed-application/core")
$root = $xmlDoc.DocumentElement
# --- Check 1: Root structure ---
$check1Ok = $true
$expectedNs = "http://v8.1c.ru/8.3/MDClasses"
if ($root.LocalName -ne "MetaDataObject") {
Report-Error "1. Root element is '$($root.LocalName)', expected 'MetaDataObject'"
& $finalize
exit 1
}
if ($root.NamespaceURI -ne $expectedNs) {
Report-Error "1. Root namespace is '$($root.NamespaceURI)', expected '$expectedNs'"
$check1Ok = $false
}
$version = $root.GetAttribute("version")
$versionRank = Get-FormatRank $version
if (-not $version) {
Report-Warn "1. Missing version attribute on MetaDataObject"
} elseif ($versionRank -eq 0) {
Report-Error "1. Malformed version '$version' (expected N.N)"
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
}
# Must have Configuration child
$cfgNode = $null
foreach ($child in $root.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Configuration" -and $child.NamespaceURI -eq $expectedNs) {
$cfgNode = $child; break
}
}
if (-not $cfgNode) {
Report-Error "1. No <Configuration> element found inside MetaDataObject"
& $finalize
exit 1
}
# UUID
$cfgUuid = $cfgNode.GetAttribute("uuid")
if (-not $cfgUuid) {
Report-Error "1. Missing uuid on <Configuration>"
$check1Ok = $false
} elseif ($cfgUuid -notmatch $guidPattern) {
Report-Error "1. Invalid uuid '$cfgUuid' on <Configuration>"
$check1Ok = $false
}
# Get name early for header
$propsNode = $cfgNode.SelectSingleNode("md:Properties", $ns)
$nameNode = if ($propsNode) { $propsNode.SelectSingleNode("md:Name", $ns) } else { $null }
$objName = if ($nameNode -and $nameNode.InnerText) { $nameNode.InnerText } else { "(unknown)" }
$script:output.Insert(0, "=== Validation: Configuration.$objName ===$([Environment]::NewLine)") | Out-Null
if ($check1Ok) {
Report-OK "1. Root structure: MetaDataObject/Configuration, version $version"
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 2: InternalInfo ---
$internalInfo = $cfgNode.SelectSingleNode("md:InternalInfo", $ns)
$check2Ok = $true
if (-not $internalInfo) {
Report-Error "2. InternalInfo: missing"
} else {
$contained = $internalInfo.SelectNodes("xr:ContainedObject", $ns)
if ($contained.Count -ne 7) {
Report-Warn "2. InternalInfo: expected 7 ContainedObject, found $($contained.Count)"
}
$foundClassIds = @{}
foreach ($co in $contained) {
$classId = $co.SelectSingleNode("xr:ClassId", $ns)
$objectId = $co.SelectSingleNode("xr:ObjectId", $ns)
if (-not $classId -or -not $classId.InnerText) {
Report-Error "2. ContainedObject missing ClassId"
$check2Ok = $false
continue
}
$cid = $classId.InnerText
if ($validClassIds -notcontains $cid) {
Report-Error "2. Unknown ClassId: $cid"
$check2Ok = $false
}
if ($foundClassIds.ContainsKey($cid)) {
Report-Error "2. Duplicate ClassId: $cid"
$check2Ok = $false
}
$foundClassIds[$cid] = $true
if (-not $objectId -or -not $objectId.InnerText) {
Report-Error "2. ContainedObject missing ObjectId for ClassId $cid"
$check2Ok = $false
} elseif ($objectId.InnerText -notmatch $guidPattern) {
Report-Error "2. Invalid ObjectId '$($objectId.InnerText)' for ClassId $cid"
$check2Ok = $false
}
}
# Check missing ClassIds
$missingIds = @($validClassIds | Where-Object { -not $foundClassIds.ContainsKey($_) })
if ($missingIds.Count -gt 0) {
Report-Warn "2. Missing ClassIds: $($missingIds.Count) of 7"
}
if ($check2Ok) {
Report-OK "2. InternalInfo: $($contained.Count) ContainedObject, all ClassIds valid"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 3: Properties — Name, Synonym, DefaultLanguage, DefaultRunMode ---
if (-not $propsNode) {
Report-Error "3. Properties block missing"
} else {
$check3Ok = $true
# Name
if (-not $nameNode -or -not $nameNode.InnerText) {
Report-Error "3. Properties: Name is missing or empty"
$check3Ok = $false
} else {
$nameVal = $nameNode.InnerText
if ($nameVal -notmatch $identPattern) {
Report-Error "3. Properties: Name '$nameVal' is not a valid 1C identifier"
$check3Ok = $false
}
}
# Synonym
$synNode = $propsNode.SelectSingleNode("md:Synonym", $ns)
$synPresent = $false
if ($synNode) {
$synItem = $synNode.SelectSingleNode("v8:item", $ns)
if ($synItem) {
$synContent = $synItem.SelectSingleNode("v8:content", $ns)
if ($synContent -and $synContent.InnerText) { $synPresent = $true }
}
}
# DefaultLanguage
$defLangNode = $propsNode.SelectSingleNode("md:DefaultLanguage", $ns)
$defLang = if ($defLangNode -and $defLangNode.InnerText) { $defLangNode.InnerText } else { "" }
if (-not $defLang) {
Report-Error "3. Properties: DefaultLanguage is missing or empty"
$check3Ok = $false
}
# DefaultRunMode
$defRunNode = $propsNode.SelectSingleNode("md:DefaultRunMode", $ns)
if (-not $defRunNode -or -not $defRunNode.InnerText) {
Report-Warn "3. Properties: DefaultRunMode is missing or empty"
}
if ($check3Ok) {
$synInfo = if ($synPresent) { "Synonym present" } else { "no Synonym" }
Report-OK "3. Properties: Name=`"$objName`", $synInfo, DefaultLanguage=$defLang"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 4: Property values — enum properties ---
if ($propsNode) {
$enumChecked = 0
$check4Ok = $true
foreach ($propName in $validEnumValues.Keys) {
$propNode = $propsNode.SelectSingleNode("md:$propName", $ns)
if ($propNode -and $propNode.InnerText) {
$val = $propNode.InnerText
$allowed = $validEnumValues[$propName]
if ($allowed -notcontains $val) {
Report-Error "4. Property '$propName' has invalid value '$val'"
$check4Ok = $false
}
$enumChecked++
}
}
if ($check4Ok) {
Report-OK "4. Property values: $enumChecked enum properties checked"
}
} else {
Report-Warn "4. No Properties block to check"
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 5: ChildObjects — valid types, no duplicates, order ---
$childObjNode = $cfgNode.SelectSingleNode("md:ChildObjects", $ns)
if (-not $childObjNode) {
Report-Error "5. ChildObjects block missing"
} else {
$check5Ok = $true
$totalCount = 0
$typeCounts = @{}
$duplicates = @{}
$typeFirstIndex = @{} # type -> first position index
$lastTypeOrder = -1
$orderOk = $true
$idx = 0
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
$typeName = $child.LocalName
$objNameVal = $child.InnerText
# Valid type?
$typeIdx = $childObjectTypes.IndexOf($typeName)
if ($typeIdx -lt 0) {
Report-Error "5. Unknown type '$typeName' in ChildObjects"
$check5Ok = $false
} else {
# Check order
if (-not $typeFirstIndex.ContainsKey($typeName)) {
$typeFirstIndex[$typeName] = $typeIdx
if ($typeIdx -lt $lastTypeOrder) {
Report-Warn "5. Type '$typeName' is out of canonical order (after type at position $lastTypeOrder)"
$orderOk = $false
}
$lastTypeOrder = $typeIdx
}
}
# Count and dedup
if (-not $typeCounts.ContainsKey($typeName)) { $typeCounts[$typeName] = @{} }
if ($typeCounts[$typeName].ContainsKey($objNameVal)) {
if (-not $duplicates.ContainsKey("$typeName.$objNameVal")) {
Report-Error "5. Duplicate: $typeName.$objNameVal"
$duplicates["$typeName.$objNameVal"] = $true
$check5Ok = $false
}
} else {
$typeCounts[$typeName][$objNameVal] = $true
}
$totalCount++
$idx++
}
$typeCount = $typeCounts.Count
if ($check5Ok) {
$orderInfo = if ($orderOk) { ", order correct" } else { "" }
Report-OK "5. ChildObjects: $typeCount types, $totalCount objects${orderInfo}"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 6: DefaultLanguage references existing Language in ChildObjects ---
if ($defLang -and $childObjNode) {
# DefaultLanguage is like "Language.Русский"
$langName = $defLang
if ($langName.StartsWith("Language.")) {
$langName = $langName.Substring(9)
}
$found = $false
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Language" -and $child.InnerText -eq $langName) {
$found = $true; break
}
}
if ($found) {
Report-OK "6. DefaultLanguage `"$defLang`" found in ChildObjects"
} else {
Report-Error "6. DefaultLanguage `"$defLang`" not found in ChildObjects"
}
} else {
if (-not $defLang) {
Report-Warn "6. Cannot check DefaultLanguage (empty)"
} else {
Report-Warn "6. Cannot check DefaultLanguage (no ChildObjects)"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 7: Language files exist ---
if ($childObjNode) {
$langNames = @()
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Language") {
$langNames += $child.InnerText
}
}
if ($langNames.Count -gt 0) {
$existCount = 0
foreach ($ln in $langNames) {
$langFile = Join-Path (Join-Path $configDir "Languages") "$ln.xml"
if (Test-Path $langFile) {
$existCount++
} else {
Report-Warn "7. Language file missing: Languages/$ln.xml"
}
}
if ($existCount -eq $langNames.Count) {
Report-OK "7. Language files: $existCount/$($langNames.Count) exist"
}
} else {
Report-Warn "7. No Language entries in ChildObjects"
}
} else {
Report-Warn "7. Cannot check language files (no ChildObjects)"
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 8: Object directories exist (spot-check) ---
if ($childObjNode) {
$dirsToCheck = @{}
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
$typeName = $child.LocalName
if ($typeName -eq "Language") { continue } # Already checked
if ($childTypeDirMap.ContainsKey($typeName)) {
$dirName = $childTypeDirMap[$typeName]
if (-not $dirsToCheck.ContainsKey($dirName)) {
$dirsToCheck[$dirName] = 0
}
$dirsToCheck[$dirName] = $dirsToCheck[$dirName] + 1
}
}
$missingDirs = @()
foreach ($dir in $dirsToCheck.Keys) {
$dirPath = Join-Path $configDir $dir
if (-not (Test-Path $dirPath -PathType Container)) {
$missingDirs += "$dir ($($dirsToCheck[$dir]) objects)"
}
}
if ($missingDirs.Count -eq 0) {
Report-OK "8. Object directories: $($dirsToCheck.Count) directories, all exist"
} else {
foreach ($md in $missingDirs) {
Report-Warn "8. Missing directory: $md"
}
}
}
# --- Check 9: Form references (HomePageWorkArea + Properties) ---
function Test-FormRef([string]$ref) {
if (-not $ref) { return $true }
# UUID — cannot verify without scanning all forms; skip
if ($ref -match $guidPattern) { return $true }
$parts = $ref.Split(".")
if ($parts.Count -eq 2 -and $parts[0] -eq "CommonForm") {
$p = Join-Path (Join-Path (Join-Path $configDir "CommonForms") $parts[1]) "Form.xml"
$pExt = Join-Path (Join-Path (Join-Path (Join-Path $configDir "CommonForms") $parts[1]) "Ext") "Form.xml"
return (Test-Path $p) -or (Test-Path $pExt)
}
if ($parts.Count -eq 4 -and $parts[2] -eq "Form" -and $childTypeDirMap.ContainsKey($parts[0])) {
$dir = $childTypeDirMap[$parts[0]]
$p = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $configDir $dir) $parts[1]) "Forms") $parts[3]) "Form.xml"
$pExt = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $configDir $dir) $parts[1]) "Forms") $parts[3]) "Ext") "Form.xml"
return (Test-Path $p) -or (Test-Path $pExt)
}
return $false
}
$formRefsChecked = 0
$formRefErrors = @()
# HomePageWorkArea
$hpPath = Join-Path (Join-Path $configDir "Ext") "HomePageWorkArea.xml"
if (Test-Path $hpPath) {
try {
[xml]$hpDoc = Get-Content -Path $hpPath -Encoding UTF8
$hpNs = New-Object System.Xml.XmlNamespaceManager($hpDoc.NameTable)
$hpNs.AddNamespace("hp", "http://v8.1c.ru/8.3/xcf/extrnprops")
foreach ($f in $hpDoc.DocumentElement.SelectNodes("//hp:Item/hp:Form", $hpNs)) {
$ref = $f.InnerText.Trim()
if (-not $ref) { continue }
$formRefsChecked++
if (-not (Test-FormRef $ref)) {
$formRefErrors += "HomePageWorkArea.Form '$ref' — file not found"
}
}
} catch {
$formRefErrors += "HomePageWorkArea.xml: parse error — $($_.Exception.Message)"
}
}
# Properties: DefaultXxxForm refs
if ($propsNode) {
$formProps = @("DefaultReportForm","DefaultReportVariantForm","DefaultReportSettingsForm","DefaultDynamicListSettingsForm","DefaultSearchForm","DefaultDataHistoryChangeHistoryForm","DefaultDataHistoryVersionDataForm","DefaultDataHistoryVersionDifferencesForm","DefaultCollaborationSystemUsersChoiceForm","DefaultConstantsForm")
foreach ($pn in $formProps) {
$node = $propsNode.SelectSingleNode("md:$pn", $ns)
if ($node -and $node.InnerText.Trim()) {
$ref = $node.InnerText.Trim()
$formRefsChecked++
if (-not (Test-FormRef $ref)) {
$formRefErrors += "Properties.$pn '$ref' — form not found"
}
}
}
}
if ($formRefsChecked -eq 0) {
Report-OK "9. Form references: none to check"
} elseif ($formRefErrors.Count -eq 0) {
Report-OK "9. Form references: $formRefsChecked verified"
} else {
foreach ($err in $formRefErrors) { Report-Error "9. $err" }
}
# --- Final output ---
& $finalize
if ($script:errors -gt 0) {
exit 1
}
exit 0
@@ -0,0 +1,644 @@
#!/usr/bin/env python3
# cf-validate v1.8 — Validate 1C configuration XML structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
import sys, os, argparse, re
from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
NS = {
'md': 'http://v8.1c.ru/8.3/MDClasses',
'v8': 'http://v8.1c.ru/8.1/data/core',
'xr': 'http://v8.1c.ru/8.3/xcf/readable',
'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'xs': 'http://www.w3.org/2001/XMLSchema',
'app': 'http://v8.1c.ru/8.2/managed-application/core',
}
GUID_PATTERN = re.compile(
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
)
IDENT_PATTERN = re.compile(
r'^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_]'
r'[A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
)
# 7 fixed ClassIds for Configuration
VALID_CLASS_IDS = [
'9cd510cd-abfc-11d4-9434-004095e12fc7', # managed application module
'9fcd25a0-4822-11d4-9414-008048da11f9', # ordinary application module
'e3687481-0a87-462c-a166-9f34594f9bba', # session module
'9de14907-ec23-4a07-96f0-85521cb6b53b', # external connection module
'51f2d5d8-ea4d-4064-8892-82951750031e', # command interface
'e68182ea-4237-4383-967f-90c1e3370bc7', # main section command interface
'fb282519-d103-4dd3-bc12-cb271d631dfc', # home page / client app interface
]
# 45 types in canonical order
CHILD_OBJECT_TYPES = [
'Language', 'Subsystem', 'StyleItem', 'Style',
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan',
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
'Constant', 'CommonForm', 'Catalog', 'Document',
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
'ChartOfCharacteristicTypes', 'ChartOfAccounts', 'AccountingRegister',
'ChartOfCalculationTypes', 'CalculationRegister',
'BusinessProcess', 'Task', 'IntegrationService',
]
# Type -> directory mapping
CHILD_TYPE_DIR_MAP = {
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
'Bot': 'Bots',
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
'SettingsStorage': 'SettingsStorages', 'FunctionalOption': 'FunctionalOptions',
'FunctionalOptionsParameter': 'FunctionalOptionsParameters', 'DefinedType': 'DefinedTypes',
'CommonCommand': 'CommonCommands', 'CommandGroup': 'CommandGroups', 'Constant': 'Constants',
'CommonForm': 'CommonForms', 'Catalog': 'Catalogs', 'Document': 'Documents',
'DocumentNumerator': 'DocumentNumerators', 'Sequence': 'Sequences',
'DocumentJournal': 'DocumentJournals', 'Enum': 'Enums', 'Report': 'Reports',
'DataProcessor': 'DataProcessors', 'InformationRegister': 'InformationRegisters',
'AccumulationRegister': 'AccumulationRegisters',
'ChartOfCharacteristicTypes': 'ChartsOfCharacteristicTypes',
'ChartOfAccounts': 'ChartsOfAccounts', 'AccountingRegister': 'AccountingRegisters',
'ChartOfCalculationTypes': 'ChartsOfCalculationTypes',
'CalculationRegister': 'CalculationRegisters',
'BusinessProcess': 'BusinessProcesses', 'Task': 'Tasks',
'IntegrationService': 'IntegrationServices',
}
# Valid enum values for Configuration properties
VALID_ENUM_VALUES = {
'ConfigurationExtensionCompatibilityMode': [
'DontUse', 'Version8_1', 'Version8_2_13', 'Version8_2_16',
'Version8_3_1', 'Version8_3_2', 'Version8_3_3', 'Version8_3_4', 'Version8_3_5',
'Version8_3_6', 'Version8_3_7', 'Version8_3_8', 'Version8_3_9', 'Version8_3_10',
'Version8_3_11', 'Version8_3_12', 'Version8_3_13', 'Version8_3_14', 'Version8_3_15',
'Version8_3_16', 'Version8_3_17', 'Version8_3_18', 'Version8_3_19', 'Version8_3_20',
'Version8_3_21', 'Version8_3_22', 'Version8_3_23', 'Version8_3_24', 'Version8_3_25',
'Version8_3_26', 'Version8_3_27', 'Version8_3_28', 'Version8_5_1',
],
'DefaultRunMode': ['ManagedApplication', 'OrdinaryApplication', 'Auto'],
'ScriptVariant': ['Russian', 'English'],
'DataLockControlMode': ['Automatic', 'Managed', 'AutomaticAndManaged'],
'ObjectAutonumerationMode': ['NotAutoFree', 'AutoFree'],
'ModalityUseMode': ['DontUse', 'Use', 'UseWithWarnings'],
'SynchronousPlatformExtensionAndAddInCallUseMode': ['DontUse', 'Use', 'UseWithWarnings'],
'InterfaceCompatibilityMode': [
'Version8_2', 'Version8_2EnableTaxi', 'Taxi', 'TaxiEnableVersion8_2',
'TaxiEnableVersion8_5', 'Version8_5EnableTaxi', 'Version8_5',
],
'DatabaseTablespacesUseMode': ['DontUse', 'Use'],
'MainClientApplicationWindowMode': ['Normal', 'Fullscreen', 'Kiosk'],
'CompatibilityMode': [
'DontUse', 'Version8_1', 'Version8_2_13', 'Version8_2_16',
'Version8_3_1', 'Version8_3_2', 'Version8_3_3', 'Version8_3_4', 'Version8_3_5',
'Version8_3_6', 'Version8_3_7', 'Version8_3_8', 'Version8_3_9', 'Version8_3_10',
'Version8_3_11', 'Version8_3_12', 'Version8_3_13', 'Version8_3_14', 'Version8_3_15',
'Version8_3_16', 'Version8_3_17', 'Version8_3_18', 'Version8_3_19', 'Version8_3_20',
'Version8_3_21', 'Version8_3_22', 'Version8_3_23', 'Version8_3_24', 'Version8_3_25',
'Version8_3_26', 'Version8_3_27', 'Version8_3_28', 'Version8_5_1',
],
}
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
# ── Format version ───────────────────────────────────────────
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
class Reporter:
def __init__(self, max_errors, detailed=False):
self.errors = 0
self.warnings = 0
self.ok_count = 0
self.stopped = False
self.max_errors = max_errors
self.detailed = detailed
self.lines = []
self.obj_name = '(unknown)'
def out(self, msg=''):
self.lines.append(msg)
def ok(self, msg):
self.ok_count += 1
if self.detailed:
self.lines.append(f'[OK] {msg}')
def error(self, msg):
self.errors += 1
self.lines.append(f'[ERROR] {msg}')
if self.errors >= self.max_errors:
self.stopped = True
def warn(self, msg):
self.warnings += 1
self.lines.append(f'[WARN] {msg}')
def text(self):
return '\r\n'.join(self.lines) + '\r\n'
def finalize(self, out_file):
checks = self.ok_count + self.errors + self.warnings
if self.errors == 0 and self.warnings == 0 and not self.detailed:
result = f'=== Validation OK: Configuration.{self.obj_name} ({checks} checks) ==='
else:
self.out('')
self.out(f'=== Result: {self.errors} errors, {self.warnings} warnings ({checks} checks) ===')
result = self.text()
print(result, end='' if '\r\n' in result else '\n')
if out_file:
with open(out_file, 'w', encoding='utf-8-sig', newline='') as f:
f.write(result)
print(f'Written to: {out_file}')
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description='Validate 1C configuration XML structure', allow_abbrev=False
)
parser.add_argument('-ConfigPath', '-Path', dest='ConfigPath', required=True)
parser.add_argument('-Detailed', action='store_true')
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
parser.add_argument('-OutFile', dest='OutFile', default='')
args = ci_parse_args(parser)
config_path = args.ConfigPath
max_errors = args.MaxErrors
out_file = args.OutFile
# --- Resolve path ---
if not os.path.isabs(config_path):
config_path = os.path.join(os.getcwd(), config_path)
if os.path.isdir(config_path):
candidate = os.path.join(config_path, 'Configuration.xml')
if os.path.exists(candidate):
config_path = candidate
else:
print(f'[ERROR] No Configuration.xml found in directory: {config_path}')
sys.exit(1)
if not os.path.exists(config_path):
print(f'[ERROR] File not found: {config_path}')
sys.exit(1)
resolved_path = os.path.abspath(config_path)
config_dir = os.path.dirname(resolved_path)
if out_file and not os.path.isabs(out_file):
out_file = os.path.join(os.getcwd(), out_file)
r = Reporter(max_errors, detailed=args.Detailed)
r.out('')
# --- 1. Parse XML ---
xml_doc = None
try:
xml_parser = etree.XMLParser(remove_blank_text=False)
xml_doc = etree.parse(resolved_path, xml_parser)
except etree.XMLSyntaxError as e:
r.lines.insert(0, '=== Validation: Configuration (parse failed) ===')
r.out('')
r.error(f'1. XML parse failed: {e}')
r.finalize(out_file)
sys.exit(1)
root = xml_doc.getroot()
# --- Check 1: Root structure ---
check1_ok = True
root_local = etree.QName(root.tag).localname
root_ns = etree.QName(root.tag).namespace or ''
if root_local != 'MetaDataObject':
r.error(f"1. Root element is '{root_local}', expected 'MetaDataObject'")
r.finalize(out_file)
sys.exit(1)
if root_ns != EXPECTED_NS:
r.error(f"1. Root namespace is '{root_ns}', expected '{EXPECTED_NS}'")
check1_ok = False
version = root.get('version', '')
version_rank = format_rank(version)
if not version:
r.warn('1. Missing version attribute on MetaDataObject')
elif version_rank == 0:
r.error(f"1. Malformed version '{version}' (expected N.N)")
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
r.warn(f"1. Format version '{version}' is below the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
r.warn(f"1. Format version '{version}' is above the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
# Must have Configuration child
cfg_node = None
for child in root:
if not isinstance(child.tag, str):
continue
if etree.QName(child.tag).localname == 'Configuration' and etree.QName(child.tag).namespace == EXPECTED_NS:
cfg_node = child
break
if cfg_node is None:
r.error('1. No <Configuration> element found inside MetaDataObject')
r.finalize(out_file)
sys.exit(1)
# UUID
cfg_uuid = cfg_node.get('uuid', '')
if not cfg_uuid:
r.error('1. Missing uuid on <Configuration>')
check1_ok = False
elif not GUID_PATTERN.match(cfg_uuid):
r.error(f"1. Invalid uuid '{cfg_uuid}' on <Configuration>")
check1_ok = False
# Get name early for header
props_node = cfg_node.find('md:Properties', NS)
name_node = props_node.find('md:Name', NS) if props_node is not None else None
obj_name = (name_node.text or '') if name_node is not None and name_node.text else '(unknown)'
r.obj_name = obj_name
r.lines.insert(0, f'=== Validation: Configuration.{obj_name} ===')
if check1_ok:
r.ok(f'1. Root structure: MetaDataObject/Configuration, version {version}')
if r.stopped:
r.finalize(out_file)
sys.exit(1)
# --- Check 2: InternalInfo ---
internal_info = cfg_node.find('md:InternalInfo', NS)
check2_ok = True
if internal_info is None:
r.error('2. InternalInfo: missing')
else:
contained = internal_info.findall('xr:ContainedObject', NS)
if len(contained) != 7:
r.warn(f'2. InternalInfo: expected 7 ContainedObject, found {len(contained)}')
found_class_ids = {}
for co in contained:
class_id_el = co.find('xr:ClassId', NS)
object_id_el = co.find('xr:ObjectId', NS)
if class_id_el is None or not (class_id_el.text or ''):
r.error('2. ContainedObject missing ClassId')
check2_ok = False
continue
cid = class_id_el.text
if cid not in VALID_CLASS_IDS:
r.error(f'2. Unknown ClassId: {cid}')
check2_ok = False
if cid in found_class_ids:
r.error(f'2. Duplicate ClassId: {cid}')
check2_ok = False
found_class_ids[cid] = True
if object_id_el is None or not (object_id_el.text or ''):
r.error(f'2. ContainedObject missing ObjectId for ClassId {cid}')
check2_ok = False
elif not GUID_PATTERN.match(object_id_el.text):
r.error(f"2. Invalid ObjectId '{object_id_el.text}' for ClassId {cid}")
check2_ok = False
# Check missing ClassIds
missing_ids = [cid for cid in VALID_CLASS_IDS if cid not in found_class_ids]
if len(missing_ids) > 0:
r.warn(f'2. Missing ClassIds: {len(missing_ids)} of 7')
if check2_ok:
r.ok(f'2. InternalInfo: {len(contained)} ContainedObject, all ClassIds valid')
if r.stopped:
r.finalize(out_file)
sys.exit(1)
# --- Check 3: Properties -- Name, Synonym, DefaultLanguage, DefaultRunMode ---
def_lang = ''
syn_present = False
if props_node is None:
r.error('3. Properties block missing')
else:
check3_ok = True
# Name
if name_node is None or not (name_node.text or ''):
r.error('3. Properties: Name is missing or empty')
check3_ok = False
else:
name_val = name_node.text
if not IDENT_PATTERN.match(name_val):
r.error(f"3. Properties: Name '{name_val}' is not a valid 1C identifier")
check3_ok = False
# Synonym
syn_node = props_node.find('md:Synonym', NS)
if syn_node is not None:
syn_item = syn_node.find('v8:item', NS)
if syn_item is not None:
syn_content = syn_item.find('v8:content', NS)
if syn_content is not None and syn_content.text:
syn_present = True
# DefaultLanguage
def_lang_node = props_node.find('md:DefaultLanguage', NS)
def_lang = (def_lang_node.text or '') if def_lang_node is not None else ''
if not def_lang:
r.error('3. Properties: DefaultLanguage is missing or empty')
check3_ok = False
# DefaultRunMode
def_run_node = props_node.find('md:DefaultRunMode', NS)
if def_run_node is None or not (def_run_node.text or ''):
r.warn('3. Properties: DefaultRunMode is missing or empty')
if check3_ok:
syn_info = 'Synonym present' if syn_present else 'no Synonym'
r.ok(f'3. Properties: Name="{obj_name}", {syn_info}, DefaultLanguage={def_lang}')
if r.stopped:
r.finalize(out_file)
sys.exit(1)
# --- Check 4: Property values -- enum properties ---
if props_node is not None:
enum_checked = 0
check4_ok = True
for prop_name, allowed in VALID_ENUM_VALUES.items():
prop_node = props_node.find(f'md:{prop_name}', NS)
if prop_node is not None and prop_node.text:
val = prop_node.text
if val not in allowed:
r.error(f"4. Property '{prop_name}' has invalid value '{val}'")
check4_ok = False
enum_checked += 1
if check4_ok:
r.ok(f'4. Property values: {enum_checked} enum properties checked')
else:
r.warn('4. No Properties block to check')
if r.stopped:
r.finalize(out_file)
sys.exit(1)
# --- Check 5: ChildObjects -- valid types, no duplicates, order ---
child_obj_node = cfg_node.find('md:ChildObjects', NS)
if child_obj_node is None:
r.error('5. ChildObjects block missing')
else:
check5_ok = True
total_count = 0
type_counts = {} # type_name -> {obj_name: True}
duplicates = {}
type_first_index = {}
last_type_order = -1
order_ok = True
for child in child_obj_node:
if not isinstance(child.tag, str):
continue
type_name = etree.QName(child.tag).localname
obj_name_val = child.text or ''
# Valid type?
if type_name in CHILD_OBJECT_TYPES:
type_idx = CHILD_OBJECT_TYPES.index(type_name)
else:
type_idx = -1
if type_idx < 0:
r.error(f"5. Unknown type '{type_name}' in ChildObjects")
check5_ok = False
else:
# Check order
if type_name not in type_first_index:
type_first_index[type_name] = type_idx
if type_idx < last_type_order:
r.warn(f"5. Type '{type_name}' is out of canonical order (after type at position {last_type_order})")
order_ok = False
last_type_order = type_idx
# Count and dedup
if type_name not in type_counts:
type_counts[type_name] = {}
if obj_name_val in type_counts[type_name]:
dup_key = f'{type_name}.{obj_name_val}'
if dup_key not in duplicates:
r.error(f'5. Duplicate: {dup_key}')
duplicates[dup_key] = True
check5_ok = False
else:
type_counts[type_name][obj_name_val] = True
total_count += 1
type_count = len(type_counts)
if check5_ok:
order_info = ', order correct' if order_ok else ''
r.ok(f'5. ChildObjects: {type_count} types, {total_count} objects{order_info}')
if r.stopped:
r.finalize(out_file)
sys.exit(1)
# --- Check 6: DefaultLanguage references existing Language in ChildObjects ---
if def_lang and child_obj_node is not None:
lang_name = def_lang
if lang_name.startswith('Language.'):
lang_name = lang_name[9:]
found = False
for child in child_obj_node:
if not isinstance(child.tag, str):
continue
if etree.QName(child.tag).localname == 'Language' and (child.text or '') == lang_name:
found = True
break
if found:
r.ok(f'6. DefaultLanguage "{def_lang}" found in ChildObjects')
else:
r.error(f'6. DefaultLanguage "{def_lang}" not found in ChildObjects')
else:
if not def_lang:
r.warn('6. Cannot check DefaultLanguage (empty)')
else:
r.warn('6. Cannot check DefaultLanguage (no ChildObjects)')
if r.stopped:
r.finalize(out_file)
sys.exit(1)
# --- Check 7: Language files exist ---
if child_obj_node is not None:
lang_names = []
for child in child_obj_node:
if not isinstance(child.tag, str):
continue
if etree.QName(child.tag).localname == 'Language':
lang_names.append(child.text or '')
if len(lang_names) > 0:
exist_count = 0
for ln in lang_names:
lang_file = os.path.join(config_dir, 'Languages', ln + '.xml')
if os.path.exists(lang_file):
exist_count += 1
else:
r.warn(f'7. Language file missing: Languages/{ln}.xml')
if exist_count == len(lang_names):
r.ok(f'7. Language files: {exist_count}/{len(lang_names)} exist')
else:
r.warn('7. No Language entries in ChildObjects')
else:
r.warn('7. Cannot check language files (no ChildObjects)')
if r.stopped:
r.finalize(out_file)
sys.exit(1)
# --- Check 8: Object directories exist (spot-check) ---
if child_obj_node is not None:
dirs_to_check = {}
for child in child_obj_node:
if not isinstance(child.tag, str):
continue
type_name = etree.QName(child.tag).localname
if type_name == 'Language':
continue
if type_name in CHILD_TYPE_DIR_MAP:
dir_name = CHILD_TYPE_DIR_MAP[type_name]
dirs_to_check[dir_name] = dirs_to_check.get(dir_name, 0) + 1
missing_dirs = []
for dir_name, count in dirs_to_check.items():
dir_path = os.path.join(config_dir, dir_name)
if not os.path.isdir(dir_path):
missing_dirs.append(f'{dir_name} ({count} objects)')
if len(missing_dirs) == 0:
r.ok(f'8. Object directories: {len(dirs_to_check)} directories, all exist')
else:
for md in missing_dirs:
r.warn(f'8. Missing directory: {md}')
else:
pass # no ChildObjects
# --- Check 9: Form references (HomePageWorkArea + Properties) ---
def test_form_ref(ref):
if not ref:
return True
if GUID_PATTERN.match(ref):
return True
parts = ref.split('.')
if len(parts) == 2 and parts[0] == 'CommonForm':
p = os.path.join(config_dir, 'CommonForms', parts[1], 'Form.xml')
p_ext = os.path.join(config_dir, 'CommonForms', parts[1], 'Ext', 'Form.xml')
return os.path.isfile(p) or os.path.isfile(p_ext)
if len(parts) == 4 and parts[2] == 'Form' and parts[0] in CHILD_TYPE_DIR_MAP:
d = CHILD_TYPE_DIR_MAP[parts[0]]
p = os.path.join(config_dir, d, parts[1], 'Forms', parts[3], 'Form.xml')
p_ext = os.path.join(config_dir, d, parts[1], 'Forms', parts[3], 'Ext', 'Form.xml')
return os.path.isfile(p) or os.path.isfile(p_ext)
return False
form_refs_checked = 0
form_ref_errors = []
hp_path = os.path.join(config_dir, 'Ext', 'HomePageWorkArea.xml')
if os.path.isfile(hp_path):
try:
hp_tree = etree.parse(hp_path)
HP_NS = 'http://v8.1c.ru/8.3/xcf/extrnprops'
for f in hp_tree.getroot().iter(f'{{{HP_NS}}}Form'):
ref = (f.text or '').strip()
if not ref:
continue
form_refs_checked += 1
if not test_form_ref(ref):
form_ref_errors.append(f"HomePageWorkArea.Form '{ref}' — file not found")
except Exception as e:
form_ref_errors.append(f'HomePageWorkArea.xml: parse error — {e}')
if props_node is not None:
form_props = ['DefaultReportForm','DefaultReportVariantForm','DefaultReportSettingsForm','DefaultDynamicListSettingsForm','DefaultSearchForm','DefaultDataHistoryChangeHistoryForm','DefaultDataHistoryVersionDataForm','DefaultDataHistoryVersionDifferencesForm','DefaultCollaborationSystemUsersChoiceForm','DefaultConstantsForm']
for pn in form_props:
node = props_node.find(f'md:{pn}', NS)
if node is not None and node.text and node.text.strip():
ref = node.text.strip()
form_refs_checked += 1
if not test_form_ref(ref):
form_ref_errors.append(f"Properties.{pn} '{ref}' — form not found")
if form_refs_checked == 0:
r.ok('9. Form references: none to check')
elif not form_ref_errors:
r.ok(f'9. Form references: {form_refs_checked} verified')
else:
for err in form_ref_errors:
r.error(f'9. {err}')
# --- Final output ---
r.finalize(out_file)
sys.exit(1 if r.errors > 0 else 0)
if __name__ == '__main__':
main()
+109
View File
@@ -0,0 +1,109 @@
---
name: cfe-borrow
description: Заимствование объектов из конфигурации 1С в расширение (CFE). Используй когда нужно перехватить метод, изменить форму или добавить реквизит к существующему объекту конфигурации
argument-hint: -ExtensionPath <path> -ConfigPath <path> -Object "Catalog.Контрагенты.Form.ФормаЭлемента" -BorrowMainAttribute
allowed-tools:
- Bash
- Read
- Glob
---
# /cfe-borrow — Заимствование объектов из конфигурации
Заимствует объекты из основной конфигурации в расширение. Создаёт XML-файлы с `ObjectBelonging=Adopted` и `ExtendedConfigurationObject`, добавляет запись в ChildObjects расширения.
## Предусловие
Расширение должно быть создано (`/cfe-init`) и содержать валидный `Configuration.xml`.
### Авто-определение ConfigPath
Если пользователь не указал `-ConfigPath` — попробуй определить автоматически:
1. Прочитай `.v8-project.json` из корня проекта
2. Разреши целевую базу (по имени, ветке или `default` — алгоритм из `/db-list`)
3. Если у базы есть поле `configSrc` — используй как `-ConfigPath`
4. Если `configSrc` нет — спроси у пользователя
## Параметры
| Параметр | Описание |
|----------|----------|
| `ExtensionPath` | Путь к каталогу расширения (обязат.) |
| `ConfigPath` | Путь к конфигурации-источнику (обязат.) |
| `Object` | Что заимствовать (обязат.), batch через `;;` |
| `Module` | Создать пустые модули объекта: `ObjectModule`, `ManagerModule`, `RecordSetModule`, `ValueManagerModule` (через запятую) или `None`. У типов с единственным модулем (`CommonModule`, `HTTPService`, `WebService`) он создаётся и без параметра |
| `BorrowMainAttribute` | Заимствовать основной реквизит формы. Без параметра — не заимствует. `Form` — реквизиты, используемые на форме. `All` — все реквизиты объекта. Требует форму в -Object |
## Формат -Object
- `Catalog.Контрагенты` — справочник
- `CommonModule.РаботаСФайлами` — общий модуль
- `Document.РеализацияТоваров` — документ
- `Enum.ВидыОплат` — перечисление
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
### Заимствование форм
Формат `Тип.Имя.Form.ИмяФормы` заимствует форму конкретного объекта. Если родительский объект ещё не заимствован — он будет заимствован автоматически.
Создаётся:
1. **Метаданные формы**`Forms/ИмяФормы.xml` с `ObjectBelonging=Adopted`, `FormType=Managed`
2. **Form.xml**`Forms/ИмяФормы/Ext/Form.xml` с копией исходной формы + `<BaseForm>` (начальное состояние)
3. **Module.bsl** — пустой файл `Forms/ИмяФормы/Ext/Form/Module.bsl`
4. **Регистрация**`<Form>` в ChildObjects родительского объекта
### Заимствование основного реквизита формы (-BorrowMainAttribute)
**Когда нужно**: пользователь хочет добавить новый реквизит в существующий объект конфигурации и вывести его на заимствованную форму. Без `-BorrowMainAttribute` форма заимствуется "пустой" — только визуальные элементы, без привязки к данным объекта. С `-BorrowMainAttribute` форма сохраняет привязки к реквизитам объекта (DataPath), что позволяет затем добавить на неё новые элементы через `/form-edit`.
**Два режима**:
- `Form` (по умолчанию) — заимствует только те реквизиты объекта, которые уже выведены на форму. Оптимальный выбор для большинства случаев
- `All` — заимствует все реквизиты и табличные части объекта. Используй если планируешь выводить на форму реквизиты, которых на ней ещё нет
**Типовой сценарий** (добавление реквизита + вывод на форму):
1. `/cfe-borrow` с `-BorrowMainAttribute` — заимствовать форму с реквизитами
2. `/meta-edit` — добавить новый реквизит в объект расширения
3. `/form-edit` — вывести реквизит на заимствованную форму
**Защита существующих данных**: уже заимствованный объект не перезаписывается — добавляется только недостающее. Повторный вызов безопасен: собственные реквизиты расширения, заимствованные подобъекты и код в модулях сохраняются.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cfe-borrow/scripts/cfe-borrow.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
```
## Примеры
```powershell
# Заимствовать один объект
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
# Заимствовать справочник вместе с модулями объекта и менеджера
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты" -Module ObjectModule,ManagerModule
# Общий модуль без файла модуля
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "CommonModule.РаботаСФайлами" -Module None
# Заимствовать форму (автоматически заимствует родительский объект)
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
# Несколько объектов за раз
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
# Заимствовать форму с основным реквизитом (реквизиты по DataPath формы)
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
# Заимствовать форму с ВСЕМИ реквизитами объекта
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
```
## Верификация
```
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
```
Конфигурацию-источник передавай и валидатору: заимствованные формы он проверяет по ней.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
---
name: cfe-diff
description: Анализ расширения конфигурации 1С (CFE) — состав, заимствованные объекты, перехватчики, проверка переноса. Используй когда нужно понять что содержит расширение или проверить перенесены ли вставки в конфигурацию
argument-hint: -ExtensionPath <path> -ConfigPath <path> [-Mode A|B]
allowed-tools:
- Bash
- Read
- Glob
---
# /cfe-diff — Анализ расширения конфигурации
Анализирует расширение в двух режимах: обзор изменений (Mode A) или проверка переноса (Mode B).
## Параметры
| Параметр | Описание | По умолчанию |
|----------|----------|--------------|
| `ExtensionPath` | Путь к расширению (обязат.) | — |
| `ConfigPath` | Путь к конфигурации (обязат.) | — |
| `Mode` | `A` (обзор) / `B` (проверка переноса) | `A` |
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cfe-diff/scripts/cfe-diff.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
```
## Mode A — обзор расширения
Для каждого объекта показывает:
- `[BORROWED]` — заимствованный: перехватчики (`&Перед`, `&После`, `&ИзменениеИКонтроль`, `&Вместо`), собственные реквизиты/ТЧ/формы
- `[OWN]` — собственный: количество реквизитов, ТЧ, форм
Для каждой формы заимствованного объекта показывается:
- `(borrowed)` / `(own)` — заимствованная или собственная форма
- callType-события формы и элементов
- callType на командах
## Mode B — проверка переноса
Для каждого `&ИзменениеИКонтроль` извлекает блоки `#Вставка`/`#КонецВставки` из расширения и ищет их в соответствующем модуле конфигурации.
Статусы:
- `[TRANSFERRED]` — код найден в конфигурации
- `[NOT_TRANSFERRED]` — код не найден
- `[NEEDS_REVIEW]` — нет блоков `#Вставка` или модуль конфигурации не найден
## Примеры
```powershell
# Обзор — что изменено в расширении
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
# Проверка переноса — все ли #Вставка перенесены
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode B
```
+474
View File
@@ -0,0 +1,474 @@
# cfe-diff v1.3 — Analyze and compare 1C configuration extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory, Position=0)]
[string]$ExtensionPath,
[Parameter(Mandatory)]
[string]$ConfigPath,
[ValidateSet("A","B")]
[string]$Mode = "A"
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Resolve paths ---
if (-not [System.IO.Path]::IsPathRooted($ExtensionPath)) {
$ExtensionPath = Join-Path (Get-Location).Path $ExtensionPath
}
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
$ConfigPath = Join-Path (Get-Location).Path $ConfigPath
}
if (Test-Path $ExtensionPath -PathType Leaf) { $ExtensionPath = Split-Path $ExtensionPath -Parent }
if (Test-Path $ConfigPath -PathType Leaf) { $ConfigPath = Split-Path $ConfigPath -Parent }
$extCfg = Join-Path $ExtensionPath "Configuration.xml"
$srcCfg = Join-Path $ConfigPath "Configuration.xml"
if (-not (Test-Path $extCfg)) { Write-Error "Extension Configuration.xml not found: $extCfg"; exit 1 }
if (-not (Test-Path $srcCfg)) { Write-Error "Config Configuration.xml not found: $srcCfg"; exit 1 }
# --- Type -> directory mapping ---
$childTypeDirMap = @{
"Catalog"="Catalogs"; "Document"="Documents"; "Enum"="Enums"
"CommonModule"="CommonModules"; "CommonPicture"="CommonPictures"
"CommonCommand"="CommonCommands"; "CommonTemplate"="CommonTemplates"
"ExchangePlan"="ExchangePlans"; "Report"="Reports"; "DataProcessor"="DataProcessors"
"InformationRegister"="InformationRegisters"; "AccumulationRegister"="AccumulationRegisters"
"ChartOfCharacteristicTypes"="ChartsOfCharacteristicTypes"
"ChartOfAccounts"="ChartsOfAccounts"; "AccountingRegister"="AccountingRegisters"
"ChartOfCalculationTypes"="ChartsOfCalculationTypes"; "CalculationRegister"="CalculationRegisters"
"BusinessProcess"="BusinessProcesses"; "Task"="Tasks"
"Subsystem"="Subsystems"; "Role"="Roles"; "Constant"="Constants"
"FunctionalOption"="FunctionalOptions"; "DefinedType"="DefinedTypes"
"FunctionalOptionsParameter"="FunctionalOptionsParameters"
"CommonForm"="CommonForms"; "DocumentJournal"="DocumentJournals"
"SessionParameter"="SessionParameters"; "StyleItem"="StyleItems"
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
"SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria"
"CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators"
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "XDTOPackage"="XDTOPackages"
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"Bot"="Bots"
}
# --- Parse extension Configuration.xml ---
$extDoc = New-Object System.Xml.XmlDocument
$extDoc.PreserveWhitespace = $false
$extDoc.Load($extCfg)
$ns = New-Object System.Xml.XmlNamespaceManager($extDoc.NameTable)
$ns.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$ns.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable")
$extProps = $extDoc.SelectSingleNode("//md:Configuration/md:Properties", $ns)
$extNameNode = $extProps.SelectSingleNode("md:Name", $ns)
$extName = if ($extNameNode) { $extNameNode.InnerText } else { "?" }
$prefixNode = $extProps.SelectSingleNode("md:NamePrefix", $ns)
$namePrefix = if ($prefixNode -and $prefixNode.InnerText) { $prefixNode.InnerText } else { "" }
$purposeNode = $extProps.SelectSingleNode("md:ConfigurationExtensionPurpose", $ns)
$purpose = if ($purposeNode) { $purposeNode.InnerText } else { "?" }
Write-Host "=== cfe-diff Mode ${Mode}: $extName (${purpose}) ==="
Write-Host " NamePrefix: $namePrefix"
Write-Host ""
# --- Collect ChildObjects ---
$childObjNode = $extDoc.SelectSingleNode("//md:Configuration/md:ChildObjects", $ns)
if (-not $childObjNode) {
Write-Host "[WARN] No ChildObjects in extension"
exit 0
}
$objects = @()
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
if ($child.LocalName -eq "Language") { continue }
$objects += @{ Type = $child.LocalName; Name = $child.InnerText }
}
if ($objects.Count -eq 0) {
Write-Host "No objects (besides Language) in extension."
exit 0
}
# --- Helper: check if object is borrowed ---
function Get-ObjectInfo {
param([string]$objType, [string]$objName)
if (-not $childTypeDirMap.ContainsKey($objType)) { return $null }
$dirName = $childTypeDirMap[$objType]
$objFile = Join-Path (Join-Path $ExtensionPath $dirName) "${objName}.xml"
if (-not (Test-Path $objFile)) { return @{ Borrowed = $false; File = $objFile; Exists = $false } }
$doc = New-Object System.Xml.XmlDocument
$doc.PreserveWhitespace = $false
$doc.Load($objFile)
$objNs = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
$objNs.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$objEl = $null
foreach ($c in $doc.DocumentElement.ChildNodes) {
if ($c.NodeType -eq 'Element') { $objEl = $c; break }
}
if (-not $objEl) { return @{ Borrowed = $false; File = $objFile; Exists = $true } }
$propsEl = $objEl.SelectSingleNode("md:Properties", $objNs)
$obNode = if ($propsEl) { $propsEl.SelectSingleNode("md:ObjectBelonging", $objNs) } else { $null }
$info = @{
Borrowed = ($obNode -and $obNode.InnerText -eq "Adopted")
File = $objFile
Exists = $true
Type = $objType
Name = $objName
DirName = $dirName
ObjElement = $objEl
ObjNs = $objNs
}
return $info
}
# --- Helper: find .bsl files for object ---
function Get-BslFiles {
param([string]$objType, [string]$objName)
if (-not $childTypeDirMap.ContainsKey($objType)) { return @() }
$dirName = $childTypeDirMap[$objType]
$objDir = Join-Path (Join-Path $ExtensionPath $dirName) $objName
if (-not (Test-Path $objDir -PathType Container)) { return @() }
$bslFiles = @()
$extDir = Join-Path $objDir "Ext"
if (Test-Path $extDir) {
$items = Get-ChildItem -Path $extDir -Filter "*.bsl" -ErrorAction SilentlyContinue
foreach ($item in $items) { $bslFiles += $item.FullName }
}
# Forms
$formsDir = Join-Path $objDir "Forms"
if (Test-Path $formsDir) {
$formModules = Get-ChildItem -Path $formsDir -Recurse -Filter "Module.bsl" -ErrorAction SilentlyContinue
foreach ($fm in $formModules) { $bslFiles += $fm.FullName }
}
return $bslFiles
}
# --- Helper: parse interceptors from .bsl ---
function Get-Interceptors {
param([string]$bslPath)
if (-not (Test-Path $bslPath)) { return @() }
$lines = [System.IO.File]::ReadAllLines($bslPath, [System.Text.Encoding]::UTF8)
$interceptors = @()
$i = 0
while ($i -lt $lines.Count) {
$line = $lines[$i].Trim()
if ($line -match '^&(Перед|После|ИзменениеИКонтроль|Вместо)\("([^"]+)"\)') {
$type = $Matches[1]
$method = $Matches[2]
$interceptors += @{ Type = $type; Method = $method; Line = $i + 1; File = $bslPath }
}
$i++
}
return $interceptors
}
# --- Helper: extract #Вставка blocks from .bsl ---
function Get-InsertionBlocks {
param([string]$bslPath)
if (-not (Test-Path $bslPath)) { return @() }
$lines = [System.IO.File]::ReadAllLines($bslPath, [System.Text.Encoding]::UTF8)
$blocks = @()
$inBlock = $false
$blockLines = @()
$startLine = 0
for ($i = 0; $i -lt $lines.Count; $i++) {
$line = $lines[$i].Trim()
if ($line -eq "#Вставка") {
$inBlock = $true
$blockLines = @()
$startLine = $i + 1
} elseif ($line -eq "#КонецВставки" -and $inBlock) {
$inBlock = $false
$blocks += @{
StartLine = $startLine
EndLine = $i + 1
Code = ($blockLines -join "`n").Trim()
File = $bslPath
}
} elseif ($inBlock) {
$blockLines += $lines[$i]
}
}
return $blocks
}
# --- Helper: analyze form for callType events and commands ---
function Get-FormInterceptors {
param([string]$formXmlPath)
if (-not (Test-Path $formXmlPath)) { return $null }
$formDoc = New-Object System.Xml.XmlDocument
$formDoc.PreserveWhitespace = $false
try { $formDoc.Load($formXmlPath) } catch { return $null }
$fNs = New-Object System.Xml.XmlNamespaceManager($formDoc.NameTable)
$fNs.AddNamespace("f", "http://v8.1c.ru/8.3/xcf/logform")
$fRoot = $formDoc.DocumentElement
$baseForm = $fRoot.SelectSingleNode("f:BaseForm", $fNs)
$isBorrowed = ($baseForm -ne $null)
$interceptors = @()
# Form-level events with callType
$eventsNode = $fRoot.SelectSingleNode("f:Events", $fNs)
if ($eventsNode) {
foreach ($evt in $eventsNode.SelectNodes("f:Event", $fNs)) {
$ct = $evt.GetAttribute("callType")
if ($ct) {
$interceptors += "Event:$($evt.GetAttribute('name')) [$ct] -> $($evt.InnerText)"
}
}
}
# Element-level events with callType (scan all elements recursively)
$childItems = $fRoot.SelectSingleNode("f:ChildItems", $fNs)
if ($childItems) {
foreach ($evtNode in $childItems.SelectNodes(".//*[f:Events/f:Event[@callType]]", $fNs)) {
$elName = $evtNode.GetAttribute("name")
foreach ($evt in $evtNode.SelectNodes("f:Events/f:Event[@callType]", $fNs)) {
$ct = $evt.GetAttribute("callType")
$interceptors += "Element:${elName}.$($evt.GetAttribute('name')) [$ct] -> $($evt.InnerText)"
}
}
}
# Commands with callType on Action
foreach ($cmd in $fRoot.SelectNodes("f:Commands/f:Command", $fNs)) {
$cmdName = $cmd.GetAttribute("name")
foreach ($action in $cmd.SelectNodes("f:Action[@callType]", $fNs)) {
$ct = $action.GetAttribute("callType")
$interceptors += "Command:$cmdName [$ct] -> $($action.InnerText)"
}
}
return @{
IsBorrowed = $isBorrowed
Interceptors = $interceptors
}
}
# ============================================================
# MODE A: Extension overview
# ============================================================
if ($Mode -eq "A") {
$borrowedList = @()
$ownList = @()
foreach ($obj in $objects) {
$info = Get-ObjectInfo $obj.Type $obj.Name
if (-not $info) {
Write-Host " [?] $($obj.Type).$($obj.Name) — unknown type"
continue
}
if (-not $info.Exists) {
Write-Host " [?] $($obj.Type).$($obj.Name) — file not found"
continue
}
if ($info.Borrowed) {
$borrowedList += $obj
Write-Host " [BORROWED] $($obj.Type).$($obj.Name)"
# Find .bsl files and interceptors
$bslFiles = Get-BslFiles $obj.Type $obj.Name
foreach ($bsl in $bslFiles) {
$relPath = $bsl.Replace($ExtensionPath, "").TrimStart("\", "/")
$interceptors = Get-Interceptors $bsl
if ($interceptors.Count -gt 0) {
foreach ($ic in $interceptors) {
Write-Host " &$($ic.Type)(`"$($ic.Method)`") — line $($ic.Line) in $relPath"
}
} else {
Write-Host " $relPath (no interceptors)"
}
}
# Check for own attributes/forms in ChildObjects
if ($info.ObjElement) {
$childObj = $info.ObjElement.SelectSingleNode("md:ChildObjects", $info.ObjNs)
if ($childObj) {
$ownAttrs = 0
$ownForms = 0
$ownTS = 0
$borrowedItems = 0
$formNames = @()
foreach ($c in $childObj.ChildNodes) {
if ($c.NodeType -ne 'Element') { continue }
$cProps = $c.SelectSingleNode("md:Properties", $info.ObjNs)
if ($cProps) {
$cOb = $cProps.SelectSingleNode("md:ObjectBelonging", $info.ObjNs)
if ($cOb -and $cOb.InnerText -eq "Adopted") {
$borrowedItems++
continue
}
}
switch ($c.LocalName) {
"Attribute" { $ownAttrs++ }
"TabularSection" { $ownTS++ }
"Form" { $formNames += $c.InnerText; $ownForms++ }
}
}
$parts = @()
if ($ownAttrs -gt 0) { $parts += "$ownAttrs own attrs" }
if ($ownTS -gt 0) { $parts += "$ownTS own TS" }
if ($ownForms -gt 0) { $parts += "$ownForms own forms" }
if ($borrowedItems -gt 0) { $parts += "$borrowedItems borrowed items" }
if ($parts.Count -gt 0) {
Write-Host " ChildObjects: $($parts -join ', ')"
}
# Analyze forms
$borrowedFormCount = 0
$ownFormCount = 0
foreach ($fn in $formNames) {
$formXmlPath = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $ExtensionPath $info.DirName) $info.Name) "Forms") $fn) "Ext/Form.xml"
$fi = Get-FormInterceptors $formXmlPath
if (-not $fi) {
Write-Host " Form.$fn (?)"
continue
}
$formTag = if ($fi.IsBorrowed) { "borrowed"; $borrowedFormCount++ } else { "own"; $ownFormCount++ }
if ($fi.Interceptors.Count -gt 0) {
Write-Host " Form.$fn ($formTag):"
foreach ($ic in $fi.Interceptors) {
Write-Host " $ic"
}
} else {
Write-Host " Form.$fn ($formTag)"
}
}
}
}
} else {
$ownList += $obj
Write-Host " [OWN] $($obj.Type).$($obj.Name)"
# Brief info for own objects
if ($info.ObjElement) {
$childObj = $info.ObjElement.SelectSingleNode("md:ChildObjects", $info.ObjNs)
if ($childObj) {
$attrs = 0; $forms = 0; $ts = 0
foreach ($c in $childObj.ChildNodes) {
if ($c.NodeType -ne 'Element') { continue }
switch ($c.LocalName) {
"Attribute" { $attrs++ }
"TabularSection" { $ts++ }
"Form" { $forms++ }
}
}
$parts = @()
if ($attrs -gt 0) { $parts += "$attrs attrs" }
if ($ts -gt 0) { $parts += "$ts TS" }
if ($forms -gt 0) { $parts += "$forms forms" }
if ($parts.Count -gt 0) {
Write-Host " $($parts -join ', ')"
}
}
}
}
}
Write-Host ""
Write-Host "=== Summary: $($borrowedList.Count) borrowed, $($ownList.Count) own objects ==="
}
# ============================================================
# MODE B: Transfer check
# ============================================================
if ($Mode -eq "B") {
$transferred = 0
$notTransferred = 0
$needsReview = 0
foreach ($obj in $objects) {
$info = Get-ObjectInfo $obj.Type $obj.Name
if (-not $info -or -not $info.Exists -or -not $info.Borrowed) { continue }
# Find .bsl files with &ИзменениеИКонтроль
$bslFiles = Get-BslFiles $obj.Type $obj.Name
foreach ($bsl in $bslFiles) {
$interceptors = Get-Interceptors $bsl
$macInterceptors = @($interceptors | Where-Object { $_.Type -eq "ИзменениеИКонтроль" })
if ($macInterceptors.Count -eq 0) { continue }
foreach ($ic in $macInterceptors) {
$methodName = $ic.Method
$relBsl = $bsl.Replace($ExtensionPath, "").TrimStart("\", "/")
# Find #Вставка blocks in this file
$insertBlocks = Get-InsertionBlocks $bsl
if ($insertBlocks.Count -eq 0) {
Write-Host " [NEEDS_REVIEW] $($obj.Type).$($obj.Name) — &ИзменениеИКонтроль(`"$methodName`") — no #Вставка blocks"
$needsReview++
continue
}
# Find corresponding module in config
if (-not $childTypeDirMap.ContainsKey($obj.Type)) { continue }
$dirName = $childTypeDirMap[$obj.Type]
$configBsl = $bsl.Replace($ExtensionPath, $ConfigPath)
if (-not (Test-Path $configBsl)) {
Write-Host " [NEEDS_REVIEW] $($obj.Type).$($obj.Name) — &ИзменениеИКонтроль(`"$methodName`") — config module not found"
$needsReview++
continue
}
$configContent = [System.IO.File]::ReadAllText($configBsl, [System.Text.Encoding]::UTF8)
$allTransferred = $true
foreach ($block in $insertBlocks) {
$code = $block.Code
if (-not $code) { continue }
# Normalize whitespace for comparison
$codeNorm = $code -replace '\s+', ' '
$configNorm = $configContent -replace '\s+', ' '
if ($configNorm.Contains($codeNorm)) {
# Found in config
} else {
$allTransferred = $false
}
}
if ($allTransferred) {
Write-Host " [TRANSFERRED] $($obj.Type).$($obj.Name) — &ИзменениеИКонтроль(`"$methodName`") — $($insertBlocks.Count) block(s)"
$transferred++
} else {
Write-Host " [NOT_TRANSFERRED] $($obj.Type).$($obj.Name) — &ИзменениеИКонтроль(`"$methodName`") — some blocks not found in config"
$notTransferred++
}
}
}
}
Write-Host ""
Write-Host "=== Transfer check: $transferred transferred, $notTransferred not transferred, $needsReview needs review ==="
}
+568
View File
@@ -0,0 +1,568 @@
#!/usr/bin/env python3
# cfe-diff v1.3 — Analyze and compare 1C configuration extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
import re
import sys
from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
# --- Namespace maps ---
MD_NSMAP = {
"md": "http://v8.1c.ru/8.3/MDClasses",
"xr": "http://v8.1c.ru/8.3/xcf/readable",
}
FORM_NSMAP = {
"f": "http://v8.1c.ru/8.3/xcf/logform",
}
# --- Type -> directory mapping ---
CHILD_TYPE_DIR_MAP = {
"Catalog": "Catalogs",
"Document": "Documents",
"Enum": "Enums",
"CommonModule": "CommonModules",
"CommonPicture": "CommonPictures",
"CommonCommand": "CommonCommands",
"CommonTemplate": "CommonTemplates",
"ExchangePlan": "ExchangePlans",
"Report": "Reports",
"DataProcessor": "DataProcessors",
"InformationRegister": "InformationRegisters",
"AccumulationRegister": "AccumulationRegisters",
"ChartOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
"ChartOfAccounts": "ChartsOfAccounts",
"AccountingRegister": "AccountingRegisters",
"ChartOfCalculationTypes": "ChartsOfCalculationTypes",
"CalculationRegister": "CalculationRegisters",
"BusinessProcess": "BusinessProcesses",
"Task": "Tasks",
"Subsystem": "Subsystems",
"Role": "Roles",
"Constant": "Constants",
"FunctionalOption": "FunctionalOptions",
"DefinedType": "DefinedTypes",
"FunctionalOptionsParameter": "FunctionalOptionsParameters",
"CommonForm": "CommonForms",
"DocumentJournal": "DocumentJournals",
"SessionParameter": "SessionParameters",
"StyleItem": "StyleItems",
"EventSubscription": "EventSubscriptions",
"ScheduledJob": "ScheduledJobs",
"SettingsStorage": "SettingsStorages",
"FilterCriterion": "FilterCriteria",
"CommandGroup": "CommandGroups",
"DocumentNumerator": "DocumentNumerators",
"Sequence": "Sequences",
"IntegrationService": "IntegrationServices",
"CommonAttribute": "CommonAttributes",
"Style": "Styles",
"XDTOPackage": "XDTOPackages",
"WebService": "WebServices",
"HTTPService": "HTTPServices",
"WSReference": "WSReferences",
"Bot": "Bots",
}
# --- Helper: check if object is borrowed ---
def get_object_info(obj_type, obj_name, extension_path):
if obj_type not in CHILD_TYPE_DIR_MAP:
return None
dir_name = CHILD_TYPE_DIR_MAP[obj_type]
obj_file = os.path.join(extension_path, dir_name, f"{obj_name}.xml")
if not os.path.isfile(obj_file):
return {"Borrowed": False, "File": obj_file, "Exists": False}
parser_xml = etree.XMLParser(remove_blank_text=False)
doc = etree.parse(obj_file, parser_xml)
doc_root = doc.getroot()
# Find first element child
obj_el = None
for c in doc_root:
if isinstance(c.tag, str):
obj_el = c
break
if obj_el is None:
return {"Borrowed": False, "File": obj_file, "Exists": True}
props_el = obj_el.find("md:Properties", MD_NSMAP)
ob_node = None
if props_el is not None:
ob_node = props_el.find("md:ObjectBelonging", MD_NSMAP)
borrowed = ob_node is not None and ob_node.text == "Adopted"
return {
"Borrowed": borrowed,
"File": obj_file,
"Exists": True,
"Type": obj_type,
"Name": obj_name,
"DirName": dir_name,
"ObjElement": obj_el,
}
# --- Helper: find .bsl files for object ---
def get_bsl_files(obj_type, obj_name, extension_path):
if obj_type not in CHILD_TYPE_DIR_MAP:
return []
dir_name = CHILD_TYPE_DIR_MAP[obj_type]
obj_dir = os.path.join(extension_path, dir_name, obj_name)
if not os.path.isdir(obj_dir):
return []
bsl_files = []
ext_dir = os.path.join(obj_dir, "Ext")
if os.path.isdir(ext_dir):
for item in os.listdir(ext_dir):
if item.lower().endswith(".bsl"):
bsl_files.append(os.path.join(ext_dir, item))
# Forms
forms_dir = os.path.join(obj_dir, "Forms")
if os.path.isdir(forms_dir):
for dirpath, dirnames, filenames in os.walk(forms_dir):
for fn in filenames:
if fn == "Module.bsl":
bsl_files.append(os.path.join(dirpath, fn))
return bsl_files
# --- Helper: parse interceptors from .bsl ---
def get_interceptors(bsl_path):
if not os.path.isfile(bsl_path):
return []
with open(bsl_path, "r", encoding="utf-8-sig") as fh:
lines = fh.readlines()
interceptors = []
pattern = re.compile(r'^&(\u041f\u0435\u0440\u0435\u0434|\u041f\u043e\u0441\u043b\u0435|\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c|\u0412\u043c\u0435\u0441\u0442\u043e)\("([^"]+)"\)')
# The above is: ^&(Перед|После|ИзменениеИКонтроль|Вместо)\("([^"]+)"\)
for i, line in enumerate(lines):
stripped = line.strip()
m = pattern.match(stripped)
if m:
interceptors.append({
"Type": m.group(1),
"Method": m.group(2),
"Line": i + 1,
"File": bsl_path,
})
return interceptors
# --- Helper: extract #Вставка blocks from .bsl ---
def get_insertion_blocks(bsl_path):
if not os.path.isfile(bsl_path):
return []
with open(bsl_path, "r", encoding="utf-8-sig") as fh:
lines = fh.readlines()
blocks = []
in_block = False
block_lines = []
start_line = 0
for i, line in enumerate(lines):
stripped = line.strip()
if stripped == "\u0023\u0412\u0441\u0442\u0430\u0432\u043a\u0430":
# #Вставка
in_block = True
block_lines = []
start_line = i + 1
elif stripped == "\u0023\u041a\u043e\u043d\u0435\u0446\u0412\u0441\u0442\u0430\u0432\u043a\u0438" and in_block:
# #КонецВставки
in_block = False
blocks.append({
"StartLine": start_line,
"EndLine": i + 1,
"Code": "\n".join(block_lines).strip(),
"File": bsl_path,
})
elif in_block:
block_lines.append(line.rstrip("\n").rstrip("\r"))
return blocks
# --- Helper: analyze form for callType events and commands ---
def get_form_interceptors(form_xml_path):
if not os.path.isfile(form_xml_path):
return None
parser_xml = etree.XMLParser(remove_blank_text=False)
try:
doc = etree.parse(form_xml_path, parser_xml)
except Exception:
return None
f_root = doc.getroot()
base_form = f_root.find("f:BaseForm", FORM_NSMAP)
is_borrowed = base_form is not None
interceptors = []
# Form-level events with callType
events_node = f_root.find("f:Events", FORM_NSMAP)
if events_node is not None:
for evt in events_node.findall("f:Event", FORM_NSMAP):
ct = evt.get("callType", "")
if ct:
evt_name = evt.get("name", "")
evt_text = evt.text or ""
interceptors.append(f"Event:{evt_name} [{ct}] -> {evt_text}")
# Element-level events with callType (scan all elements recursively)
child_items = f_root.find("f:ChildItems", FORM_NSMAP)
if child_items is not None:
# Walk all descendant elements looking for Events/Event[@callType]
f_ns = FORM_NSMAP["f"]
for el in child_items.iter():
if not isinstance(el.tag, str):
continue
el_name = el.get("name", "")
if not el_name:
continue
events_sub = el.find(f"{{{f_ns}}}Events")
if events_sub is None:
continue
for evt in events_sub.findall(f"{{{f_ns}}}Event"):
ct = evt.get("callType", "")
if ct:
evt_name = evt.get("name", "")
evt_text = evt.text or ""
interceptors.append(f"Element:{el_name}.{evt_name} [{ct}] -> {evt_text}")
# Commands with callType on Action
f_ns = FORM_NSMAP["f"]
cmds_node = f_root.find(f"{{{f_ns}}}Commands")
if cmds_node is not None:
for cmd in cmds_node.findall(f"{{{f_ns}}}Command"):
cmd_name = cmd.get("name", "")
for action in cmd.findall(f"{{{f_ns}}}Action"):
ct = action.get("callType", "")
if ct:
action_text = action.text or ""
interceptors.append(f"Command:{cmd_name} [{ct}] -> {action_text}")
return {
"IsBorrowed": is_borrowed,
"Interceptors": interceptors,
}
# --- Mode A: Extension overview ---
def mode_a(objects, extension_path):
borrowed_list = []
own_list = []
for obj in objects:
info = get_object_info(obj["Type"], obj["Name"], extension_path)
if info is None:
print(f" [?] {obj['Type']}.{obj['Name']} \u2014 unknown type")
continue
if not info["Exists"]:
print(f" [?] {obj['Type']}.{obj['Name']} \u2014 file not found")
continue
if info["Borrowed"]:
borrowed_list.append(obj)
print(f" [BORROWED] {obj['Type']}.{obj['Name']}")
# Find .bsl files and interceptors
bsl_files = get_bsl_files(obj["Type"], obj["Name"], extension_path)
for bsl in bsl_files:
rel_path = bsl.replace(extension_path, "").lstrip("\\/")
interceptor_list = get_interceptors(bsl)
if len(interceptor_list) > 0:
for ic in interceptor_list:
print(f' &{ic["Type"]}("{ic["Method"]}") \u2014 line {ic["Line"]} in {rel_path}')
else:
print(f" {rel_path} (no interceptors)")
# Check for own attributes/forms in ChildObjects
obj_el = info.get("ObjElement")
if obj_el is not None:
child_obj = obj_el.find("md:ChildObjects", MD_NSMAP)
if child_obj is not None:
own_attrs = 0
own_forms = 0
own_ts = 0
borrowed_items = 0
form_names = []
for c in child_obj:
if not isinstance(c.tag, str):
continue
ln = etree.QName(c.tag).localname
c_props = c.find("md:Properties", MD_NSMAP)
if c_props is not None:
c_ob = c_props.find("md:ObjectBelonging", MD_NSMAP)
if c_ob is not None and c_ob.text == "Adopted":
borrowed_items += 1
continue
if ln == "Attribute":
own_attrs += 1
elif ln == "TabularSection":
own_ts += 1
elif ln == "Form":
form_names.append(c.text or "")
own_forms += 1
parts = []
if own_attrs > 0:
parts.append(f"{own_attrs} own attrs")
if own_ts > 0:
parts.append(f"{own_ts} own TS")
if own_forms > 0:
parts.append(f"{own_forms} own forms")
if borrowed_items > 0:
parts.append(f"{borrowed_items} borrowed items")
if len(parts) > 0:
print(f" ChildObjects: {', '.join(parts)}")
# Analyze forms
for fn in form_names:
form_xml_path = os.path.join(
extension_path, info["DirName"], info["Name"],
"Forms", fn, "Ext", "Form.xml"
)
fi = get_form_interceptors(form_xml_path)
if fi is None:
print(f" Form.{fn} (?)")
continue
form_tag = "borrowed" if fi["IsBorrowed"] else "own"
if len(fi["Interceptors"]) > 0:
print(f" Form.{fn} ({form_tag}):")
for ic in fi["Interceptors"]:
print(f" {ic}")
else:
print(f" Form.{fn} ({form_tag})")
else:
own_list.append(obj)
print(f" [OWN] {obj['Type']}.{obj['Name']}")
# Brief info for own objects
obj_el = info.get("ObjElement")
if obj_el is not None:
child_obj = obj_el.find("md:ChildObjects", MD_NSMAP)
if child_obj is not None:
attrs = 0
forms = 0
ts = 0
for c in child_obj:
if not isinstance(c.tag, str):
continue
ln = etree.QName(c.tag).localname
if ln == "Attribute":
attrs += 1
elif ln == "TabularSection":
ts += 1
elif ln == "Form":
forms += 1
parts = []
if attrs > 0:
parts.append(f"{attrs} attrs")
if ts > 0:
parts.append(f"{ts} TS")
if forms > 0:
parts.append(f"{forms} forms")
if len(parts) > 0:
print(f" {', '.join(parts)}")
print("")
print(f"=== Summary: {len(borrowed_list)} borrowed, {len(own_list)} own objects ===")
# --- Mode B: Transfer check ---
def mode_b(objects, extension_path, config_path):
transferred = 0
not_transferred = 0
needs_review = 0
for obj in objects:
info = get_object_info(obj["Type"], obj["Name"], extension_path)
if info is None or not info["Exists"] or not info["Borrowed"]:
continue
# Find .bsl files with &ИзменениеИКонтроль
bsl_files = get_bsl_files(obj["Type"], obj["Name"], extension_path)
for bsl in bsl_files:
interceptor_list = get_interceptors(bsl)
mac_interceptors = [ic for ic in interceptor_list if ic["Type"] == "\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c"]
if len(mac_interceptors) == 0:
continue
for ic in mac_interceptors:
method_name = ic["Method"]
rel_bsl = bsl.replace(extension_path, "").lstrip("\\/")
# Find #Вставка blocks in this file
insert_blocks = get_insertion_blocks(bsl)
if len(insert_blocks) == 0:
print(f' [NEEDS_REVIEW] {obj["Type"]}.{obj["Name"]} \u2014 &\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c("{method_name}") \u2014 no #\u0412\u0441\u0442\u0430\u0432\u043a\u0430 blocks')
needs_review += 1
continue
# Find corresponding module in config
if obj["Type"] not in CHILD_TYPE_DIR_MAP:
continue
config_bsl = bsl.replace(extension_path, config_path)
if not os.path.isfile(config_bsl):
print(f' [NEEDS_REVIEW] {obj["Type"]}.{obj["Name"]} \u2014 &\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c("{method_name}") \u2014 config module not found')
needs_review += 1
continue
with open(config_bsl, "r", encoding="utf-8-sig") as fh:
config_content = fh.read()
all_transferred = True
for block in insert_blocks:
code = block["Code"]
if not code:
continue
# Normalize whitespace for comparison
code_norm = re.sub(r'\s+', ' ', code)
config_norm = re.sub(r'\s+', ' ', config_content)
if code_norm not in config_norm:
all_transferred = False
if all_transferred:
print(f' [TRANSFERRED] {obj["Type"]}.{obj["Name"]} \u2014 &\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c("{method_name}") \u2014 {len(insert_blocks)} block(s)')
transferred += 1
else:
print(f' [NOT_TRANSFERRED] {obj["Type"]}.{obj["Name"]} \u2014 &\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435\u0418\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u044c("{method_name}") \u2014 some blocks not found in config')
not_transferred += 1
print("")
print(f"=== Transfer check: {transferred} transferred, {not_transferred} not transferred, {needs_review} needs review ===")
# --- Main ---
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Analyze and compare 1C configuration extension (CFE)", allow_abbrev=False)
parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
args = ci_parse_args(parser)
extension_path = args.ExtensionPath
config_path = args.ConfigPath
mode = args.Mode
# --- Resolve paths ---
if not os.path.isabs(extension_path):
extension_path = os.path.join(os.getcwd(), extension_path)
if not os.path.isabs(config_path):
config_path = os.path.join(os.getcwd(), config_path)
if os.path.isfile(extension_path):
extension_path = os.path.dirname(extension_path)
if os.path.isfile(config_path):
config_path = os.path.dirname(config_path)
ext_cfg = os.path.join(extension_path, "Configuration.xml")
src_cfg = os.path.join(config_path, "Configuration.xml")
if not os.path.isfile(ext_cfg):
print(f"Extension Configuration.xml not found: {ext_cfg}", file=sys.stderr)
sys.exit(1)
if not os.path.isfile(src_cfg):
print(f"Config Configuration.xml not found: {src_cfg}", file=sys.stderr)
sys.exit(1)
# --- Parse extension Configuration.xml ---
parser_xml = etree.XMLParser(remove_blank_text=False)
ext_doc = etree.parse(ext_cfg, parser_xml)
ext_root = ext_doc.getroot()
ext_props = ext_root.find(".//md:Configuration/md:Properties", MD_NSMAP)
ext_name_node = ext_props.find("md:Name", MD_NSMAP) if ext_props is not None else None
ext_name = ext_name_node.text if ext_name_node is not None and ext_name_node.text else "?"
prefix_node = ext_props.find("md:NamePrefix", MD_NSMAP) if ext_props is not None else None
name_prefix = prefix_node.text if prefix_node is not None and prefix_node.text else ""
purpose_node = ext_props.find("md:ConfigurationExtensionPurpose", MD_NSMAP) if ext_props is not None else None
purpose = purpose_node.text if purpose_node is not None and purpose_node.text else "?"
print(f"=== cfe-diff Mode {mode}: {ext_name} ({purpose}) ===")
print(f" NamePrefix: {name_prefix}")
print("")
# --- Collect ChildObjects ---
child_obj_node = ext_root.find(".//md:Configuration/md:ChildObjects", MD_NSMAP)
if child_obj_node is None:
print("[WARN] No ChildObjects in extension")
sys.exit(0)
objects = []
for child in child_obj_node:
if not isinstance(child.tag, str):
continue
ln = etree.QName(child.tag).localname
if ln == "Language":
continue
objects.append({"Type": ln, "Name": child.text or ""})
if len(objects) == 0:
print("No objects (besides Language) in extension.")
sys.exit(0)
# --- Run selected mode ---
if mode == "A":
mode_a(objects, extension_path)
elif mode == "B":
mode_b(objects, extension_path, config_path)
if __name__ == "__main__":
main()
+71
View File
@@ -0,0 +1,71 @@
---
name: cfe-init
description: Создать расширение конфигурации 1С (CFE) — scaffold XML-исходников. Используй когда нужно создать новое расширение для исправления, доработки или дополнения конфигурации
argument-hint: <Name> [-ConfigPath <path>] [-Purpose Patch|Customization|AddOn] [-CompatibilityMode Version8_3_24]
allowed-tools:
- Bash
- Read
- Glob
---
# /cfe-init — Создание расширения конфигурации 1С
Создаёт scaffold расширения: `Configuration.xml`, `Languages/Русский.xml`, опционально `Roles/`.
## Подготовка
Если есть выгрузка базовой конфигурации, передай `-ConfigPath` — скрипт автоматически определит `CompatibilityMode` и UUID языка из базовой конфигурации.
### Авто-определение ConfigPath
Если пользователь не указал `-ConfigPath` — попробуй определить автоматически:
1. Прочитай `.v8-project.json` из корня проекта
2. Разреши целевую базу (по имени, ветке или `default` — алгоритм из `/db-list`)
3. Если у базы есть поле `configSrc` — используй как `-ConfigPath`
4. Если `configSrc` нет — спроси у пользователя
Если `.v8-project.json` не найден и `-ConfigPath` не задан — расширение создастся с предупреждением (UUID языка = нули, CompatibilityMode по умолчанию).
## Параметры
| Параметр | Описание | По умолчанию |
|----------|----------|--------------|
| `Name` | Имя расширения (обязат.) | — |
| `Synonym` | Синоним | = Name |
| `NamePrefix` | Префикс собственных объектов | = Name + "_" |
| `OutputDir` | Каталог для создания; клади расширение в свой подкаталог, названный по имени: `src\cfe\<Name>` | `src` |
| `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` |
| `Version` | Версия расширения | — |
| `Vendor` | Поставщик | — |
| `CompatibilityMode` | Режим совместимости; при заданном `ConfigPath` определяется по базовой конфигурации и этот параметр не нужен | `Version8_3_24` |
| `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — |
| `NoRole` | Без основной роли | false |
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cfe-init/scripts/cfe-init.ps1" -Name "МоёРасширение" -OutputDir "src\cfe\МоёРасширение" -ConfigPath "src\cf"
```
## Примеры
```powershell
# Расширение для ERP с авто-определением совместимости из базовой конфигурации
... -Name Расш1 -ConfigPath src\cf -OutputDir src\cfe\Расш1
# Расширение-исправление с явным режимом совместимости
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src\cfe\Расш1
# Расширение-доработка с версией
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src\cfe\МоёРасширение
# Без роли, с явным префиксом
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src\cfe\ИсправлениеБага
```
## Верификация
```
/cfe-validate <OutputDir> -ConfigPath <ConfigPath>
```
+320
View File
@@ -0,0 +1,320 @@
# cfe-init v1.11 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory)]
[string]$Name,
[string]$Synonym = $Name,
[string]$NamePrefix,
[string]$OutputDir = "src",
[ValidateSet("Patch","Customization","AddOn")]
[string]$Purpose = "Customization",
[string]$Version,
[string]$Vendor,
[string]$CompatibilityMode = "Version8_3_24",
[string]$ConfigPath,
[switch]$NoRole
)
$ErrorActionPreference = "Stop"
function Esc-XmlText {
param([string]$s)
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Default NamePrefix ---
if (-not $NamePrefix) {
$NamePrefix = "${Name}_"
}
# --- Resolve output dir ---
if (-not [System.IO.Path]::IsPathRooted($OutputDir)) {
$OutputDir = Join-Path (Get-Location).Path $OutputDir
}
# --- Check existing ---
$cfgFile = Join-Path $OutputDir "Configuration.xml"
if (Test-Path $cfgFile) {
Write-Error "Configuration.xml already exists: $cfgFile"
exit 1
}
# MDClasses format version — inherited from the base config so the extension stays uniform
# with it (a 2.13 base must yield a 2.13 extension, else platform import rejects the mismatch).
$formatVersion = "2.17"
# --- Resolve ConfigPath ---
$baseLangUuid = "00000000-0000-0000-0000-000000000000"
if ($ConfigPath) {
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
$ConfigPath = Join-Path (Get-Location).Path $ConfigPath
}
if (Test-Path $ConfigPath -PathType Container) {
$candidate = Join-Path $ConfigPath "Configuration.xml"
if (Test-Path $candidate) { $ConfigPath = $candidate }
else { Write-Error "No Configuration.xml in config directory: $ConfigPath"; exit 1 }
}
if (-not (Test-Path $ConfigPath)) { Write-Error "Config file not found: $ConfigPath"; exit 1 }
$cfgDir = Split-Path (Resolve-Path $ConfigPath).Path -Parent
# 3a. Read Language UUID from base config
$baseLangFile = Join-Path (Join-Path $cfgDir "Languages") "Русский.xml"
if (Test-Path $baseLangFile) {
$baseLangDoc = New-Object System.Xml.XmlDocument
$baseLangDoc.PreserveWhitespace = $false
$baseLangDoc.Load($baseLangFile)
$langEl = $null
foreach ($c in $baseLangDoc.DocumentElement.ChildNodes) {
if ($c.NodeType -eq 'Element' -and $c.LocalName -eq 'Language') { $langEl = $c; break }
}
if ($langEl) {
$baseLangUuid = $langEl.GetAttribute("uuid")
Write-Host "[INFO] Base config Language UUID: $baseLangUuid"
} else {
Write-Host "[WARN] No <Language> element in $baseLangFile"
}
} else {
Write-Host "[WARN] Base config language not found: $baseLangFile"
}
# 3b. Read CompatibilityMode and InterfaceCompatibilityMode from base config
$baseCfgDoc = New-Object System.Xml.XmlDocument
$baseCfgDoc.PreserveWhitespace = $false
$baseCfgDoc.Load((Resolve-Path $ConfigPath).Path)
$baseCfgNs = New-Object System.Xml.XmlNamespaceManager($baseCfgDoc.NameTable)
$baseCfgNs.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$fmtVer = $baseCfgDoc.DocumentElement.GetAttribute("version")
if ($fmtVer) {
$formatVersion = $fmtVer
Write-Host "[INFO] Base config format version: $formatVersion"
}
$compatNode = $baseCfgDoc.SelectSingleNode("//md:Configuration/md:Properties/md:CompatibilityMode", $baseCfgNs)
if ($compatNode -and $compatNode.InnerText) {
$CompatibilityMode = $compatNode.InnerText.Trim()
Write-Host "[INFO] Base config CompatibilityMode: $CompatibilityMode"
} else {
Write-Host "[WARN] CompatibilityMode not found in base config, using default: $CompatibilityMode"
}
$ifcNode = $baseCfgDoc.SelectSingleNode("//md:Configuration/md:Properties/md:InterfaceCompatibilityMode", $baseCfgNs)
if ($ifcNode -and $ifcNode.InnerText) {
$InterfaceCompatibilityMode = $ifcNode.InnerText.Trim()
Write-Host "[INFO] Base config InterfaceCompatibilityMode: $InterfaceCompatibilityMode"
} else {
$InterfaceCompatibilityMode = "TaxiEnableVersion8_2"
Write-Host "[WARN] InterfaceCompatibilityMode not found in base config, using default: $InterfaceCompatibilityMode"
}
} else {
$InterfaceCompatibilityMode = "TaxiEnableVersion8_2"
Write-Host "[WARN] Language ExtendedConfigurationObject set to zeros. Use -ConfigPath to auto-resolve from base config, or fix manually before loading."
}
# --- Generate UUIDs ---
$uuidCfg = [guid]::NewGuid().ToString()
$uuidLang = [guid]::NewGuid().ToString()
$uuidRole = [guid]::NewGuid().ToString()
# 7 ContainedObject ObjectIds
$co1 = [guid]::NewGuid().ToString()
$co2 = [guid]::NewGuid().ToString()
$co3 = [guid]::NewGuid().ToString()
$co4 = [guid]::NewGuid().ToString()
$co5 = [guid]::NewGuid().ToString()
$co6 = [guid]::NewGuid().ToString()
$co7 = [guid]::NewGuid().ToString()
# --- Synonym XML ---
$synonymXml = ""
if ($Synonym) {
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
}
# --- Optional properties ---
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
# пишет <Vendor/>, а не <Vendor></Vendor>.
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
# --- Role name ---
$roleName = "${NamePrefix}ОсновнаяРоль"
# --- DefaultRoles XML ---
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
$defaultRolesEl = "<DefaultRoles/>"
if (-not $NoRole) {
$defaultRolesEl = "<DefaultRoles>`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t</DefaultRoles>"
}
# --- ChildObjects ---
$childObjectsXml = "`r`n`t`t`t<Language>Русский</Language>"
if (-not $NoRole) {
$childObjectsXml += "`r`n`t`t`t<Role>$roleName</Role>"
}
$childObjectsXml += "`r`n`t`t"
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
# дописать в конец нельзя.
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
$f221Captions = ""
if ((Get-FormatRank $formatVersion) -ge 221) {
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
$f221Captions = "`r`n`t`t`t<Caption/>`r`n`t`t`t<ShortCaption/>"
}
# --- Configuration.xml ---
$cfgXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $xmlnsDecl version="$formatVersion">
<Configuration uuid="$uuidCfg">
<InternalInfo>
<xr:ContainedObject>
<xr:ClassId>9cd510cd-abfc-11d4-9434-004095e12fc7</xr:ClassId>
<xr:ObjectId>$co1</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>9fcd25a0-4822-11d4-9414-008048da11f9</xr:ClassId>
<xr:ObjectId>$co2</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>e3687481-0a87-462c-a166-9f34594f9bba</xr:ClassId>
<xr:ObjectId>$co3</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>9de14907-ec23-4a07-96f0-85521cb6b53b</xr:ClassId>
<xr:ObjectId>$co4</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>51f2d5d8-ea4d-4064-8892-82951750031e</xr:ClassId>
<xr:ObjectId>$co5</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>e68182ea-4237-4383-967f-90c1e3370bc7</xr:ClassId>
<xr:ObjectId>$co6</xr:ObjectId>
</xr:ContainedObject>
<xr:ContainedObject>
<xr:ClassId>fb282519-d103-4dd3-bc12-cb271d631dfc</xr:ClassId>
<xr:ObjectId>$co7</xr:ObjectId>
</xr:ContainedObject>
</InternalInfo>
<Properties>
<ObjectBelonging>Adopted</ObjectBelonging>
<Name>$(Esc-XmlText ($Name))</Name>
<Synonym>$synonymXml</Synonym>
<Comment/>
<ConfigurationExtensionPurpose>$Purpose</ConfigurationExtensionPurpose>
<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
<NamePrefix>$(Esc-XmlText ($NamePrefix))</NamePrefix>
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
<DefaultRunMode>ManagedApplication</DefaultRunMode>
<UsePurposes>
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
</UsePurposes>
<ScriptVariant>Russian</ScriptVariant>
$defaultRolesEl
$vendorEl
$versionEl$f221Captions
<DefaultLanguage>Language.Русский</DefaultLanguage>
<BriefInformation/>
<DetailedInformation/>
<Copyright/>
<VendorInformationAddress/>
<ConfigurationInformationAddress/>
<InterfaceCompatibilityMode>$InterfaceCompatibilityMode</InterfaceCompatibilityMode>
</Properties>
<ChildObjects>$childObjectsXml</ChildObjects>
</Configuration>
</MetaDataObject>
"@
# --- Languages/Русский.xml (adopted format) ---
$langXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $xmlnsDecl version="$formatVersion">
<Language uuid="$uuidLang">
<InternalInfo/>
<Properties>
<ObjectBelonging>Adopted</ObjectBelonging>
<Name>Русский</Name>
<Comment/>
<ExtendedConfigurationObject>$baseLangUuid</ExtendedConfigurationObject>
<LanguageCode>ru</LanguageCode>
</Properties>
</Language>
</MetaDataObject>
"@
# --- Role XML ---
$roleXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $xmlnsDecl version="$formatVersion">
<Role uuid="$uuidRole">
<Properties>
<Name>$(Esc-XmlText ($roleName))</Name>
<Synonym/>
<Comment/>
</Properties>
</Role>
</MetaDataObject>
"@
# --- Create directories ---
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}
$langDir = Join-Path $OutputDir "Languages"
if (-not (Test-Path $langDir)) {
New-Item -ItemType Directory -Path $langDir -Force | Out-Null
}
# --- Write files with UTF-8 BOM ---
$enc = New-Object System.Text.UTF8Encoding($true)
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile $cfgFile $cfgXml $enc
$langFile = Join-Path $langDir "Русский.xml"
Write-XmlFile $langFile $langXml $enc
# --- Role ---
if (-not $NoRole) {
$roleDir = Join-Path $OutputDir "Roles"
if (-not (Test-Path $roleDir)) {
New-Item -ItemType Directory -Path $roleDir -Force | Out-Null
}
$roleFile = Join-Path $roleDir "$roleName.xml"
Write-XmlFile $roleFile $roleXml $enc
}
# --- Output ---
Write-Host "[OK] Создано расширение: $Name"
Write-Host " Каталог: $OutputDir"
Write-Host " Назначение: $Purpose"
Write-Host " Префикс: $NamePrefix"
Write-Host " Совместимость: $CompatibilityMode"
Write-Host " Configuration.xml: $cfgFile"
Write-Host " Languages: $langFile"
if (-not $NoRole) {
Write-Host " Role: $roleFile"
}
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
# cfe-init v1.11 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C configuration extension."""
import sys, os, re, argparse, uuid
from xml.etree import ElementTree as ET
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def esc_xml_text(s):
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def new_uuid():
return str(uuid.uuid4())
def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description='Create 1C configuration extension scaffold', allow_abbrev=False)
parser.add_argument('-Name', dest='Name', required=True)
parser.add_argument('-Synonym', dest='Synonym', default=None)
parser.add_argument('-NamePrefix', dest='NamePrefix', default=None)
parser.add_argument('-OutputDir', dest='OutputDir', default='src')
parser.add_argument('-Purpose', dest='Purpose', default='Customization', choices=['Patch','Customization','AddOn'])
parser.add_argument('-Version', dest='Version', default='')
parser.add_argument('-Vendor', dest='Vendor', default='')
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
parser.add_argument('-ConfigPath', dest='ConfigPath', default=None)
parser.add_argument('-NoRole', dest='NoRole', action='store_true')
args = ci_parse_args(parser)
name = args.Name
synonym = args.Synonym if args.Synonym else name
name_prefix = args.NamePrefix if args.NamePrefix else f"{name}_"
output_dir = args.OutputDir
purpose = args.Purpose
version = args.Version
vendor = args.Vendor
compat = args.CompatibilityMode
# --- Resolve output dir ---
if not os.path.isabs(output_dir):
output_dir = os.path.join(os.getcwd(), output_dir)
# --- Check existing ---
cfg_file = os.path.join(output_dir, "Configuration.xml")
if os.path.exists(cfg_file):
print(f"Configuration.xml already exists: {cfg_file}", file=sys.stderr)
sys.exit(1)
# MDClasses format version — inherited from the base config so the extension stays uniform
# with it (a 2.13 base must yield a 2.13 extension, else platform import rejects the mismatch).
format_version = "2.17"
# --- Resolve ConfigPath ---
base_lang_uuid = "00000000-0000-0000-0000-000000000000"
if args.ConfigPath:
config_path = args.ConfigPath
if not os.path.isabs(config_path):
config_path = os.path.join(os.getcwd(), config_path)
if os.path.isdir(config_path):
candidate = os.path.join(config_path, "Configuration.xml")
if os.path.exists(candidate):
config_path = candidate
else:
print(f"No Configuration.xml in config directory: {config_path}", file=sys.stderr)
sys.exit(1)
if not os.path.exists(config_path):
print(f"Config file not found: {config_path}", file=sys.stderr)
sys.exit(1)
cfg_dir = os.path.dirname(os.path.abspath(config_path))
# Read Language UUID from base config
base_lang_file = os.path.join(cfg_dir, "Languages", "Русский.xml")
if os.path.exists(base_lang_file):
try:
base_tree = ET.parse(base_lang_file)
base_root = base_tree.getroot()
for child in base_root:
if child.tag.endswith('}Language') or child.tag == 'Language':
base_lang_uuid = child.get('uuid', base_lang_uuid)
print(f"[INFO] Base config Language UUID: {base_lang_uuid}")
break
except Exception:
print(f"[WARN] Could not parse {base_lang_file}")
else:
print(f"[WARN] Base config language not found: {base_lang_file}")
# Read CompatibilityMode and InterfaceCompatibilityMode from base config
try:
base_cfg_tree = ET.parse(os.path.abspath(config_path))
base_cfg_root = base_cfg_tree.getroot()
fmt_ver = base_cfg_root.get("version")
if fmt_ver:
format_version = fmt_ver
print(f"[INFO] Base config format version: {format_version}")
ns = {'md': 'http://v8.1c.ru/8.3/MDClasses'}
compat_node = base_cfg_root.find('.//md:Configuration/md:Properties/md:CompatibilityMode', ns)
if compat_node is not None and compat_node.text:
compat = compat_node.text.strip()
print(f"[INFO] Base config CompatibilityMode: {compat}")
else:
print(f"[WARN] CompatibilityMode not found in base config, using default: {compat}")
ifc_node = base_cfg_root.find('.//md:Configuration/md:Properties/md:InterfaceCompatibilityMode', ns)
if ifc_node is not None and ifc_node.text:
ifc_mode = ifc_node.text.strip()
print(f"[INFO] Base config InterfaceCompatibilityMode: {ifc_mode}")
else:
ifc_mode = "TaxiEnableVersion8_2"
print(f"[WARN] InterfaceCompatibilityMode not found in base config, using default: {ifc_mode}")
except Exception:
print(f"[WARN] Could not parse base config, using default CompatibilityMode: {compat}")
ifc_mode = "TaxiEnableVersion8_2"
else:
ifc_mode = "TaxiEnableVersion8_2"
print("[WARN] Language ExtendedConfigurationObject set to zeros. Use -ConfigPath to auto-resolve from base config, or fix manually before loading.")
# --- Generate UUIDs ---
uuid_cfg = new_uuid()
uuid_lang = new_uuid()
uuid_role = new_uuid()
co = [new_uuid() for _ in range(7)]
# --- Synonym XML ---
synonym_xml = ""
if synonym:
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
# пишет <Vendor/>, а не <Vendor></Vendor>.
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
# --- Role name ---
role_name = f"{name_prefix}ОсновнаяРоль"
# --- DefaultRoles XML ---
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
default_roles_el = "<DefaultRoles/>"
if not args.NoRole:
default_roles_el = ('<DefaultRoles>\r\n\t\t\t\t'
f'<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>'
'\r\n\t\t\t</DefaultRoles>')
# --- ChildObjects ---
child_objects_xml = f"\r\n\t\t\t<Language>Русский</Language>"
if not args.NoRole:
child_objects_xml += f"\r\n\t\t\t<Role>{role_name}</Role>"
child_objects_xml += "\r\n\t\t"
class_ids = [
"9cd510cd-abfc-11d4-9434-004095e12fc7",
"9fcd25a0-4822-11d4-9414-008048da11f9",
"e3687481-0a87-462c-a166-9f34594f9bba",
"9de14907-ec23-4a07-96f0-85521cb6b53b",
"51f2d5d8-ea4d-4064-8892-82951750031e",
"e68182ea-4237-4383-967f-90c1e3370bc7",
"fb282519-d103-4dd3-bc12-cb271d631dfc",
]
contained_objects = ""
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
xmlns_decl = (
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
)
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
# дописать в конец нельзя.
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
f221_captions = ""
if format_rank(format_version) >= 221:
xmlns_decl = xmlns_decl.replace(
' xmlns:style=',
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
for i in range(7):
contained_objects += f"""\t\t\t<xr:ContainedObject>
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
\t\t\t\t<xr:ObjectId>{co[i]}</xr:ObjectId>
\t\t\t</xr:ContainedObject>\n"""
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {xmlns_decl} version="{format_version}">
\t<Configuration uuid="{uuid_cfg}">
\t\t<InternalInfo>
{contained_objects}\t\t</InternalInfo>
\t\t<Properties>
\t\t\t<ObjectBelonging>Adopted</ObjectBelonging>
\t\t\t<Name>{esc_xml_text(name)}</Name>
\t\t\t<Synonym>{synonym_xml}</Synonym>
\t\t\t<Comment/>
\t\t\t<ConfigurationExtensionPurpose>{purpose}</ConfigurationExtensionPurpose>
\t\t\t<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
\t\t\t<NamePrefix>{esc_xml_text(name_prefix)}</NamePrefix>
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
\t\t\t<UsePurposes>
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
\t\t\t</UsePurposes>
\t\t\t<ScriptVariant>Russian</ScriptVariant>
\t\t\t{default_roles_el}
\t\t\t{vendor_el}
\t\t\t{version_el}{f221_captions}
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
\t\t\t<BriefInformation/>
\t\t\t<DetailedInformation/>
\t\t\t<Copyright/>
\t\t\t<VendorInformationAddress/>
\t\t\t<ConfigurationInformationAddress/>
\t\t\t<InterfaceCompatibilityMode>{ifc_mode}</InterfaceCompatibilityMode>
\t\t</Properties>
\t\t<ChildObjects>{child_objects_xml}</ChildObjects>
\t</Configuration>
</MetaDataObject>'''
# --- Languages/Русский.xml (adopted format) ---
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {xmlns_decl} version="{format_version}">
\t<Language uuid="{uuid_lang}">
\t\t<InternalInfo/>
\t\t<Properties>
\t\t\t<ObjectBelonging>Adopted</ObjectBelonging>
\t\t\t<Name>Русский</Name>
\t\t\t<Comment/>
\t\t\t<ExtendedConfigurationObject>{base_lang_uuid}</ExtendedConfigurationObject>
\t\t\t<LanguageCode>ru</LanguageCode>
\t\t</Properties>
\t</Language>
</MetaDataObject>'''
# --- Role XML ---
role_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {xmlns_decl} version="{format_version}">
\t<Role uuid="{uuid_role}">
\t\t<Properties>
\t\t\t<Name>{esc_xml_text(role_name)}</Name>
\t\t\t<Synonym/>
\t\t\t<Comment/>
\t\t</Properties>
\t</Role>
</MetaDataObject>'''
# --- Create directories ---
os.makedirs(output_dir, exist_ok=True)
lang_dir = os.path.join(output_dir, "Languages")
os.makedirs(lang_dir, exist_ok=True)
# --- Write files ---
write_xml_file(cfg_file, cfg_xml)
lang_file = os.path.join(lang_dir, "Русский.xml")
write_xml_file(lang_file, lang_xml)
# --- Role ---
role_file = None
if not args.NoRole:
role_dir = os.path.join(output_dir, "Roles")
os.makedirs(role_dir, exist_ok=True)
role_file = os.path.join(role_dir, f"{role_name}.xml")
write_xml_file(role_file, role_xml)
# --- Output ---
print(f"[OK] Создано расширение: {name}")
print(f" Каталог: {output_dir}")
print(f" Назначение: {purpose}")
print(f" Префикс: {name_prefix}")
print(f" Совместимость: {compat}")
print(f" Configuration.xml: {cfg_file}")
print(f" Languages: {lang_file}")
if role_file:
print(f" Role: {role_file}")
if __name__ == '__main__':
main()
+145
View File
@@ -0,0 +1,145 @@
---
name: cfe-patch-method
description: Генерация и актуализация перехватчика метода в расширении 1С (CFE). Используй когда нужно перехватить метод заимствованного объекта — вставить код до, после, вместо оригинала, изменить его тело (ИзменениеИКонтроль) — или актуализировать перехватчик после изменения оригинала
argument-hint: -ExtensionPath <path> -ConfigPath <path> -ModulePath "Catalog.X.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
allowed-tools:
- Bash
- Read
- Glob
---
# /cfe-patch-method — Генерация перехватчика метода
Читает оригинал метода из конфигурации-источника и генерирует `.bsl`-перехватчик заимствованного объекта: с правильной директивой контекста, полной сигнатурой, обрамляющими инструкциями препроцессора и областями. Для `ИзменениеИКонтроль` копирует всё тело оригинала. Создаёт файл модуля, дописывает в существующий или актуализирует уже заимствованный метод.
## Предусловие
Объект должен быть заимствован в расширение (`/cfe-borrow`). Нужен доступ к исходникам базовой конфигурации (`-ConfigPath`) — оттуда читается оригинал метода. `NamePrefix` берётся из `Configuration.xml` расширения.
### Авто-определение ConfigPath
Если пользователь не указал `-ConfigPath` — попробуй определить автоматически:
1. Прочитай `.v8-project.json` из корня проекта
2. Разреши целевую базу (по имени, ветке или `default` — алгоритм из `/db-list`)
3. Если у базы есть поле `configSrc` — используй как `-ConfigPath`
4. Если `configSrc` нет — спроси у пользователя
## Параметры
| Параметр | Описание | По умолчанию |
|----------|----------|--------------|
| `ExtensionPath` | Путь к расширению (обязат.) | — |
| `ConfigPath` | Путь к конфигурации-источнику | обязат., кроме случая, когда `ModulePath` — путь к файлу |
| `ModulePath` | Логическое имя (`Тип.Имя.Модуль`) **или** путь к файлу модуля `.bsl` | обязат. для генерации |
| `MethodName` | Имя перехватываемого метода | обязат. для генерации |
| `InterceptorType` | `Before` / `After` / `Instead` / `ModificationAndControl` | обязат. для генерации |
| `Check` | Проверить контролируемые методы на дрейф (только отчёт) | — |
| `Actualize` | Актуализировать дрейфнувшие контролируемые методы | — |
## Формат ModulePath
| ModulePath | Файл |
|------------|------|
| `Catalog.X.ObjectModule` | `Catalogs/X/Ext/ObjectModule.bsl` |
| `Catalog.X.ManagerModule` | `Catalogs/X/Ext/ManagerModule.bsl` |
| `Catalog.X.Form.Y` | `Catalogs/X/Forms/Y/Ext/Form/Module.bsl` |
| `CommonModule.X` | `CommonModules/X/Ext/Module.bsl` |
| `Document.X.ObjectModule` | `Documents/X/Ext/ObjectModule.bsl` |
| `Document.X.Form.Y` | `Documents/X/Forms/Y/Ext/Form/Module.bsl` |
Аналогично для Report, DataProcessor, InformationRegister и других типов.
Вместо логического имени в `ModulePath` можно передать **путь к файлу** модуля-источника `.bsl` — тогда `-ConfigPath` указывать не нужно (оригинал читается прямо из этого файла). Путь модуля расширения определяется от типовой папки в пути автоматически.
## Типы перехвата
| InterceptorType | Декоратор | Назначение | Применим к |
|-----------------|-----------|------------|------------|
| `Before` | `&Перед` | Код до вызова оригинального метода | процедуры |
| `After` | `&После` | Код после вызова оригинального метода | процедуры |
| `Instead` | `&Вместо` | Замена метода; в теле — скаффолд `ПродолжитьВызов(...)` | процедуры и функции |
| `ModificationAndControl` | `&ИзменениеИКонтроль` | Копия тела оригинала для правки маркерами `#Вставка`/`#Удаление` | процедуры и функции |
## Маркеры `#Вставка` / `#Удаление` (для `ModificationAndControl`)
`&ИзменениеИКонтроль` вставляет в перехватчик **копию тела оригинала**. Дальше отредактируй тело под свою доработку, **помечая каждое изменение** — платформа так отличает твою правку от неизменного оригинала:
- **Добавляешь код** → оберни его `#Вставка``#КонецВставки`.
- **Удаляешь код оригинала** → оберни удаляемые строки `#Удаление``#КонецУдаления`, но сами строки **оставь между маркерами** (платформа сверяет их с оригиналом).
- **Заменяешь** → `#Удаление` старое `#КонецУдаления` сразу за ним `#Вставка` новое `#КонецВставки`.
Пример:
```bsl
&ИзменениеИКонтроль("ПриЗаписи")
Процедура Расш_ПриЗаписи(Отказ)
СуммаДокумента = РассчитатьСумму();
#Вставка
// доработка: округляем
СуммаДокумента = Окр(СуммаДокумента, 2);
#КонецВставки
#Удаление
Записать();
#КонецУдаления
#Вставка
ЗаписатьСПроверкой(Отказ);
#КонецВставки
КонецПроцедуры
```
Правила:
- Маркеры — на **отдельной строке с 0-й колонки** (без отступа), даже внутри отступов и текста запроса (`|…`).
- **Незамеченные (unmarked) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`).
- Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай.
## Актуализация
После изменения оригинала в конфигурации-источнике перехватчик `&ИзменениеИКонтроль` может рассинхронизироваться — unmarked-контекст разойдётся с новым оригиналом. Платформа при загрузке об этом молчит, поэтому актуализируй сам:
- **Проверить** — `-Check`: отчёт по всем контролируемым методам расширения, ничего не пишет (`exit 1`, если есть дрейф или конфликт).
- **Актуализировать** — `-Actualize`: переносит правки (`#Вставка`/`#Удаление`) на новый оригинал по всему расширению. Сузить область: `-ModulePath` — один модуль, `+ -MethodName` — один метод. Для одного метода то же делает повторный вызов с `-InterceptorType ModificationAndControl`.
Статусы в выводе:
- `[АКТУАЛЕН]` — оригинал не менялся, правок нет;
- `[АКТУАЛИЗИРОВАН]` — тело обновлено по новому оригиналу, правки сохранены (в выводе — сводка);
- `[АКТУАЛИЗИРОВАН-ЧАСТИЧНО]` — часть правок не удалось разместить (якорь изменился в новом оригинале). Они помечены `// [РЕСИНК-КОНФЛИКТ]` в модуле и не потеряны; путь к merge-воркспейсу — в выводе (начни с `index.md`, дальше по каждому конфликту его `conflict.md`, размести блоки вручную в `.bsl` расширения);
- `[ПЕРЕНЕСЕНО В ОСНОВНУЮ]` — правка уже есть в новом оригинале (вставленный код внесён вендором) или удаляемый блок уже вырезан. Правка неактуальна — убирается из тела, дублировать/конфликтовать не нужно. Если так со всеми правками метода — перехватчик можно удалить. `-Check` этим не роняет `exit`.
Повторный вызов `Before`/`After`/`Instead` для уже перехваченного метода дубль не создаёт (`[ПРОПУЩЕН]`).
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cfe-patch-method/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
```
## Примеры
```powershell
# Код перед записью
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
# Перехват После на форме
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
# Замена функции (ПродолжитьВызов)
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
# ИзменениеИКонтроль — копия тела для правки маркерами
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
... -ExtensionPath src\cfe\extname -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
# Проверить все контролируемые методы расширения на дрейф
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Check
# Актуализировать дрейфнувшие контролируемые методы пачкой
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Actualize
```
## Верификация
```
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
---
name: cfe-validate
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
argument-hint: <ExtensionPath> [-ConfigPath <ConfigDir>] [-Detailed] [-MaxErrors 30]
allowed-tools:
- Bash
- Read
- Glob
---
# /cfe-validate — валидация расширения конфигурации (CFE)
Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты, права ролей. Аналог `/cf-validate`, но для расширений.
## Параметры
| Параметр | Обяз. | Умолч. | Описание |
|---------------|:-----:|---------|-------------------------------------------------|
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
| ConfigPath | нет | — | Каталог конфигурации, из которой заимствованы объекты |
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
| MaxErrors | нет | 30 | Остановиться после N ошибок |
| OutFile | нет | — | Записать результат в файл |
### ConfigPath
Указывай всегда, когда конфигурация-источник доступна: без неё часть ошибок заимствованных форм не ловится, и расширение может пройти валидацию, а потом быть отвергнутым платформой при загрузке.
Если пользователь не указал путь — определи сам:
1. Прочитай `.v8-project.json` из корня проекта
2. Разреши целевую базу (по имени, ветке или `default`)
3. Возьми её поле `configSrc`
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname\Configuration.xml"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf"
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
---
name: db-create
description: Создание информационной базы 1С. Используй когда нужно создать базу, новую ИБ, пустую базу
argument-hint: <path|name>
allowed-tools:
- Bash
- Read
- Write
- Glob
- AskUserQuestion
---
# /db-create — Создание информационной базы
Создаёт новую информационную базу 1С (файловую или серверную) и предлагает зарегистрировать в `.v8-project.json`.
## Usage
```
/db-create <path> — файловая база по указанному пути
/db-create <server>/<name> — серверная база
/db-create — интерактивно
```
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта для `v8path` (путь к платформе).
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
После создания базы предложи зарегистрировать через `/db-list add`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-create/scripts/db-create.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Путь к файловой базе |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UseTemplate <файл>` | нет | Создать из шаблона (.cf или .dt) |
| `-AddToList` | нет | Добавить в список баз 1С |
| `-ListName <имя>` | нет | Имя базы в списке |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
## После создания
Предложи зарегистрировать базу в `.v8-project.json` (через `/db-list add`)
3. Если указан шаблон `/UseTemplate` — предупреди что конфигурация будет загружена из шаблона
## Примеры
```powershell
# Создать файловую базу
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-create/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB"
# Создать серверную базу
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-create/scripts/db-create.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test"
# Создать из шаблона CF
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-create/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf"
# Создать и добавить в список баз
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-create/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -AddToList -ListName "Новая база"
```
@@ -0,0 +1,476 @@
# db-create v1.14 — Create 1C information base
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Создание информационной базы 1С
.DESCRIPTION
Создаёт новую информационную базу 1С (файловую или серверную).
Поддерживает создание из шаблона и добавление в список баз.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UseTemplate
Путь к файлу шаблона (.cf или .dt)
.PARAMETER AddToList
Добавить в список баз 1С
.PARAMETER ListName
Имя базы в списке
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB"
.EXAMPLE
.\db-create.ps1 -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test"
.EXAMPLE
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UseTemplate,
[Parameter(Mandatory=$false)]
[switch]$AddToList,
[Parameter(Mandatory=$false)]
[string]$ListName,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$UseTemplate = ConvertTo-CleanPath $UseTemplate '-UseTemplate'
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
function Test-FileIbCreated {
# File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
# Exit code 0 without it (broken/headless env) is a false success — reject it.
param([string]$IbPath)
$f = Join-Path $IbPath "1Cv8.1CD"
return (Test-Path $f) -and ((Get-Item $f -ErrorAction SilentlyContinue).Length -gt 0)
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/UseTemplate' = '-UseTemplate'; '/AddToList' = '-AddToList'; '--db-path' = '-InfoBasePath'; '--load' = '-UseTemplate'; '--restore' = '-UseTemplate' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
# --- Validate connection ---
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Validate template ---
if ($UseTemplate -and -not (Test-Path $UseTemplate)) {
Write-Host "Error: template file not found: $UseTemplate" -ForegroundColor Red
exit 1
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "db_create_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch (file infobase only) ---
$arguments = @("infobase", "create", "--db-path=$InfoBasePath", "--create-database")
if ($UseTemplate) {
if ([System.IO.Path]::GetExtension($UseTemplate) -ieq ".dt") {
$arguments += "--restore=$UseTemplate"
} else {
$arguments += "--load=$UseTemplate", "--apply"
}
}
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
$ibMissing = ($exitCode -eq 0) -and -not (Test-FileIbCreated $InfoBasePath)
if ($ibMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
} elseif ($ibMissing) {
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
} else {
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $output
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("CREATEINFOBASE")
# Quotes go INSIDE the token (File="path"): 1C's own parser wants them there, quoting
# the whole token instead breaks a path with spaces. Hence -PreQuoted on the launch.
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "Srvr=`"$InfoBaseServer`";Ref=`"$InfoBaseRef`""
} else {
$arguments += "File=`"$InfoBasePath`""
}
# --- Template ---
if ($UseTemplate) {
$arguments += "/UseTemplate", "`"$UseTemplate`""
}
# --- Add to list ---
if ($AddToList) {
if ($ListName) {
$arguments += "/AddToList", "`"$ListName`""
} else {
$arguments += "/AddToList"
}
}
# --- Output ---
$outFile = Join-Path $tempDir "create_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
$ibMissing = ($exitCode -eq 0) -and -not ($InfoBaseServer -and $InfoBaseRef) -and -not (Test-FileIbCreated $InfoBasePath)
if ($ibMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
if ($InfoBaseServer -and $InfoBaseRef) {
Write-Host "Information base created successfully: $InfoBaseServer/$InfoBaseRef" -ForegroundColor Green
} else {
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
}
} elseif ($ibMissing) {
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
} else {
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
}
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,538 @@
#!/usr/bin/env python3
# db-create v1.14 — Create 1C information base
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter — catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error — reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply — a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
def file_ib_created(ib_path):
"""File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
Exit code 0 without it (broken/headless env) is a false success — reject it."""
f = os.path.join(ib_path, "1Cv8.1CD")
return os.path.isfile(f) and os.path.getsize(f) > 0
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block —
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path — reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Create 1C information base",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="")
parser.add_argument("-InfoBasePath", default="")
parser.add_argument("-InfoBaseServer", default="")
parser.add_argument("-InfoBaseRef", default="")
parser.add_argument("-UseTemplate", default="")
parser.add_argument("-AddToList", action="store_true")
parser.add_argument("-ListName", default="")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
args.UseTemplate = clean_path(args.UseTemplate, "-UseTemplate")
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/UseTemplate": "-UseTemplate",
"/AddToList": "-AddToList",
"--db-path": "-InfoBasePath",
"--load": "-UseTemplate",
"--restore": "-UseTemplate",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate connection ---
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- Validate template ---
if args.UseTemplate and not os.path.exists(args.UseTemplate):
print(f"Error: template file not found: {args.UseTemplate}")
sys.exit(1)
# --- ibcmd branch (file infobase only) ---
if engine == "ibcmd":
arguments = ["infobase", "create", f"--db-path={args.InfoBasePath}", "--create-database"]
if args.UseTemplate:
if os.path.splitext(args.UseTemplate)[1].lower() == ".dt":
arguments.append(f"--restore={args.UseTemplate}")
else:
arguments.extend([f"--load={args.UseTemplate}", "--apply"])
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
arguments.append(f"--data={ib_data}")
arguments.extend(quote_if_needed(a) for a in extra_args)
print(f"Running: ibcmd {' '.join(format_args_for_display(arguments, engine))}")
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
exit_code = result.returncode
ib_missing = exit_code == 0 and not file_ib_created(args.InfoBasePath)
if ib_missing:
exit_code = 1
if exit_code == 0:
print(f"Information base created successfully: {args.InfoBasePath}")
elif ib_missing:
print(
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
"— information base was not created",
)
else:
print(f"Error creating information base (code: {exit_code})")
print_platform_output(result)
sys.exit(exit_code)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"db_create_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
# --- Build arguments ---
arguments = ["CREATEINFOBASE"]
# Quotes go INSIDE the token (File="path"): that is where 1C's parser expects them.
# Quoting the whole token instead breaks a path with spaces — on both OSes.
if args.InfoBaseServer and args.InfoBaseRef:
arguments.append(f'Srvr="{args.InfoBaseServer}";Ref="{args.InfoBaseRef}"')
else:
arguments.append(f'File="{args.InfoBasePath}"')
# --- Template ---
if args.UseTemplate:
arguments.extend(["/UseTemplate", f'"{args.UseTemplate}"'])
# --- Add to list ---
if args.AddToList:
if args.ListName:
arguments.extend(["/AddToList", f'"{args.ListName}"'])
else:
arguments.append("/AddToList")
# --- Output ---
out_file = os.path.join(temp_dir, "create_log.txt")
arguments.extend(["/Out", f'"{out_file}"'])
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {' '.join(format_args_for_display(arguments, engine))}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
is_server = bool(args.InfoBaseServer and args.InfoBaseRef)
ib_missing = exit_code == 0 and not is_server and not file_ib_created(args.InfoBasePath)
if ib_missing:
exit_code = 1
if exit_code == 0:
if is_server:
print(f"Information base created successfully: {args.InfoBaseServer}/{args.InfoBaseRef}")
else:
print(f"Information base created successfully: {args.InfoBasePath}")
elif ib_missing:
print(
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
"— information base was not created",
)
else:
print(f"Error creating information base (code: {exit_code})")
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
sys.exit(exit_code)
finally:
if os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+70
View File
@@ -0,0 +1,70 @@
---
name: db-dump-cf
description: Выгрузка конфигурации 1С в CF-файл. Используй когда нужно выгрузить конфигурацию в CF, сохранить конфигурацию, сделать бэкап CF
argument-hint: "[database] [output.cf]"
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-dump-cf — Выгрузка конфигурации в CF-файл
Выгружает конфигурацию информационной базы в бинарный CF-файл.
## Usage
```
/db-dump-cf [database] [output.cf]
/db-dump-cf dev config.cf
/db-dump-cf — база по умолчанию, файл config.cf
```
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-cf/scripts/db-dump-cf.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-OutputFile <путь>` | да | Путь к выходному CF-файлу |
| `-Extension <имя>` | нет | Выгрузить расширение |
| `-AllExtensions` | нет | Выгрузить все расширения |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
## Примеры
```powershell
# Выгрузка конфигурации (файловая база)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-cf/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\config.cf"
# Серверная база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-cf/scripts/db-dump-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "config.cf"
# Выгрузка расширения
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-cf/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "ext.cfe" -Extension "МоёРасширение"
```
@@ -0,0 +1,496 @@
# db-dump-cf v1.16 — Dump 1C configuration to CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Выгрузка конфигурации 1С в CF-файл
.DESCRIPTION
Выгружает конфигурацию информационной базы в бинарный CF-файл.
Поддерживает выгрузку расширений.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER OutputFile
Путь к выходному CF-файлу
.PARAMETER Extension
Имя расширения для выгрузки
.PARAMETER AllExtensions
Выгрузить все расширения
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "config.cf"
.EXAMPLE
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$true)]
[string]$OutputFile,
[Parameter(Mandatory=$false)]
[string]$Extension,
[Parameter(Mandatory=$false)]
[switch]$AllExtensions,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
function Assert-InfoBaseExists {
# These skills work on a ready infobase. Saying so up front beats the platform's
# "Неверные или отсутствующие параметры соединения" after a launch.
param([string]$Path)
if (-not $Path) { return }
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
exit 1
}
}
Assert-InfoBaseExists $InfoBasePath
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
function Test-OutputNonEmpty {
# Postcondition: the platform must have produced a non-empty output file.
# Exit code 0 without it (broken/headless env) is a false success — reject it.
param([string]$Path)
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
# --- Validate connection ---
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Ensure output directory exists ---
$outDir = Split-Path $OutputFile -Parent
if ($outDir -and -not (Test-Path $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "db_dump_cf_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch (file infobase only) ---
if ($AllExtensions) {
Write-Host "Error: ibcmd config save does not support -AllExtensions (use -Extension)" -ForegroundColor Red
exit 1
}
$arguments = @("infobase", "config", "save", "--db-path=$InfoBasePath")
if ($Extension) { $arguments += "--extension=$Extension" }
$arguments += "$OutputFile"
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
} else {
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $output
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
$arguments += "/DumpCfg", "`"$OutputFile`""
# --- Extensions ---
if ($Extension) {
$arguments += "-Extension", "`"$Extension`""
} elseif ($AllExtensions) {
$arguments += "-AllExtensions"
}
# --- Output ---
$outFile = Join-Path $tempDir "dump_cf_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
# Postcondition: exit 0 without a non-empty output file is a false success.
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
} else {
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
}
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,552 @@
#!/usr/bin/env python3
# db-dump-cf v1.16 — Dump 1C configuration to CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter — catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error — reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply — a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path — reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block —
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def output_nonempty(path):
"""Postcondition: the platform must have produced a non-empty output file.
Exit code 0 without it (broken/headless env) is a false success — reject it."""
return os.path.isfile(path) and os.path.getsize(path) > 0
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Dump 1C configuration to CF file",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="")
parser.add_argument("-InfoBasePath", default="")
parser.add_argument("-InfoBaseServer", default="")
parser.add_argument("-InfoBaseRef", default="")
parser.add_argument("-UserName", default="")
parser.add_argument("-Password", default="")
parser.add_argument("-OutputFile", required=True)
parser.add_argument("-Extension", default="")
parser.add_argument("-AllExtensions", action="store_true")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate connection ---
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- Ensure output directory exists ---
out_dir = os.path.dirname(args.OutputFile)
if out_dir and not os.path.isdir(out_dir):
os.makedirs(out_dir, exist_ok=True)
# --- ibcmd branch (file infobase only) ---
if engine == "ibcmd":
if args.AllExtensions:
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)")
sys.exit(1)
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
if args.Extension:
arguments.append(f"--extension={args.Extension}")
arguments.append(args.OutputFile)
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
exit_code = result.returncode
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
if out_missing:
exit_code = 1
if exit_code == 0:
print(f"Configuration dumped successfully to: {args.OutputFile}")
elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
else:
print(f"Error dumping configuration (code: {exit_code})")
sys.exit(exit_code)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_cf_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
else:
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
arguments.extend(["/DumpCfg", f'"{args.OutputFile}"'])
# --- Extensions ---
if args.Extension:
arguments.extend(["-Extension", f'"{args.Extension}"'])
elif args.AllExtensions:
arguments.append("-AllExtensions")
# --- Output ---
out_file = os.path.join(temp_dir, "dump_cf_log.txt")
arguments.extend(["/Out", f'"{out_file}"'])
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
# Postcondition: exit 0 without a non-empty output file is a false success.
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
if out_missing:
exit_code = 1
if exit_code == 0:
print(f"Configuration dumped successfully to: {args.OutputFile}")
elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
else:
print(f"Error dumping configuration (code: {exit_code})")
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
sys.exit(exit_code)
finally:
if os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+74
View File
@@ -0,0 +1,74 @@
---
name: db-dump-dt
description: Выгрузка информационной базы 1С в DT-файл (вся база — конфигурация + данные). Используй когда нужно выгрузить информационную базу, выгрузить архив базы, сделать бэкап, выгрузить dt
argument-hint: "[database] [output.dt]"
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-dump-dt — Выгрузка информационной базы в DT-файл
Выгружает информационную базу целиком (конфигурация **+ данные**) в DT-файл — полный снимок ИБ.
> В отличие от `/db-dump-cf` (только конфигурация), `.dt` содержит **всю базу**: данные,
> настройки, пользователей. Это бэкап/точка отката, а не выгрузка метаданных.
## Usage
```
/db-dump-dt [database] [output.dt]
/db-dump-dt dev backup.dt
/db-dump-dt — база по умолчанию, имя файла по базе и дате
```
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-dt/scripts/db-dump-dt.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-OutputFile <путь>` | да | Путь к выходному DT-файлу |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
## Примеры
```powershell
# Выгрузка ИБ (файловая база)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-dt/scripts/db-dump-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt"
# Серверная база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-dt/scripts/db-dump-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt"
```
## Связанные навыки
- `/db-load-dt` — загрузка ИБ из DT (обратная операция)
- `/db-dump-cf` — выгрузка только конфигурации (без данных)
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
@@ -0,0 +1,470 @@
# db-dump-dt v1.15 — Dump 1C information base to DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Выгрузка информационной базы 1С в DT-файл
.DESCRIPTION
Выгружает информационную базу целиком (конфигурация + данные) в DT-файл.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER OutputFile
Путь к выходному DT-файлу
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$true)]
[string]$OutputFile,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
function Assert-InfoBaseExists {
# These skills work on a ready infobase. Saying so up front beats the platform's
# "Неверные или отсутствующие параметры соединения" after a launch.
param([string]$Path)
if (-not $Path) { return }
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
exit 1
}
}
Assert-InfoBaseExists $InfoBasePath
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
function Test-OutputNonEmpty {
# Postcondition: the platform must have produced a non-empty output file.
# Exit code 0 without it (broken/headless env) is a false success — reject it.
param([string]$Path)
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
# --- Validate connection ---
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Ensure output directory exists ---
$outDir = Split-Path $OutputFile -Parent
if ($outDir -and -not (Test-Path $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "db_dump_dt_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch (file infobase only) ---
$arguments = @("infobase", "dump", "--db-path=$InfoBasePath")
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "$OutputFile"
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
} else {
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $output
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
$arguments += "/DumpIB", "`"$OutputFile`""
# --- Output ---
$outFile = Join-Path $tempDir "dump_dt_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
# Postcondition: exit 0 without a non-empty output file is a false success.
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
} else {
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
}
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,539 @@
#!/usr/bin/env python3
# db-dump-dt v1.15 — Dump 1C information base to DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter — catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error — reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply — a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path — reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block —
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def output_nonempty(path):
"""Postcondition: the platform must have produced a non-empty output file.
Exit code 0 without it (broken/headless env) is a false success — reject it."""
return os.path.isfile(path) and os.path.getsize(path) > 0
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Dump 1C information base to DT file",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="")
parser.add_argument("-InfoBasePath", default="")
parser.add_argument("-InfoBaseServer", default="")
parser.add_argument("-InfoBaseRef", default="")
parser.add_argument("-UserName", default="")
parser.add_argument("-Password", default="")
parser.add_argument("-OutputFile", required=True)
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate connection ---
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- Ensure output directory exists ---
out_dir = os.path.dirname(args.OutputFile)
if out_dir and not os.path.isdir(out_dir):
os.makedirs(out_dir, exist_ok=True)
# --- ibcmd branch (file infobase only) ---
if engine == "ibcmd":
arguments = ["infobase", "dump", f"--db-path={args.InfoBasePath}"]
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(args.OutputFile)
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
exit_code = result.returncode
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
if out_missing:
exit_code = 1
if exit_code == 0:
print(f"Information base dumped successfully to: {args.OutputFile}")
elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
else:
print(f"Error dumping information base (code: {exit_code})")
sys.exit(exit_code)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_dt_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
else:
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
arguments.extend(["/DumpIB", f'"{args.OutputFile}"'])
# --- Output ---
out_file = os.path.join(temp_dir, "dump_dt_log.txt")
arguments.extend(["/Out", f'"{out_file}"'])
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
# Postcondition: exit 0 without a non-empty output file is a false success.
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
if out_missing:
exit_code = 1
if exit_code == 0:
print(f"Information base dumped successfully to: {args.OutputFile}")
elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
else:
print(f"Error dumping information base (code: {exit_code})")
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
sys.exit(exit_code)
finally:
if os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+93
View File
@@ -0,0 +1,93 @@
---
name: db-dump-xml
description: Выгрузка конфигурации 1С в XML-файлы. Используй когда нужно выгрузить конфигурацию в файлы, XML, исходники, DumpConfigToFiles
argument-hint: "[database] [outputDir]"
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-dump-xml — Выгрузка конфигурации в XML
Выгружает конфигурацию информационной базы в XML-файлы (исходники). Поддерживает полную, инкрементальную, частичную выгрузку и обновление ConfigDumpInfo.
## Usage
```
/db-dump-xml [database] [outputDir]
/db-dump-xml dev src/config
/db-dump-xml dev src/config -Mode Full
/db-dump-xml dev src/config -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
```
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
Если в записи базы указан `configSrc` — используй как каталог выгрузки по умолчанию.
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-xml/scripts/db-dump-xml.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-ConfigDir <путь>` | да | Каталог для выгрузки |
| `-Mode <режим>` | нет | `Full` / `Changes` (по умолч.) / `Partial` / `UpdateInfo` |
| `-Objects <список>` | для Partial | Имена объектов через запятую |
| `-Extension <имя>` | нет | Выгрузить расширение |
| `-AllExtensions` | нет | Выгрузить все расширения |
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
### Режимы выгрузки
| Режим | Описание |
|-------|----------|
| `Full` | Полная выгрузка — все объекты конфигурации |
| `Changes` | Инкрементальная — только изменённые с последней выгрузки (использует ConfigDumpInfo.xml) |
| `Partial` | Частичная — выбранные объекты из параметра `-Objects` |
| `UpdateInfo` | Обновить только ConfigDumpInfo.xml без выгрузки файлов |
> Если пользователь просит выгрузить конкретные объекты — используй `-Mode Partial` с `-Objects`.
## Примеры
```powershell
# Полная выгрузка (файловая база)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-xml/scripts/db-dump-xml.ps1" -V8Path "C:\Program Files\1cv8\8.3.25.1257\bin" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full
# Инкрементальная выгрузка
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Changes
# Частичная выгрузка
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
# Серверная база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -ConfigDir "C:\WS\cfsrc" -Mode Full
# Выгрузка расширения
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение"
```
@@ -0,0 +1,694 @@
# db-dump-xml v1.21 — Dump 1C configuration to XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Выгрузка конфигурации 1С в XML-файлы
.DESCRIPTION
Выполняет выгрузку конфигурации 1С в файлы в четырёх режимах:
- Full: полная выгрузка всей конфигурации
- Changes: инкрементальная выгрузка изменённых объектов
- Partial: выгрузка конкретных объектов из списка
- UpdateInfo: обновление только ConfigDumpInfo.xml
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER ConfigDir
Каталог для выгрузки конфигурации
.PARAMETER Mode
Режим выгрузки: Full, Changes, Partial, UpdateInfo (по умолчанию Changes)
.PARAMETER Objects
Имена объектов метаданных через запятую (для режима Partial)
.PARAMETER Extension
Имя расширения для выгрузки
.PARAMETER AllExtensions
Выгрузить все расширения
.PARAMETER Format
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
.EXAMPLE
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$true)]
[string]$ConfigDir,
[Parameter(Mandatory=$false)]
# Пустое значение = режим не задан. Прежнее умолчание Changes подставляется ниже, после
# того как станет видно, перечислены ли объекты.
[ValidateSet("", "Full", "Changes", "Partial", "UpdateInfo")]
[string]$Mode = "",
[Parameter(Mandatory=$false)]
[string]$Objects,
[Parameter(Mandatory=$false)]
[string]$Extension,
[Parameter(Mandatory=$false)]
[switch]$AllExtensions,
[Parameter(Mandatory=$false)]
[ValidateSet("Hierarchical", "Plain")]
[string]$Format = "Hierarchical",
[Parameter(Mandatory=$false)]
[string]$ObjectsFile,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
function Find-V8Project([string]$startDir) {
$d = $startDir
for ($i = 0; $i -lt 20 -and $d; $i++) {
$pj = Join-Path $d ".v8-project.json"
if (Test-Path $pj) { return $pj }
$parent = [System.IO.Path]::GetDirectoryName($d)
if ($parent -eq $d) { break }
$d = $parent
}
return $null
}
function Test-SamePath {
param([string]$A, [string]$B)
if (-not $A -or -not $B) { return $false }
try {
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
} catch { return $false }
}
function Find-ProjectDatabase {
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
$pf = Find-V8Project (Get-Location).Path
if (-not $pf) { return $null }
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
if (-not $proj.databases) { return $null }
foreach ($db in $proj.databases) {
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
}
}
return $null
}
function Resolve-RepositorySettings {
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
$dbRec = Find-ProjectDatabase
$rec = $null
if ($dbRec) {
if ($Extension) {
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
if ($dbRec.extensions) {
foreach ($ext in $dbRec.extensions) {
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
$rec = $ext.repository
break
}
}
}
} else {
$rec = $dbRec.repository
}
}
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
return @{
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
User = $user
Password = $pwd
FromRegistry = [bool]($rec -and $rec.path)
DbRecord = $dbRec
}
}
function Get-RepositoryArgs {
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
param([hashtable]$Repo)
$a = @()
if (-not $Repo -or -not $Repo.Path) { return $a }
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
return $a
}
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
function Assert-InfoBaseExists {
# These skills work on a ready infobase. Saying so up front beats the platform's
# "Неверные или отсутствующие параметры соединения" after a launch.
param([string]$Path)
if (-not $Path) { return }
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
exit 1
}
}
Assert-InfoBaseExists $InfoBasePath
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
function Test-DirNonEmpty {
# Postcondition: the platform must have written files into the output directory.
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
param([string]$Path)
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
# --- Validate connection ---
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Validate Partial mode ---
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
if ($ObjectsFile) {
if (-not (Test-Path $ObjectsFile)) {
Write-Host "Error: -ObjectsFile not found: $ObjectsFile" -ForegroundColor Red
exit 1
}
$fromFile = @([System.IO.File]::ReadAllLines($ObjectsFile, [System.Text.Encoding]::UTF8) |
ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
$Objects = (@(@($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $fromFile) -join ',')
}
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
if ($Objects) {
if ($Mode -eq "UpdateInfo") {
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
Write-Host "Error: -Mode UpdateInfo does not take an object list — it only refreshes ConfigDumpInfo.xml" -ForegroundColor Red
exit 1
}
if ($Mode -eq "Full" -or $Mode -eq "Changes") {
Write-Host "[note] перечислены объекты — выгружаются только они; -Mode $Mode не применён" -ForegroundColor Yellow
}
$Mode = "Partial"
}
if (-not $Mode) { $Mode = "Changes" }
if ($Mode -eq "Partial" -and -not $Objects) {
Write-Host "Error: -Objects or -ObjectsFile required for Partial mode" -ForegroundColor Red
exit 1
}
# --- Create output dir if needed ---
if (-not (Test-Path $ConfigDir)) {
New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null
Write-Host "Created output directory: $ConfigDir"
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "db_dump_xml_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
if ($Format -eq "Plain") {
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
exit 1
}
if ($AllExtensions) {
$arguments = @("infobase", "config", "export", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
} elseif ($Mode -eq "UpdateInfo") {
Write-Host "Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8" -ForegroundColor Red
exit 1
} elseif ($Mode -eq "Partial") {
$objList = @($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
$arguments = @("infobase", "config", "export", "objects") + $objList
$arguments += "--out=$ConfigDir", "--db-path=$InfoBasePath"
if ($Extension) { $arguments += "--extension=$Extension" }
} else {
$arguments = @("infobase", "config", "export", "--db-path=$InfoBasePath")
if ($Extension) { $arguments += "--extension=$Extension" }
$arguments += "$ConfigDir"
}
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "Configuration exported successfully to: $ConfigDir" -ForegroundColor Green
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not exported" -ForegroundColor Red
} else {
Write-Host "Error exporting configuration (code: $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $output
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/DumpConfigToFiles", "`"$ConfigDir`""
$arguments += "-Format", $Format
switch ($Mode) {
"Full" {
Write-Host "Executing full configuration dump..."
}
"Changes" {
Write-Host "Executing incremental configuration dump..."
$arguments += "-update"
$arguments += "-force"
}
"Partial" {
Write-Host "Executing partial configuration dump..."
$objectList = $Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
$listFile = Join-Path $tempDir "dump_list.txt"
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllLines($listFile, $objectList, $utf8Bom)
$arguments += "-listFile", "`"$listFile`""
Write-Host "Objects to dump: $($objectList.Count)"
foreach ($obj in $objectList) { Write-Host " $obj" }
}
"UpdateInfo" {
Write-Host "Updating ConfigDumpInfo.xml..."
$arguments += "-configDumpInfoOnly"
}
}
# --- Extensions ---
if ($Extension) {
$arguments += "-Extension", "`"$Extension`""
} elseif ($AllExtensions) {
$arguments += "-AllExtensions"
}
# --- Output ---
$outFile = Join-Path $tempDir "dump_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
# Postcondition: exit 0 with an empty output directory is a false success.
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "Dump completed successfully" -ForegroundColor Green
Write-Host "Configuration dumped to: $ConfigDir"
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not dumped" -ForegroundColor Red
} else {
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
}
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,726 @@
#!/usr/bin/env python3
# db-dump-xml v1.21 — Dump 1C configuration to XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def same_path(a, b):
if not a or not b:
return False
try:
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
except Exception:
return False
def find_project_database(args):
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
pf = _sg_find_v8project(os.getcwd())
if not pf:
return None
try:
with open(pf, encoding="utf-8-sig") as f:
proj = json.load(f)
except Exception:
return None
for db in proj.get("databases") or []:
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
return db
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
if (db["server"].lower() == args.InfoBaseServer.lower()
and db["ref"].lower() == args.InfoBaseRef.lower()):
return db
return None
def resolve_repository_settings(args):
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
db_rec = find_project_database(args)
rec = None
if db_rec:
if args.Extension:
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
for ext in db_rec.get("extensions") or []:
if (ext.get("name") or "").lower() == args.Extension.lower():
rec = ext.get("repository")
break
else:
rec = db_rec.get("repository")
path = args.RepositoryPath or ((rec or {}).get("path") or None)
user = args.RepositoryUser or ((rec or {}).get("user") or None)
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
return {
"path": path.strip().strip('"') if path else None,
"user": user,
"password": pwd,
"from_registry": bool(rec and rec.get("path")),
}
def repository_args(repo):
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
a = []
if not repo or not repo.get("path"):
return a
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
if repo.get("user"):
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
if repo.get("password"):
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
return a
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter — catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error — reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply — a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path — reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block —
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def dir_nonempty(path):
"""Postcondition: the platform must have written files into the output directory.
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
return os.path.isdir(path) and any(os.scandir(path))
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Dump 1C configuration to XML files",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password")
parser.add_argument("-RepositoryPath", default="")
parser.add_argument("-RepositoryUser", default="")
parser.add_argument("-RepositoryPassword", default="")
parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump")
parser.add_argument(
"-Mode",
default="",
choices=["", "Full", "Changes", "Partial", "UpdateInfo"],
help="Dump mode (default: Changes)",
)
parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)")
parser.add_argument("-ObjectsFile", default="")
parser.add_argument("-Extension", default="", help="Extension name to dump")
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
parser.add_argument(
"-Format",
default="Hierarchical",
choices=["Hierarchical", "Plain"],
help="Dump format (default: Hierarchical)",
)
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
# --- Resolve V8Path ---
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate connection ---
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- Validate Partial mode ---
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
if args.ObjectsFile:
if not os.path.exists(args.ObjectsFile):
print("Error: -ObjectsFile not found: %s" % args.ObjectsFile)
sys.exit(1)
with open(args.ObjectsFile, encoding="utf-8-sig") as f:
from_file = [s.strip() for s in f.read().splitlines()
if s.strip() and not s.strip().startswith("#")]
inline = [s.strip() for s in args.Objects.split(",") if s.strip()]
args.Objects = ",".join(inline + from_file)
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
if args.Objects:
if args.Mode == "UpdateInfo":
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
print("Error: -Mode UpdateInfo does not take an object list — it only refreshes "
"ConfigDumpInfo.xml")
sys.exit(1)
if args.Mode in ("Full", "Changes"):
print("[note] перечислены объекты — выгружаются только они; -Mode %s не применён"
% args.Mode)
args.Mode = "Partial"
if not args.Mode:
args.Mode = "Changes"
if args.Mode == "Partial" and not args.Objects:
print("Error: -Objects or -ObjectsFile required for Partial mode")
sys.exit(1)
# --- Create output dir if needed ---
if not os.path.exists(args.ConfigDir):
os.makedirs(args.ConfigDir, exist_ok=True)
print(f"Created output directory: {args.ConfigDir}")
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
if engine == "ibcmd":
if args.Format == "Plain":
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1)
if args.AllExtensions:
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
elif args.Mode == "UpdateInfo":
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8")
sys.exit(1)
elif args.Mode == "Partial":
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
arguments = ["infobase", "config", "export", "objects"] + obj_list
arguments += [f"--out={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
if args.Extension:
arguments.append(f"--extension={args.Extension}")
else:
arguments = ["infobase", "config", "export", f"--db-path={args.InfoBasePath}"]
if args.Extension:
arguments.append(f"--extension={args.Extension}")
arguments.append(args.ConfigDir)
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
exit_code = result.returncode
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
if out_missing:
exit_code = 1
if exit_code == 0:
print(f"Configuration exported successfully to: {args.ConfigDir}")
elif out_missing:
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported")
else:
print(f"Error exporting configuration (code: {exit_code})")
sys.exit(exit_code)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_xml_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
else:
arguments += ["/F", f'"{args.InfoBasePath}"']
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
arguments += ["-Format", args.Format]
if args.Mode == "Full":
print("Executing full configuration dump...")
elif args.Mode == "Changes":
print("Executing incremental configuration dump...")
arguments.append("-update")
arguments.append("-force")
elif args.Mode == "Partial":
print("Executing partial configuration dump...")
object_list = [obj.strip() for obj in args.Objects.split(",") if obj.strip()]
list_file = os.path.join(temp_dir, "dump_list.txt")
with open(list_file, "w", encoding="utf-8-sig") as f:
f.write("\n".join(object_list))
arguments += ["-listFile", f'"{list_file}"']
print(f"Objects to dump: {len(object_list)}")
for obj in object_list:
print(f" {obj}")
elif args.Mode == "UpdateInfo":
print("Updating ConfigDumpInfo.xml...")
arguments.append("-configDumpInfoOnly")
# --- Extensions ---
if args.Extension:
arguments += ["-Extension", f'"{args.Extension}"']
elif args.AllExtensions:
arguments.append("-AllExtensions")
# --- Output ---
out_file = os.path.join(temp_dir, "dump_log.txt")
arguments += ["/Out", f'"{out_file}"']
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
# Postcondition: exit 0 with an empty output directory is a false success.
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
if out_missing:
exit_code = 1
if exit_code == 0:
print("Dump completed successfully")
print(f"Configuration dumped to: {args.ConfigDir}")
elif out_missing:
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped")
else:
print(f"Error dumping configuration (code: {exit_code})")
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
sys.exit(exit_code)
finally:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+210
View File
@@ -0,0 +1,210 @@
---
name: db-list
description: Управление реестром баз данных 1С (.v8-project.json). Используй когда нужно работать с реестром баз — список баз, зарегистрировать базу в реестре, какие базы есть
argument-hint: "[add|remove|show]"
allowed-tools:
- Read
- Write
- Glob
- AskUserQuestion
---
# /db-list — Управление реестром баз данных
Управляет файлом `.v8-project.json` — реестром информационных баз проекта. Файл хранит параметры подключения, алиасы, привязку к веткам Git.
## Usage
```
/db-list — показать список баз
/db-list add — добавить базу (интерактивно)
/db-list remove <id> — удалить базу из реестра
/db-list show <id|alias> — подробности по базе
```
## Формат `.v8-project.json`
Файл размещается в корне проекта (рядом с `.git/`).
```json
{
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
"v8args": ["/UseHwLicenses+"],
"databases": [
{
"id": "dev",
"name": "Разработка",
"type": "file",
"path": "C:\\Bases\\MyApp_Dev",
"user": "Admin",
"password": "",
"aliases": ["dev", "разработка"],
"branches": ["dev", "develop", "feature/*"],
"configSrc": "C:\\WS\\myapp\\cfsrc",
"repository": {
"path": "\\\\srv01\\repo\\MyApp",
"user": "Ivanov",
"password": ""
},
"extensions": [
{
"name": "МоёРасширение",
"src": "src\\cfe\\МоёРасширение",
"repository": { "path": "\\\\srv01\\repo\\MyApp_Ext", "user": "Ivanov", "password": "" }
}
]
},
{
"id": "test",
"name": "Тестовая",
"type": "server",
"server": "srv01",
"ref": "MyApp_Test",
"user": "Admin",
"password": "123",
"aliases": ["test", "тест"]
}
],
"default": "dev"
}
```
### Поля корневого объекта
| Поле | Тип | Описание |
|------|-----|----------|
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
| `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` |
| `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) |
| `databases` | array | Массив баз данных |
| `default` | string | id базы по умолчанию |
### Поля объекта базы данных
| Поле | Тип | Обязательное | Описание |
|------|-----|:------------:|----------|
| `id` | string | да | Уникальный идентификатор (латиница, без пробелов) |
| `name` | string | да | Человекочитаемое имя |
| `type` | `"file"` / `"server"` | да | Тип подключения |
| `path` | string | для file | Путь к каталогу файловой базы |
| `server` | string | для server | Адрес сервера 1С |
| `ref` | string | для server | Имя базы на сервере |
| `user` | string | нет | Имя пользователя 1С |
| `password` | string | нет | Пароль |
| `aliases` | string[] | нет | Альтернативные имена для быстрого доступа |
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`), привязанные к этой базе |
| `configSrc` | string | нет | Каталог XML-выгрузки конфигурации |
| `repository` | object | нет | Хранилище конфигурации: `path`, `user`, `password` (см. ниже) |
| `extensions` | array | нет | Расширения: `name`, `src`, необязательное `repository` (см. ниже) |
### Хранилище конфигурации
База, подключённая к хранилищу конфигурации 1С, **не принимает ни одной операции конфигуратора**
без реквизитов доступа к хранилищу — это касается не только `/db-repo`, но и `/db-load-xml`,
`/db-dump-xml`, `/db-update`, `/db-load-git`. Реквизиты берутся из `repository` записи базы,
передавать их в каждом вызове не нужно.
| Поле | Тип | Обязательное | Описание |
|------|-----|:------------:|----------|
| `repository.path` | string | да | Каталог хранилища или `tcp://<хост>[:<порт>]/<имя>` |
| `repository.user` | string | нет | Пользователь **хранилища**. Не наследуется от `user` базы |
| `repository.password` | string | нет | Пароль пользователя хранилища |
У расширения **своё хранилище** со своим путём, поэтому одного `repository` мало:
| Поле | Тип | Обязательное | Описание |
|------|-----|:------------:|----------|
| `extensions[].name` | string | да | Имя расширения, как в конфигурации |
| `extensions[].src` | string | нет | Каталог XML-исходников расширения |
| `extensions[].repository` | object | нет | Хранилище расширения. Расширение без хранилища — обычный случай |
Пароль хранилища — такой же секрет, как `password` базы; `.v8-project.json` в `.gitignore`.
> **Сетевое хранилище.** Адрес — `tcp://<хост>[:<порт>]/<имя>`, порт по умолчанию 1542.
> Обслуживается сервером хранилища. Если он недоступен, платформа отвечает «Соединение с
> хранилищем конфигурации не установлено» — тем же сообщением, что и при отсутствии реквизитов.
## Алгоритм разрешения базы данных
Этот алгоритм используется ВСЕМИ навыками (`db-*`, `epf-build`, `epf-dump`, `erf-build`, `erf-dump`) для определения целевой базы.
1. Если пользователь указал **параметры подключения** (путь, сервер) — используй напрямую
2. Если пользователь указал **базу по имени** — ищи совпадение в таком порядке:
1. По `id` (точное совпадение)
2. По `aliases` (совпадение в массиве с учётом морфологии: «тестовую» = «тестовая» = «тестовой»)
3. По `name` (нечёткое совпадение с учётом морфологии и регистра)
3. Если пользователь **не указал** базу — сопоставь текущую ветку Git с `databases[].branches`:
- Точное совпадение: ветка `dev``"branches": ["dev"]`
- Glob-паттерн: ветка `release/2.1``"branches": ["release/*"]`
4. Если ветка не совпала — используй `default`
5. Если не найдено или неоднозначно — спроси пользователя
6. Если файл `.v8-project.json` не найден — спроси параметры подключения и предложи создать файл
После выполнения: если использованная база не зарегистрирована — предложи добавить через `/db-list add`.
### Автоопределение платформы
Если `v8path` не задан в конфиге:
```powershell
$v8 = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort-Object -Descending | Select-Object -First 1
```
## Операции
### Показать список баз
Прочитай `.v8-project.json`, выведи таблицу:
```
ID Имя Тип Путь/Сервер По умолч.
dev Разработка file C:\Bases\MyApp_Dev ✓
test Тестовая server srv01/MyApp_Test
```
### Добавить базу
Спроси у пользователя через AskUserQuestion:
- id, name, type (file/server)
- path (для file) или server + ref (для server)
- user, password (необязательно)
- aliases, branches (необязательно)
- если база под хранилищем конфигурации — `repository`: путь, пользователь, пароль
Добавь в массив `databases`. Если это первая база — установи как `default`.
### Удалить базу
Удали из массива `databases` по id. Если удаляемая была `default` — спросить новый default.
### Подробности по базе
Выведи все поля конкретной базы.
## Формирование строки подключения
Для использования в шаблонах команд других навыков:
**Файловая база:**
```
/F "<path>"
```
**Серверная база:**
```
/S "<server>/<ref>"
```
**Аутентификация** (добавляется если user задан):
```
/N"<user>" /P"<password>"
```
> **Важно**: между `/N` и именем пробела нет. Между `/P` и паролем пробела нет. Если пароль пустой — опусти `/P` целиком.
**Хранилище конфигурации** (если у базы задан `repository`) — скрипты навыков подставляют
сами, сопоставляя параметры соединения с записью реестра:
```
/ConfigurationRepositoryF"<path>" /ConfigurationRepositoryN"<user>" /ConfigurationRepositoryP"<password>"
```
+75
View File
@@ -0,0 +1,75 @@
---
name: db-load-cf
description: Загрузка конфигурации 1С из CF-файла. Используй когда нужно загрузить конфигурацию из CF, восстановить из бэкапа CF
argument-hint: <input.cf> [database]
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-load-cf — Загрузка конфигурации из CF-файла
Загружает конфигурацию из бинарного CF-файла в информационную базу.
## Usage
```
/db-load-cf <input.cf> [database]
/db-load-cf config.cf dev
```
> **Внимание**: загрузка CF **полностью заменяет** конфигурацию в базе. Перед выполнением запроси подтверждение у пользователя.
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-cf/scripts/db-load-cf.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-InputFile <путь>` | да | Путь к CF-файлу |
| `-Extension <имя>` | нет | Загрузить как расширение |
| `-AllExtensions` | нет | Загрузить все расширения из архива |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
## После выполнения
**Предложи выполнить `/db-update`** — загрузка CF обновляет только «основную» конфигурацию конфигуратора, для применения к БД нужен `/UpdateDBCfg`
## Примеры
```powershell
# Файловая база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-cf/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\config.cf"
# Серверная база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-cf/scripts/db-load-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "config.cf"
# Загрузка расширения
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-cf/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "ext.cfe" -Extension "МоёРасширение"
```
@@ -0,0 +1,497 @@
# db-load-cf v1.17 — Load 1C configuration from CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Загрузка конфигурации 1С из CF-файла
.DESCRIPTION
Загружает конфигурацию из бинарного CF-файла в информационную базу.
Поддерживает загрузку расширений.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER InputFile
Путь к CF-файлу для загрузки
.PARAMETER Extension
Загрузить как расширение
.PARAMETER AllExtensions
Загрузить все расширения из архива
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "config.cf"
.EXAMPLE
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$true)]
[string]$InputFile,
[Parameter(Mandatory=$false)]
[string]$Extension,
[Parameter(Mandatory=$false)]
[switch]$AllExtensions,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
function Get-ExitAnnotation {
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
# half-updated — surface that instead of a plain code. (Windows exception codes only;
# POSIX signals are handled in the .py port.)
param([int]$Code)
$win = @{
-1073741819 = "0xC0000005 (access violation)"
-1073741515 = "0xC0000135 (missing DLL)"
-1073740791 = "0xC0000409 (stack overrun)"
}
if ($win.ContainsKey($Code)) {
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
}
return ""
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
function Assert-InfoBaseExists {
# These skills work on a ready infobase. Saying so up front beats the platform's
# "Неверные или отсутствующие параметры соединения" after a launch.
param([string]$Path)
if (-not $Path) { return }
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
exit 1
}
}
Assert-InfoBaseExists $InfoBasePath
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
# --- Validate connection ---
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Validate input file ---
if (-not (Test-Path $InputFile)) {
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
exit 1
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "db_load_cf_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch (file infobase only) ---
if ($AllExtensions) {
Write-Host "Error: ibcmd config load does not support -AllExtensions (use -Extension)" -ForegroundColor Red
exit 1
}
$arguments = @("infobase", "config", "load", "--db-path=$InfoBasePath")
if ($Extension) { $arguments += "--extension=$Extension" }
$arguments += "$InputFile"
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
if ($exitCode -eq 0) {
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
} else {
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $output
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
$arguments += "/LoadCfg", "`"$InputFile`""
# --- Extensions ---
if ($Extension) {
$arguments += "-Extension", "`"$Extension`""
} elseif ($AllExtensions) {
$arguments += "-AllExtensions"
}
# --- Output ---
$outFile = Join-Path $tempDir "load_cf_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
if ($exitCode -eq 0) {
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
} else {
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,558 @@
#!/usr/bin/env python3
# db-load-cf v1.17 — Load 1C configuration from CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter — catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error — reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply — a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path — reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block —
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def describe_exit(code):
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
if code is None:
return ""
win = {
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
}
if code in win:
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
if -64 <= code < 0:
try:
import signal
name = signal.Signals(-code).name
except (ValueError, AttributeError):
name = f"signal {-code}"
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
"the infobase may be left in an inconsistent state; verify it before retrying")
return ""
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Load 1C configuration from CF file",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="")
parser.add_argument("-InfoBasePath", default="")
parser.add_argument("-InfoBaseServer", default="")
parser.add_argument("-InfoBaseRef", default="")
parser.add_argument("-UserName", default="")
parser.add_argument("-Password", default="")
parser.add_argument("-InputFile", required=True)
parser.add_argument("-Extension", default="")
parser.add_argument("-AllExtensions", action="store_true")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
args.InputFile = clean_path(args.InputFile, "-InputFile")
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate connection ---
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- Validate input file ---
if not os.path.isfile(args.InputFile):
print(f"Error: input file not found: {args.InputFile}")
sys.exit(1)
# --- ibcmd branch (file infobase only) ---
if engine == "ibcmd":
if args.AllExtensions:
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)")
sys.exit(1)
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
if args.Extension:
arguments.append(f"--extension={args.Extension}")
arguments.append(args.InputFile)
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
if result.returncode == 0:
print(f"Configuration loaded successfully from: {args.InputFile}")
else:
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_cf_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
else:
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
arguments.extend(["/LoadCfg", f'"{args.InputFile}"'])
# --- Extensions ---
if args.Extension:
arguments.extend(["-Extension", f'"{args.Extension}"'])
elif args.AllExtensions:
arguments.append("-AllExtensions")
# --- Output ---
out_file = os.path.join(temp_dir, "load_cf_log.txt")
arguments.extend(["/Out", f'"{out_file}"'])
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
if exit_code == 0:
print(f"Configuration loaded successfully from: {args.InputFile}")
else:
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
sys.exit(exit_code)
finally:
if os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+94
View File
@@ -0,0 +1,94 @@
---
name: db-load-dt
description: Загрузка информационной базы 1С из DT-файла — полная перезапись базы (конфигурация + данные). Используй когда нужно загрузить архив информационной базы, восстановить базу, загрузить dt
disable-model-invocation: true
argument-hint: <input.dt> [database]
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-load-dt — Загрузка информационной базы из DT-файла
Восстанавливает информационную базу целиком (конфигурация **+ данные**) из DT-файла.
> ⚠️ **Необратимая операция.** Загрузка `.dt` **полностью перезаписывает базу** — и
> конфигурацию, и все данные. Текущее содержимое базы будет потеряно. После загрузки
> `/db-update` **не нужен** — конфигурация БД уже синхронна внутри снимка.
## Когда НЕ использовать
- Нужно создать **новую** базу из `.dt` → используй `/db-create` (из DT-шаблона), а не загрузку
в существующую.
- Нужно обновить только конфигурацию (без данных) → `/db-load-cf` или `/db-load-xml`.
## Usage
```
/db-load-dt <input.dt> [database]
/db-load-dt backup.dt dev
```
## Порядок действий перед загрузкой
1. Предложи пользователю сначала сделать `/db-dump-dt` текущего состояния базы — это точка
отката (восстановиться будет нечем, если не сохранить).
2. Запроси **явное подтверждение**: вся база (данные + конфигурация) будет перезаписана.
3. Только после подтверждения выполняй загрузку.
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-dt/scripts/db-load-dt.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-InputFile <путь>` | да | Путь к DT-файлу |
| `-JobsCount <N>` | нет | Число фоновых заданий загрузки (0 = по числу процессоров) |
| `-UnlockCode <код>` | нет | Код разблокировки (`/UC`), если заблокировано начало сеансов |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
## После выполнения
Если база занята (активные сеансы), загрузка не выполнится — для серверной базы можно
передать `-UnlockCode`; иначе освободи базу и повтори.
## Примеры
```powershell
# Файловая база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-dt/scripts/db-load-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt"
# Серверная база с ускорением загрузки
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-dt/scripts/db-load-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4
```
## Связанные навыки
- `/db-dump-dt` — выгрузка ИБ в DT (обратная операция, точка отката перед загрузкой)
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
@@ -0,0 +1,487 @@
# db-load-dt v1.16 — Load 1C information base from DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Загрузка информационной базы 1С из DT-файла
.DESCRIPTION
Загружает информационную базу целиком (конфигурация + данные) из DT-файла.
ВНИМАНИЕ: операция полностью перезаписывает базу.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER InputFile
Путь к DT-файлу для загрузки
.PARAMETER JobsCount
Количество фоновых заданий для загрузки (0 = по числу процессоров)
.PARAMETER UnlockCode
Код разблокировки базы (/UC) если заблокировано начало сеансов
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$true)]
[string]$InputFile,
[Parameter(Mandatory=$false)]
[int]$JobsCount = 0,
[Parameter(Mandatory=$false)]
[string]$UnlockCode,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
function Get-ExitAnnotation {
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
# half-updated — surface that instead of a plain code. (Windows exception codes only;
# POSIX signals are handled in the .py port.)
param([int]$Code)
$win = @{
-1073741819 = "0xC0000005 (access violation)"
-1073741515 = "0xC0000135 (missing DLL)"
-1073740791 = "0xC0000409 (stack overrun)"
}
if ($win.ContainsKey($Code)) {
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
}
return ""
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
function Assert-InfoBaseExists {
# These skills work on a ready infobase. Saying so up front beats the platform's
# "Неверные или отсутствующие параметры соединения" after a launch.
param([string]$Path)
if (-not $Path) { return }
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
exit 1
}
}
Assert-InfoBaseExists $InfoBasePath
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
# --- Validate connection ---
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Validate input file ---
if (-not (Test-Path $InputFile)) {
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
exit 1
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "db_load_dt_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch (file infobase only) ---
$arguments = @("infobase", "restore", "--db-path=$InfoBasePath")
if (-not (Test-Path (Join-Path $InfoBasePath "1Cv8.1CD"))) { $arguments += "--create-database" }
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "$InputFile"
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
if ($exitCode -eq 0) {
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
} else {
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $output
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
if ($UnlockCode) { $arguments += "/UC`"$UnlockCode`"" }
$arguments += "/RestoreIB", "`"$InputFile`""
if ($JobsCount -gt 0) { $arguments += "-JobsCount", "$JobsCount" }
# --- Output ---
$outFile = Join-Path $tempDir "load_dt_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
if ($exitCode -eq 0) {
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
} else {
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,553 @@
#!/usr/bin/env python3
# db-load-dt v1.16 — Load 1C information base from DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def describe_exit(code):
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
if code is None:
return ""
win = {
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
}
if code in win:
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
if -64 <= code < 0:
try:
import signal
name = signal.Signals(-code).name
except (ValueError, AttributeError):
name = f"signal {-code}"
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
"the infobase may be left in an inconsistent state; verify it before retrying")
return ""
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Load 1C information base from DT file",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="")
parser.add_argument("-InfoBasePath", default="")
parser.add_argument("-InfoBaseServer", default="")
parser.add_argument("-InfoBaseRef", default="")
parser.add_argument("-UserName", default="")
parser.add_argument("-Password", default="")
parser.add_argument("-InputFile", required=True)
parser.add_argument("-JobsCount", type=int, default=0)
parser.add_argument("-UnlockCode", default="")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
args.InputFile = clean_path(args.InputFile, "-InputFile")
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate connection ---
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- Validate input file ---
if not os.path.isfile(args.InputFile):
print(f"Error: input file not found: {args.InputFile}")
sys.exit(1)
# --- ibcmd branch (file infobase only) ---
if engine == "ibcmd":
arguments = ["infobase", "restore", f"--db-path={args.InfoBasePath}"]
if not os.path.isfile(os.path.join(args.InfoBasePath, "1Cv8.1CD")):
arguments.append("--create-database")
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(args.InputFile)
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
if result.returncode == 0:
print(f"Information base restored successfully from: {args.InputFile}")
else:
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_dt_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
else:
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
if args.UnlockCode:
arguments.append(f'/UC"{args.UnlockCode}"')
arguments.extend(["/RestoreIB", f'"{args.InputFile}"'])
if args.JobsCount > 0:
arguments.extend(["-JobsCount", str(args.JobsCount)])
# --- Output ---
out_file = os.path.join(temp_dir, "load_dt_log.txt")
arguments.extend(["/Out", f'"{out_file}"'])
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
if exit_code == 0:
print(f"Information base restored successfully from: {args.InputFile}")
else:
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}")
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
sys.exit(exit_code)
finally:
if os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
---
name: db-load-git
description: Загрузка изменений из Git в базу 1С. Используй когда нужно загрузить изменения из гита, обновить базу из репозитория, partial load из коммита
argument-hint: "[database] [source]"
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-load-git — Загрузка изменений из Git
Определяет изменённые файлы конфигурации по данным Git и выполняет частичную загрузку в информационную базу.
## Usage
```
/db-load-git [database]
/db-load-git dev — все незафиксированные изменения
/db-load-git dev -Source Staged — только staged
/db-load-git dev -Source Commit -CommitRange "HEAD~3..HEAD"
/db-load-git dev -DryRun — только показать что будет загружено
```
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
Если в записи базы указан `configSrc` — используй как каталог конфигурации.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-git/scripts/db-load-git.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-ConfigDir <путь>` | да | Каталог XML-выгрузки (git-репозиторий) |
| `-Source <источник>` | нет | `All` (по умолч.) / `Staged` / `Unstaged` / `Commit` |
| `-CommitRange <range>` | для Commit | Диапазон коммитов (напр. `HEAD~3..HEAD`) |
| `-Extension <имя>` | нет | Загрузить в расширение |
| `-AllExtensions` | нет | Загрузить все расширения |
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
| `-DryRun` | нет | Только показать что будет загружено (без загрузки) |
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
## После выполнения
Если `-UpdateDB` не был указан — **предложить `/db-update`** для применения изменений к БД
## Примеры
```powershell
# Все незафиксированные изменения
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-git/scripts/db-load-git.ps1" -V8Path "C:\Program Files\1cv8\8.3.25.1257\bin" -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\WS\cfsrc" -Source All -UpdateDB
# Из диапазона коммитов
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-git/scripts/db-load-git.ps1" -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\WS\cfsrc" -Source Commit -CommitRange "HEAD~3..HEAD"
```
@@ -0,0 +1,862 @@
# db-load-git v1.26 — Load Git changes into 1C database
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Загрузка изменений из Git в базу 1С
.DESCRIPTION
Определяет изменённые файлы конфигурации по данным Git и выполняет
частичную загрузку в информационную базу.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER ConfigDir
Каталог XML-выгрузки конфигурации (git-репозиторий)
.PARAMETER Source
Источник изменений: All, Staged, Unstaged, Commit (по умолчанию All)
.PARAMETER CommitRange
Диапазон коммитов (для Source=Commit), напр. HEAD~3..HEAD
.PARAMETER Extension
Имя расширения для загрузки
.PARAMETER AllExtensions
Загрузить все расширения
.PARAMETER Format
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
.PARAMETER DryRun
Только показать что будет загружено (без загрузки)
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source All
.EXAMPLE
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source Commit -CommitRange "HEAD~3..HEAD"
.EXAMPLE
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$true)]
[string]$ConfigDir,
[Parameter(Mandatory=$false)]
[ValidateSet("All", "Staged", "Unstaged", "Commit")]
[string]$Source = "All",
[Parameter(Mandatory=$false)]
[string]$CommitRange,
[Parameter(Mandatory=$false)]
[string]$Extension,
[Parameter(Mandatory=$false)]
[switch]$AllExtensions,
[Parameter(Mandatory=$false)]
[ValidateSet("Hierarchical", "Plain")]
[string]$Format = "Hierarchical",
[Parameter(Mandatory=$false)]
[switch]$DryRun,
[Parameter(Mandatory=$false)]
[switch]$UpdateDB,
[Parameter(Mandatory=$false)]
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
# но в логе есть отбраковка.
[switch]$StrictLog,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
function Find-V8Project([string]$startDir) {
$d = $startDir
for ($i = 0; $i -lt 20 -and $d; $i++) {
$pj = Join-Path $d ".v8-project.json"
if (Test-Path $pj) { return $pj }
$parent = [System.IO.Path]::GetDirectoryName($d)
if ($parent -eq $d) { break }
$d = $parent
}
return $null
}
function Test-SamePath {
param([string]$A, [string]$B)
if (-not $A -or -not $B) { return $false }
try {
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
} catch { return $false }
}
function Find-ProjectDatabase {
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
$pf = Find-V8Project (Get-Location).Path
if (-not $pf) { return $null }
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
if (-not $proj.databases) { return $null }
foreach ($db in $proj.databases) {
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
}
}
return $null
}
function Resolve-RepositorySettings {
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
$dbRec = Find-ProjectDatabase
$rec = $null
if ($dbRec) {
if ($Extension) {
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
if ($dbRec.extensions) {
foreach ($ext in $dbRec.extensions) {
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
$rec = $ext.repository
break
}
}
}
} else {
$rec = $dbRec.repository
}
}
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
return @{
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
User = $user
Password = $pwd
FromRegistry = [bool]($rec -and $rec.path)
DbRecord = $dbRec
}
}
function Get-RepositoryArgs {
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
param([hashtable]$Repo)
$a = @()
if (-not $Repo -or -not $Repo.Path) { return $a }
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
return $a
}
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
function Write-RepositoryHints {
param([string]$LogText)
if (-not $LogText) { return }
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
}
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
$obj = $m.Groups[1].Value
if ($obj -eq 'Configuration') {
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
} else {
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
}
}
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
}
}
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function Get-ExitAnnotation {
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
# half-updated — surface that instead of a plain code. (Windows exception codes only;
# POSIX signals are handled in the .py port.)
param([int]$Code)
$win = @{
-1073741819 = "0xC0000005 (access violation)"
-1073741515 = "0xC0000135 (missing DLL)"
-1073740791 = "0xC0000409 (stack overrun)"
}
if ($win.ContainsKey($Code)) {
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
}
return ""
}
# --- Helper: map sub-file path (BSL, HTML, etc.) to object XML ---
function Get-ObjectXmlFromSubFile {
param([string]$RelativePath)
$parts = $RelativePath -split '[\\/]'
if ($parts.Count -ge 2) {
return "$($parts[0])/$($parts[1]).xml"
}
return $null
}
# --- Resolve V8Path (skip if DryRun) ---
if (-not $DryRun) {
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
}
# --- Detect engine + validate connection (skip if DryRun) ---
$engine = "1cv8"
if (-not $DryRun) {
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
#
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Find-SilentRejections {
param([string]$LogText)
$patterns = @(
'Неверное свойство объекта метаданных',
'не входит в состав объекта метаданных',
'Неизвестное имя типа',
'Неизвестный объект метаданных',
'Ни один из документов не является регистратором для регистра',
'Неверное значение перечисления',
'не может быть приведен к типу',
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
'Для работы с конфигурацией необходима версия платформы не меньше'
)
$found = @()
if ($LogText) {
foreach ($line in ($LogText -split "`r?`n")) {
foreach ($pat in $patterns) {
if ($line -match [regex]::Escape($pat)) {
$found += $line.Trim()
break
}
}
}
}
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
return $found
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
}
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
# --- Validate config dir ---
if (-not (Test-Path $ConfigDir)) {
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
exit 1
}
# --- Validate Commit mode ---
if ($Source -eq "Commit" -and -not $CommitRange) {
Write-Host "Error: -CommitRange required for Source=Commit" -ForegroundColor Red
exit 1
}
# --- Check git ---
try {
$null = git --version 2>&1
} catch {
Write-Host "Error: git not found in PATH" -ForegroundColor Red
exit 1
}
# --- Get changed files from Git ---
# Все git-вызовы для сбора путей идут через один хелпер с -c core.quotePath=false,
# иначе кириллические пути возвращаются в octal-виде и не распознаются (зеркало run_git в .py).
function Invoke-GitLines {
param([string[]]$GitArgs)
$out = git -c core.quotePath=false @GitArgs 2>&1
if ($LASTEXITCODE -eq 0) { return $out }
return @()
}
$changedFiles = @()
$ConfigDir = (Resolve-Path $ConfigDir).Path.TrimEnd('\')
$configDirNormalized = $ConfigDir.Replace('\', '/')
Push-Location $ConfigDir
try {
switch ($Source) {
"Staged" {
Write-Host "Getting staged changes..."
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
}
"Unstaged" {
Write-Host "Getting unstaged changes..."
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
}
"Commit" {
Write-Host "Getting changes from $CommitRange..."
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative', $CommitRange)
}
"All" {
Write-Host "Getting all uncommitted changes..."
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
}
}
} finally {
Pop-Location
}
$changedFiles = $changedFiles | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
if ($changedFiles.Count -eq 0) {
Write-Host "No changes found"
exit 0
}
Write-Host "Git changes detected: $($changedFiles.Count) files"
# --- Filter and map to config files ---
$configFiles = @()
$supportSkipped = @()
foreach ($file in $changedFiles) {
$file = $file.Trim().Replace('\', '/')
if ([string]::IsNullOrWhiteSpace($file)) { continue }
# Skip service files (not partially loadable). Support-state files are tracked
# to warn the user: support changes apply only via a full load.
if ($file -match 'ParentConfigurations\.bin$') { $supportSkipped += $file; continue }
if ($file -eq "ConfigDumpInfo.xml" -or $file -match '(^|/)ConfigDumpInfo\.xml$') { continue }
$fullPath = Join-Path $ConfigDir $file
if ($file -match '\.xml$') {
# XML file — add directly if exists
if (Test-Path $fullPath) {
if ($configFiles -notcontains $file) {
$configFiles += $file
}
}
}
else {
# Non-XML (BSL, HTML, etc.) — map to parent object XML + include all Ext/ files
$objectXml = Get-ObjectXmlFromSubFile -RelativePath $file
if ($objectXml) {
$fullXmlPath = Join-Path $ConfigDir $objectXml
if (Test-Path $fullXmlPath) {
if ($configFiles -notcontains $objectXml) {
$configFiles += $objectXml
}
if ((Test-Path $fullPath) -and $configFiles -notcontains $file) {
$configFiles += $file
}
# Add all files from Ext/ directory of the object
$parts = $file -split '[\\/]'
if ($parts.Count -ge 2) {
$extDir = Join-Path (Join-Path $ConfigDir $parts[0]) "$($parts[1])\Ext"
if (Test-Path $extDir) {
Get-ChildItem -Path $extDir -Recurse -File | ForEach-Object {
$extRelPath = $_.FullName.Replace("$ConfigDir\", '').Replace('\', '/')
if ($configFiles -notcontains $extRelPath) {
$configFiles += $extRelPath
}
}
}
}
}
}
}
}
if ($supportSkipped.Count -gt 0) {
Write-Host "[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):" -ForegroundColor Yellow
foreach ($sf in $supportSkipped) { Write-Host " - $sf" -ForegroundColor Yellow }
Write-Host " Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full)." -ForegroundColor Yellow
}
if ($configFiles.Count -eq 0) {
Write-Host "No configuration files found in changes"
exit 0
}
Write-Host "Files for loading: $($configFiles.Count)"
foreach ($f in $configFiles) { Write-Host " $f" }
# --- DryRun: stop here ---
if ($DryRun) {
Write-Host ""
Write-Host "DryRun mode - no changes applied"
exit 0
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "db_load_git_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch (file infobase only; import specific files) ---
if ($Format -eq "Plain") {
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
exit 1
}
if ($AllExtensions) {
Write-Host "Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)" -ForegroundColor Red
exit 1
}
$arguments = @("infobase", "config", "import", "files") + $configFiles
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
if ($Extension) { $arguments += "--extension=$Extension" }
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
if ($exitCode -ne 0) {
Write-Host "Error loading changes (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
Write-PlatformOutput $output
exit $exitCode
}
Write-Host "Changes loaded successfully ($($configFiles.Count) files)" -ForegroundColor Green
Write-PlatformOutput $output
if ($UpdateDB) {
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
if ($UserName) { $applyArgs += "--user=$UserName" }
if ($Password) { $applyArgs += "--password=$Password" }
$applyArgs += "--data=$tempDir"
$applyArgs += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
$applyOut = $__ib.Output
$exitCode = $__ib.ExitCode
if ($exitCode -eq 0) {
Write-Host "Database configuration updated successfully" -ForegroundColor Green
} else {
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $applyOut
}
exit $exitCode
}
# --- 1cv8 branch ---
# --- Write list file (UTF-8 with BOM) ---
$listFile = Join-Path $tempDir "load_list.txt"
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllLines($listFile, $configFiles, $utf8Bom)
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
$arguments += "-listFile", "`"$listFile`""
$arguments += "-Format", $Format
$arguments += "-partial"
$arguments += "-updateConfigDumpInfo"
# --- Extensions ---
if ($Extension) {
$arguments += "-Extension", "`"$Extension`""
} elseif ($AllExtensions) {
$arguments += "-AllExtensions"
}
# --- UpdateDB ---
if ($UpdateDB) {
$arguments += "/UpdateDBCfg"
}
# --- Output ---
$outFile = Join-Path $tempDir "load_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host ""
Write-Host "Executing partial configuration load..."
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
Write-Host ""
if ($exitCode -eq 0) {
Write-Host "Load completed successfully" -ForegroundColor Green
} else {
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
$logContent = $null
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
Write-RepositoryHints $logContent
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
$silentFailures = @(Find-SilentRejections $logContent)
if ($silentFailures.Count -gt 0) {
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
}
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,911 @@
#!/usr/bin/env python3
# db-load-git v1.26 — Load Git changes into 1C database
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def same_path(a, b):
if not a or not b:
return False
try:
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
except Exception:
return False
def find_project_database(args):
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
pf = _sg_find_v8project(os.getcwd())
if not pf:
return None
try:
with open(pf, encoding="utf-8-sig") as f:
proj = json.load(f)
except Exception:
return None
for db in proj.get("databases") or []:
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
return db
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
if (db["server"].lower() == args.InfoBaseServer.lower()
and db["ref"].lower() == args.InfoBaseRef.lower()):
return db
return None
def resolve_repository_settings(args):
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
db_rec = find_project_database(args)
rec = None
if db_rec:
if args.Extension:
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
for ext in db_rec.get("extensions") or []:
if (ext.get("name") or "").lower() == args.Extension.lower():
rec = ext.get("repository")
break
else:
rec = db_rec.get("repository")
path = args.RepositoryPath or ((rec or {}).get("path") or None)
user = args.RepositoryUser or ((rec or {}).get("user") or None)
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
return {
"path": path.strip().strip('"') if path else None,
"user": user,
"password": pwd,
"from_registry": bool(rec and rec.get("path")),
}
def repository_args(repo):
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
a = []
if not repo or not repo.get("path"):
return a
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
if repo.get("user"):
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
if repo.get("password"):
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
return a
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
def write_repository_hints(log_text):
if not log_text:
return
if "текущая конфигурация помещена в хранилище" in log_text:
print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
print(" Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
obj = m.group(1)
if obj == "Configuration":
print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
print(' /db-repo lock <база> -Objects "Конфигурация"')
else:
print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
if "Соединение с хранилищем конфигурации не установлено" in log_text:
print("[hint] соединение с хранилищем не установлено. Две причины:")
print(' реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
print(" либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def find_silent_rejections(log_text):
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
Возвращает подошедшие строки.
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
автономны). Держать копии одинаковыми сознательно: разошедшиеся копии сводят на нет
весь смысл.
"""
patterns = [
"Неверное свойство объекта метаданных",
"не входит в состав объекта метаданных",
"Неизвестное имя типа",
"Неизвестный объект метаданных",
"Ни один из документов не является регистратором для регистра",
"Неверное значение перечисления",
"не может быть приведен к типу",
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
"Для работы с конфигурацией необходима версия платформы не меньше",
]
found = []
if log_text:
for line in log_text.splitlines():
for pat in patterns:
if pat in line:
found.append(line.strip())
break
return found
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def get_object_xml_from_subfile(relative_path):
"""Map sub-file path (BSL, HTML, etc.) to object XML path."""
parts = re.split(r"[\\/]", relative_path)
if len(parts) >= 2:
return f"{parts[0]}/{parts[1]}.xml"
return None
def run_git(config_dir, git_args):
"""Run a git command in config_dir and return output lines on success."""
result = subprocess.run(
["git", "-c", "core.quotePath=false"] + git_args,
capture_output=True,
text=True,
encoding="utf-8",
cwd=config_dir,
)
if result.returncode == 0:
return [line for line in result.stdout.splitlines() if line.strip()]
return []
def describe_exit(code):
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
if code is None:
return ""
win = {
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
}
if code in win:
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
if -64 <= code < 0:
try:
import signal
name = signal.Signals(-code).name
except (ValueError, AttributeError):
name = f"signal {-code}"
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
"the infobase may be left in an inconsistent state; verify it before retrying")
return ""
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Load Git changes into 1C database",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password")
parser.add_argument("-RepositoryPath", default="")
parser.add_argument("-RepositoryUser", default="")
parser.add_argument("-RepositoryPassword", default="")
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration (git repo)")
parser.add_argument(
"-Source",
default="All",
choices=["All", "Staged", "Unstaged", "Commit"],
help="Change source (default: All)",
)
parser.add_argument("-CommitRange", default="", help="Commit range (for Source=Commit), e.g. HEAD~3..HEAD")
parser.add_argument("-Extension", default="", help="Extension name to load")
parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions")
parser.add_argument(
"-Format",
default="Hierarchical",
choices=["Hierarchical", "Plain"],
help="File format (default: Hierarchical)",
)
parser.add_argument("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)")
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
# но в логе есть отбраковка.
parser.add_argument("-StrictLog", action="store_true")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
# --- Resolve V8Path (skip if DryRun) ---
v8path = None
if not args.DryRun:
v8path = resolve_v8path(args.V8Path)
# --- Detect engine + validate connection (skip if DryRun) ---
engine = "1cv8"
if not args.DryRun:
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate config dir ---
if not os.path.exists(args.ConfigDir):
print(f"Error: config directory not found: {args.ConfigDir}")
sys.exit(1)
# --- Validate Commit mode ---
if args.Source == "Commit" and not args.CommitRange:
print("Error: -CommitRange required for Source=Commit")
sys.exit(1)
# --- Check git ---
try:
subprocess.run(["git", "--version"], capture_output=True, text=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: git not found in PATH")
sys.exit(1)
# --- Get changed files from Git ---
changed_files = []
if args.Source == "Staged":
print("Getting staged changes...")
changed_files += run_git(args.ConfigDir, ["diff", "--cached", "--name-only", "--relative"])
elif args.Source == "Unstaged":
print("Getting unstaged changes...")
changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative"])
changed_files += run_git(args.ConfigDir, ["ls-files", "--others", "--exclude-standard"])
elif args.Source == "Commit":
print(f"Getting changes from {args.CommitRange}...")
changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative", args.CommitRange])
elif args.Source == "All":
print("Getting all uncommitted changes...")
changed_files += run_git(args.ConfigDir, ["diff", "--cached", "--name-only", "--relative"])
changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative"])
changed_files += run_git(args.ConfigDir, ["ls-files", "--others", "--exclude-standard"])
# Deduplicate and filter blanks
changed_files = list(dict.fromkeys(f for f in changed_files if f.strip()))
if len(changed_files) == 0:
print("No changes found")
sys.exit(0)
print(f"Git changes detected: {len(changed_files)} files")
# --- Filter and map to config files ---
config_files = []
support_skipped = []
for file in changed_files:
file = file.strip().replace("\\", "/")
if not file:
continue
# Skip service files (not partially loadable). Support-state files are
# tracked to warn: support changes apply only via a full load.
if file.endswith("ParentConfigurations.bin"):
support_skipped.append(file)
continue
if file == "ConfigDumpInfo.xml" or file.endswith("/ConfigDumpInfo.xml"):
continue
full_path = os.path.join(args.ConfigDir, file)
if file.endswith(".xml"):
# XML file — add directly if exists
if os.path.exists(full_path):
if file not in config_files:
config_files.append(file)
else:
# Non-XML (BSL, HTML, etc.) — map to parent object XML + include all Ext/ files
object_xml = get_object_xml_from_subfile(file)
if object_xml:
full_xml_path = os.path.join(args.ConfigDir, object_xml)
if os.path.exists(full_xml_path):
if object_xml not in config_files:
config_files.append(object_xml)
if os.path.exists(full_path) and file not in config_files:
config_files.append(file)
# Add all files from Ext/ directory of the object
parts = re.split(r"[\\/]", file)
if len(parts) >= 2:
ext_dir = os.path.join(args.ConfigDir, parts[0], parts[1], "Ext")
if os.path.isdir(ext_dir):
for root, dirs, files in os.walk(ext_dir):
for fname in files:
abs_path = os.path.join(root, fname)
rel_path = os.path.relpath(abs_path, args.ConfigDir).replace("\\", "/")
if rel_path not in config_files:
config_files.append(rel_path)
if support_skipped:
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):")
for sf in support_skipped:
print(f" - {sf}")
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).")
if len(config_files) == 0:
print("No configuration files found in changes")
sys.exit(0)
print(f"Files for loading: {len(config_files)}")
for f in config_files:
print(f" {f}")
# --- DryRun: stop here ---
if args.DryRun:
print("")
print("DryRun mode - no changes applied")
sys.exit(0)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_git_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
if engine == "ibcmd":
# --- ibcmd branch (file infobase only; import specific files) ---
if args.Format == "Plain":
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1)
if args.AllExtensions:
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)")
sys.exit(1)
arguments = ["infobase", "config", "import", "files"] + config_files
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
if args.Extension:
arguments.append(f"--extension={args.Extension}")
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
if result.returncode != 0:
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode)
print(f"Changes loaded successfully ({len(config_files)} files)")
exit_code = 0
if args.UpdateDB:
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
if args.UserName:
apply_args.append(f"--user={args.UserName}")
if args.Password:
apply_args.append(f"--password={args.Password}")
apply_args.append(f"--data={ib_data}")
apply_args.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
exit_code = ar.returncode
if exit_code == 0:
print("Database configuration updated successfully")
else:
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
print_platform_output(ar)
sys.exit(exit_code)
# --- Write list file (UTF-8 with BOM) ---
list_file = os.path.join(temp_dir, "load_list.txt")
with open(list_file, "w", encoding="utf-8-sig") as f:
f.write("\n".join(config_files))
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
else:
arguments += ["/F", f'"{args.InfoBasePath}"']
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
arguments += ["-listFile", f'"{list_file}"']
arguments += ["-Format", args.Format]
arguments.append("-partial")
arguments.append("-updateConfigDumpInfo")
# --- Extensions ---
if args.Extension:
arguments += ["-Extension", f'"{args.Extension}"']
elif args.AllExtensions:
arguments.append("-AllExtensions")
# --- UpdateDB ---
if args.UpdateDB:
arguments.append("/UpdateDBCfg")
# --- Output ---
out_file = os.path.join(temp_dir, "load_log.txt")
arguments += ["/Out", f'"{out_file}"']
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print("")
print("Executing partial configuration load...")
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
print("")
if exit_code == 0:
print("Load completed successfully")
else:
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
log_content = ""
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
write_repository_hints(log_content)
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
silent_failures = find_silent_rejections(log_content)
if silent_failures:
print(
f"[warning] platform reported success, but the log contains "
f"{len(silent_failures)} problem(s):"
)
for line in silent_failures:
print(f" {line}")
if args.StrictLog and exit_code == 0:
exit_code = 1
sys.exit(exit_code)
finally:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
---
name: db-load-xml
description: Загрузка конфигурации 1С из XML-файлов. Используй когда нужно загрузить конфигурацию из файлов, XML, исходников, LoadConfigFromFiles
argument-hint: <configDir> [database]
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-load-xml — Загрузка конфигурации из XML
Загружает конфигурацию в информационную базу из XML-файлов (исходников). Поддерживает полную и частичную загрузку.
## Usage
```
/db-load-xml <configDir> [database]
/db-load-xml src/config dev
/db-load-xml src/config dev -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
```
> **Внимание**: полная загрузка **заменяет всю конфигурацию** в базе. Перед выполнением запроси подтверждение у пользователя.
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
Если в записи базы указан `configSrc` — используй как каталог загрузки по умолчанию.
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-xml/scripts/db-load-xml.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-ConfigDir <путь>` | да | Каталог XML-исходников |
| `-Mode <режим>` | нет | `Full` (по умолч.) / `Partial` |
| `-Files <список>` | для Partial | Относительные пути файлов через запятую |
| `-ListFile <путь>` | для Partial | Путь к файлу со списком (альтернатива `-Files`) |
| `-Extension <имя>` | нет | Загрузить в расширение |
| `-AllExtensions` | нет | Загрузить все расширения |
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
### Режимы загрузки
| Режим | Описание |
|-------|----------|
| `Full` | Полная загрузка — замена всей конфигурации из каталога XML |
| `Partial` | Частичная — загрузка выбранных файлов (с `-partial -updateConfigDumpInfo`) |
### Формат файла списка (listFile)
Файл содержит **относительные пути к файлам** в каталоге выгрузки (один на строку), кодировка **UTF-8 с BOM**:
```
Catalogs/Номенклатура.xml
Catalogs/Номенклатура/Ext/ObjectModule.bsl
Documents/Заказ.xml
Documents/Заказ/Forms/ФормаДокумента.xml
```
## После выполнения
Если `-UpdateDB` не был указан — **предложи выполнить `/db-update`** для применения изменений к БД
## Примеры
```powershell
# Полная загрузка
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-xml/scripts/db-load-xml.ps1" -V8Path "C:\Program Files\1cv8\8.3.25.1257\bin" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full
# Частичная загрузка конкретных файлов
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-xml/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
# Загрузка расширения
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-xml/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение"
# Загрузка + обновление БД в одном запуске
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-load-xml/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full -UpdateDB
```
@@ -0,0 +1,815 @@
# db-load-xml v1.28 — Load 1C configuration from XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Загрузка конфигурации 1С из XML-файлов
.DESCRIPTION
Загружает конфигурацию в информационную базу из XML-файлов.
Поддерживает полную и частичную загрузку.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER ConfigDir
Каталог XML-исходников конфигурации
.PARAMETER Mode
Режим загрузки: Full или Partial (по умолчанию Full)
.PARAMETER Files
Относительные пути файлов через запятую (для режима Partial)
.PARAMETER ListFile
Путь к файлу со списком файлов (альтернатива -Files, для режима Partial)
.PARAMETER Extension
Имя расширения для загрузки
.PARAMETER AllExtensions
Загрузить все расширения
.PARAMETER Format
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
.EXAMPLE
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$true)]
[string]$ConfigDir,
[Parameter(Mandatory=$false)]
# Пустое значение = режим не задан. Прежнее умолчание Full подставляется ниже, после того
# как станет видно, перечислены ли файлы.
[ValidateSet("", "Full", "Partial")]
[string]$Mode = "",
[Parameter(Mandatory=$false)]
[string]$Files,
[Parameter(Mandatory=$false)]
[string]$ListFile,
[Parameter(Mandatory=$false)]
[string]$Extension,
[Parameter(Mandatory=$false)]
[switch]$AllExtensions,
[Parameter(Mandatory=$false)]
[ValidateSet("Hierarchical", "Plain")]
[string]$Format = "Hierarchical",
[Parameter(Mandatory=$false)]
[switch]$UpdateDB,
[Parameter(Mandatory=$false)]
[switch]$StrictLog,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
function Find-V8Project([string]$startDir) {
$d = $startDir
for ($i = 0; $i -lt 20 -and $d; $i++) {
$pj = Join-Path $d ".v8-project.json"
if (Test-Path $pj) { return $pj }
$parent = [System.IO.Path]::GetDirectoryName($d)
if ($parent -eq $d) { break }
$d = $parent
}
return $null
}
function Test-SamePath {
param([string]$A, [string]$B)
if (-not $A -or -not $B) { return $false }
try {
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
} catch { return $false }
}
function Find-ProjectDatabase {
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
$pf = Find-V8Project (Get-Location).Path
if (-not $pf) { return $null }
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
if (-not $proj.databases) { return $null }
foreach ($db in $proj.databases) {
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
}
}
return $null
}
function Resolve-RepositorySettings {
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
$dbRec = Find-ProjectDatabase
$rec = $null
if ($dbRec) {
if ($Extension) {
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
if ($dbRec.extensions) {
foreach ($ext in $dbRec.extensions) {
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
$rec = $ext.repository
break
}
}
}
} else {
$rec = $dbRec.repository
}
}
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
return @{
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
User = $user
Password = $pwd
FromRegistry = [bool]($rec -and $rec.path)
DbRecord = $dbRec
}
}
function Get-RepositoryArgs {
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
param([hashtable]$Repo)
$a = @()
if (-not $Repo -or -not $Repo.Path) { return $a }
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
return $a
}
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
function Write-RepositoryHints {
param([string]$LogText)
if (-not $LogText) { return }
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
}
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
$obj = $m.Groups[1].Value
if ($obj -eq 'Configuration') {
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
} else {
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
}
}
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
}
}
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
function Get-ExitAnnotation {
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
# half-updated — surface that instead of a plain code. (Windows exception codes only;
# POSIX signals are handled in the .py port.)
param([int]$Code)
$win = @{
-1073741819 = "0xC0000005 (access violation)"
-1073741515 = "0xC0000135 (missing DLL)"
-1073740791 = "0xC0000409 (stack overrun)"
}
if ($win.ContainsKey($Code)) {
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
}
return ""
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
$ListFile = ConvertTo-CleanPath $ListFile '-ListFile'
function Assert-InfoBaseExists {
# These skills work on a ready infobase. Saying so up front beats the platform's
# "Неверные или отсутствующие параметры соединения" after a launch.
param([string]$Path)
if (-not $Path) { return }
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
exit 1
}
}
Assert-InfoBaseExists $InfoBasePath
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
#
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Find-SilentRejections {
param([string]$LogText)
$patterns = @(
'Неверное свойство объекта метаданных',
'не входит в состав объекта метаданных',
'Неизвестное имя типа',
'Неизвестный объект метаданных',
'Ни один из документов не является регистратором для регистра',
'Неверное значение перечисления',
'не может быть приведен к типу',
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
'Для работы с конфигурацией необходима версия платформы не меньше'
)
$found = @()
if ($LogText) {
foreach ($line in ($LogText -split "`r?`n")) {
foreach ($pat in $patterns) {
if ($line -match [regex]::Escape($pat)) {
$found += $line.Trim()
break
}
}
}
}
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
return $found
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
# --- Validate connection ---
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Validate config dir ---
if (-not (Test-Path $ConfigDir)) {
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
exit 1
}
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание Full
# заменило бы всю конфигурацию базы.
if ($Files -or $ListFile) {
if ($Mode -eq "Full") {
Write-Host "[note] перечислены файлы — загружаются только они; -Mode Full не применён" -ForegroundColor Yellow
}
$Mode = "Partial"
}
if (-not $Mode) { $Mode = "Full" }
# --- Validate Partial mode ---
if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) {
Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red
exit 1
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "db_load_xml_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
if ($Format -eq "Plain") {
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
exit 1
}
if ($AllExtensions) {
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
} elseif ($Mode -eq "Partial") {
# partial: import specific files (relative to ConfigDir)
$fileList = @()
if ($ListFile) {
if (-not (Test-Path $ListFile)) {
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
exit 1
}
$fileList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
} elseif ($Files) {
$fileList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
if ($fileList.Count -eq 0) {
Write-Host "Error: -Files or -ListFile required for partial import" -ForegroundColor Red
exit 1
}
$arguments = @("infobase", "config", "import", "files") + $fileList
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
if ($Extension) { $arguments += "--extension=$Extension" }
} else {
$arguments = @("infobase", "config", "import", "--db-path=$InfoBasePath")
if ($Extension) { $arguments += "--extension=$Extension" }
$arguments += "$ConfigDir"
}
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
if ($exitCode -ne 0) {
Write-Host "Error loading configuration from files (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
Write-PlatformOutput $output
exit $exitCode
}
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
Write-PlatformOutput $output
if ($UpdateDB) {
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
if ($UserName) { $applyArgs += "--user=$UserName" }
if ($Password) { $applyArgs += "--password=$Password" }
$applyArgs += "--data=$tempDir"
$applyArgs += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
$applyOut = $__ib.Output
$exitCode = $__ib.ExitCode
if ($exitCode -eq 0) {
Write-Host "Database configuration updated successfully" -ForegroundColor Green
} else {
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $applyOut
}
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
if ($Mode -eq "Full") {
Write-Host "Executing full configuration load..."
} else {
Write-Host "Executing partial configuration load..."
# Build list file
$rawList = @()
if ($ListFile) {
if (-not (Test-Path $ListFile)) {
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
exit 1
}
$rawList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
} else {
$rawList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
# Support-state service files are NOT partially loadable — exclude with a hint.
$supportRe = 'ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$'
$supportFiles = @($rawList | Where-Object { $_ -match $supportRe })
$fileList = @($rawList | Where-Object { $_ -notmatch $supportRe })
if ($supportFiles.Count -gt 0) {
Write-Host "[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):" -ForegroundColor Yellow
foreach ($sf in $supportFiles) { Write-Host " - $sf" -ForegroundColor Yellow }
Write-Host " Смена состояния поддержки применяется только полной загрузкой: -Mode Full." -ForegroundColor Yellow
}
if ($fileList.Count -eq 0) {
Write-Host "Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full." -ForegroundColor Red
exit 1
}
$generatedListFile = Join-Path $tempDir "load_list.txt"
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllLines($generatedListFile, $fileList, $utf8Bom)
Write-Host "Files to load: $($fileList.Count)"
foreach ($f in $fileList) { Write-Host " $f" }
$arguments += "-listFile", "`"$generatedListFile`""
$arguments += "-partial"
$arguments += "-updateConfigDumpInfo"
}
$arguments += "-Format", $Format
# --- Extensions ---
if ($Extension) {
$arguments += "-Extension", "`"$Extension`""
} elseif ($AllExtensions) {
$arguments += "-AllExtensions"
}
# --- UpdateDB ---
if ($UpdateDB) {
$arguments += "/UpdateDBCfg"
}
# --- Output ---
$outFile = Join-Path $tempDir "load_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Read log ---
$logContent = $null
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
}
# --- Scan log for silent rejections ---
$silentFailures = @(Find-SilentRejections $logContent)
# --- Result ---
# Default: mirror platform's verdict via exit code. Log content (including any
# rejection warnings) is always printed to stdout for visibility. With -StrictLog,
# elevate exit code to 1 when rejection patterns are found even if platform said 0.
if ($exitCode -eq 0) {
Write-Host "Load completed successfully" -ForegroundColor Green
} else {
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
Write-PlatformOutput $__v8.Output
Write-RepositoryHints $logContent
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
if ($silentFailures.Count -gt 0) {
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
}
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,851 @@
#!/usr/bin/env python3
# db-load-xml v1.28 — Load 1C configuration from XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def same_path(a, b):
if not a or not b:
return False
try:
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
except Exception:
return False
def find_project_database(args):
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
pf = _sg_find_v8project(os.getcwd())
if not pf:
return None
try:
with open(pf, encoding="utf-8-sig") as f:
proj = json.load(f)
except Exception:
return None
for db in proj.get("databases") or []:
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
return db
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
if (db["server"].lower() == args.InfoBaseServer.lower()
and db["ref"].lower() == args.InfoBaseRef.lower()):
return db
return None
def resolve_repository_settings(args):
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
db_rec = find_project_database(args)
rec = None
if db_rec:
if args.Extension:
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
for ext in db_rec.get("extensions") or []:
if (ext.get("name") or "").lower() == args.Extension.lower():
rec = ext.get("repository")
break
else:
rec = db_rec.get("repository")
path = args.RepositoryPath or ((rec or {}).get("path") or None)
user = args.RepositoryUser or ((rec or {}).get("user") or None)
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
return {
"path": path.strip().strip('"') if path else None,
"user": user,
"password": pwd,
"from_registry": bool(rec and rec.get("path")),
}
def repository_args(repo):
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
a = []
if not repo or not repo.get("path"):
return a
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
if repo.get("user"):
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
if repo.get("password"):
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
return a
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
def write_repository_hints(log_text):
if not log_text:
return
if "текущая конфигурация помещена в хранилище" in log_text:
print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
print(" Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
obj = m.group(1)
if obj == "Configuration":
print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
print(' /db-repo lock <база> -Objects "Конфигурация"')
else:
print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
if "Соединение с хранилищем конфигурации не установлено" in log_text:
print("[hint] соединение с хранилищем не установлено. Две причины:")
print(' реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
print(" либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def find_silent_rejections(log_text):
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
Возвращает подошедшие строки.
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
автономны). Держать копии одинаковыми сознательно: разошедшиеся копии сводят на нет
весь смысл.
"""
patterns = [
"Неверное свойство объекта метаданных",
"не входит в состав объекта метаданных",
"Неизвестное имя типа",
"Неизвестный объект метаданных",
"Ни один из документов не является регистратором для регистра",
"Неверное значение перечисления",
"не может быть приведен к типу",
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
"Для работы с конфигурацией необходима версия платформы не меньше",
]
found = []
if log_text:
for line in log_text.splitlines():
for pat in patterns:
if pat in line:
found.append(line.strip())
break
return found
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def describe_exit(code):
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
if code is None:
return ""
win = {
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
}
if code in win:
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
if -64 <= code < 0:
try:
import signal
name = signal.Signals(-code).name
except (ValueError, AttributeError):
name = f"signal {-code}"
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
"the infobase may be left in an inconsistent state; verify it before retrying")
return ""
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Load 1C configuration from XML files",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password")
parser.add_argument("-RepositoryPath", default="")
parser.add_argument("-RepositoryUser", default="")
parser.add_argument("-RepositoryPassword", default="")
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
parser.add_argument(
"-Mode",
default="",
choices=["", "Full", "Partial"],
help="Load mode (default: Full)",
)
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
parser.add_argument("-ListFile", default="", help="Path to file list (alternative to -Files, for Partial mode)")
parser.add_argument("-Extension", default="", help="Extension name to load")
parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions")
parser.add_argument(
"-Format",
default="Hierarchical",
choices=["Hierarchical", "Plain"],
help="File format (default: Hierarchical)",
)
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
parser.add_argument(
"-StrictLog",
action="store_true",
help="Treat silent rejection warnings in the log as errors (elevate exit code to 1)",
)
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
args.ListFile = clean_path(args.ListFile, "-ListFile")
# --- Resolve V8Path ---
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate connection ---
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- Validate config dir ---
if not os.path.exists(args.ConfigDir):
print(f"Error: config directory not found: {args.ConfigDir}")
sys.exit(1)
# --- Validate Partial mode ---
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание
# Full заменило бы всю конфигурацию базы.
if args.Files or args.ListFile:
if args.Mode == "Full":
print("[note] перечислены файлы — загружаются только они; -Mode Full не применён")
args.Mode = "Partial"
if not args.Mode:
args.Mode = "Full"
if args.Mode == "Partial" and not args.Files and not args.ListFile:
print("Error: -Files or -ListFile required for Partial mode")
sys.exit(1)
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
if engine == "ibcmd":
if args.Format == "Plain":
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1)
if args.AllExtensions:
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
elif args.Mode == "Partial":
# partial: import specific files (relative to ConfigDir)
if args.ListFile:
if not os.path.isfile(args.ListFile):
print(f"Error: list file not found: {args.ListFile}")
sys.exit(1)
with open(args.ListFile, encoding="utf-8-sig") as f:
file_list = [ln.strip() for ln in f if ln.strip()]
elif args.Files:
file_list = [p.strip() for p in args.Files.split(",") if p.strip()]
else:
file_list = []
if not file_list:
print("Error: -Files or -ListFile required for partial import")
sys.exit(1)
arguments = ["infobase", "config", "import", "files"] + file_list
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
if args.Extension:
arguments.append(f"--extension={args.Extension}")
else:
arguments = ["infobase", "config", "import", f"--db-path={args.InfoBasePath}"]
if args.Extension:
arguments.append(f"--extension={args.Extension}")
arguments.append(args.ConfigDir)
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
if result.returncode != 0:
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode)
print(f"Configuration loaded successfully from: {args.ConfigDir}")
exit_code = 0
if args.UpdateDB:
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
if args.UserName:
apply_args.append(f"--user={args.UserName}")
if args.Password:
apply_args.append(f"--password={args.Password}")
apply_args.append(f"--data={ib_data}")
apply_args.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
exit_code = ar.returncode
if exit_code == 0:
print("Database configuration updated successfully")
else:
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
print_platform_output(ar)
sys.exit(exit_code)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_xml_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
else:
arguments += ["/F", f'"{args.InfoBasePath}"']
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
if args.Mode == "Full":
print("Executing full configuration load...")
else:
print("Executing partial configuration load...")
# Build list file
if args.ListFile:
if not os.path.isfile(args.ListFile):
print(f"Error: list file not found: {args.ListFile}")
sys.exit(1)
with open(args.ListFile, encoding="utf-8-sig") as f:
raw_list = [ln.strip() for ln in f if ln.strip()]
else:
raw_list = [f.strip() for f in args.Files.split(",") if f.strip()]
# Support-state service files are NOT partially loadable — exclude with a hint.
support_re = re.compile(r"ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$")
support_files = [x for x in raw_list if support_re.search(x)]
file_list = [x for x in raw_list if not support_re.search(x)]
if support_files:
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):")
for sf in support_files:
print(f" - {sf}")
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.")
if not file_list:
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.")
sys.exit(1)
generated_list_file = os.path.join(temp_dir, "load_list.txt")
with open(generated_list_file, "w", encoding="utf-8-sig") as f:
f.write("\n".join(file_list))
print(f"Files to load: {len(file_list)}")
for fl in file_list:
print(f" {fl}")
arguments += ["-listFile", f'"{generated_list_file}"']
arguments.append("-partial")
arguments.append("-updateConfigDumpInfo")
arguments += ["-Format", args.Format]
# --- Extensions ---
if args.Extension:
arguments += ["-Extension", f'"{args.Extension}"']
elif args.AllExtensions:
arguments.append("-AllExtensions")
# --- UpdateDB ---
if args.UpdateDB:
arguments.append("/UpdateDBCfg")
# --- Output ---
out_file = os.path.join(temp_dir, "load_log.txt")
arguments += ["/Out", f'"{out_file}"']
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Read log ---
log_content = ""
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
except Exception:
log_content = ""
# --- Scan log for silent rejections ---
# Platform often writes load-time rejections into /Out but exits with code 0.
# These patterns flag cases where metadata was dropped or rejected silently.
silent_failures = find_silent_rejections(log_content)
# --- Result ---
# Default: mirror platform's verdict via exit code. Log content (including any
# rejection warnings) is always printed to stdout for visibility. With -StrictLog,
# elevate exit code to 1 when rejection patterns are found even if platform said 0.
if exit_code == 0:
print("Load completed successfully")
else:
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
print_platform_output(result)
write_repository_hints(log_content)
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
# Поток — stdout, как у PS1-порта: предупреждение относится к содержимому загрузки, а не к
# отказу навыка, и при code 0 остаётся предупреждением. Раньше py писал его в stderr —
# наблюдаемое поведение портов расходилось, и один кейс не мог проверить оба.
if silent_failures:
print(
f"[warning] platform reported success, but the log contains "
f"{len(silent_failures)} problem(s):"
)
for f in silent_failures:
print(f" {f}")
if args.StrictLog and exit_code == 0:
exit_code = 1
sys.exit(exit_code)
finally:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+200
View File
@@ -0,0 +1,200 @@
---
name: db-repo
description: Работа с хранилищем конфигурации 1С. Используй когда нужно захватить объекты, поместить изменения в хранилище конфигурации, получить изменения из него, подключить базу к хранилищу
argument-hint: <lock|unlock|commit|update> [database] -Objects "<объекты>"
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-repo — Хранилище конфигурации 1С
Захват и помещение объектов, получение изменений, подключение базы, история версий,
администрирование хранилища.
> Хранилище конфигурации 1С, а не Git-репозиторий.
## Usage
```
/db-repo lock [database] -Objects "Справочник.Номенклатура"
/db-repo commit [database] -Objects "Справочник.Номенклатура" -Comment "Добавлен Артикул"
/db-repo unlock [database] -Objects "Справочник.Номенклатура"
/db-repo update [database]
```
## Порядок работы
В базу, подключённую к хранилищу, исходники грузятся **только частично** и **только по захваченным**
объектам. Выполняй строго по шагам:
```
0. /db-repo update <база> — начать с актуального состояния
1. /db-repo lock <база> -Objects "Справочник.Номенклатура"
2. если шаг 0 или 1 напечатал «локальная конфигурация изменена, получено объектов из хранилища: N» —
выгрузи названные объекты: /db-dump-xml <база> <каталог> -Mode Partial -ObjectsFile "<файл из вывода>"
3. правки в исходниках: /meta-edit, /form-edit, /skd-edit, /meta-compile и т. д.
4. /db-load-xml <каталог> <база> -Mode Partial -Files "Catalogs/Номенклатура.xml,…" -UpdateDB
5. /db-repo commit <база> -Objects "Справочник.Номенклатура" -Comment "…"
```
Шаг 0 стоит делать всегда, когда работа не продолжается сразу после предыдущего цикла: правки
должны опираться на актуальное состояние — в том числе тех объектов, которые ты не меняешь, но
используешь.
Шаг 2 пропускать нельзя: захват и обновление подтягивают из хранилища свежие версии, и загрузка
исходников, снятых раньше, откатит чужие изменения — молча, без ошибки.
**Что вообще захватывается.** Отдельные объекты хранилища — сам объект, а также его **формы,
макеты и команды**. Реквизиты, табличные части, измерения и ресурсы отдельными объектами **не
являются**: они правятся в составе владельца.
| Что правишь | Что захватывать |
|-------------|-----------------|
| Реквизит, табличную часть, измерение, ресурс, модуль объекта | сам объект: `Справочник.Контрагенты` |
| Существующую форму, макет, команду | её саму: `Справочник.Контрагенты.Форма.ФормаЭлемента` |
| Добавляешь новую форму, макет, команду | объект-владельца; при помещении назови и новый объект |
| Добавляешь новый объект конфигурации | только корень: `Конфигурация`. Самого объекта ещё нет — захватить его нельзя; при помещении назови и его |
Захватывай минимум того, что правишь: чем шире захват, тем больше конфликтов с коллегами.
Захват объекта его формы и макеты не захватывает — для этого есть `-WithChildren`.
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` и разреши базу:
1. Если пользователь указал параметры подключения — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Реквизиты хранилища передавать не нужно: запись базы находится по переданным параметрам
соединения (`-InfoBasePath` либо `-InfoBaseServer` + `-InfoBaseRef`), реквизиты берутся из её
`repository`. Задать их явно можно параметрами `-Repository*`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-repo/scripts/db-repo.ps1" -Command <подкоманда> <параметры>
```
### Рабочий цикл
| Подкоманда | Что делает |
|------------|------------|
| `lock` | Захватить объекты |
| `unlock` | Отменить захват |
| `commit` | Поместить изменения в хранилище |
| `update` | Получить изменения из хранилища |
### Параметры
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Пользователь базы |
| `-Password <пароль>` | нет | Пароль пользователя базы |
| `-Objects <список>` | усл. | Объекты через запятую. Для `lock`, `unlock`, `commit` обязателен, если не задан `-All` |
| `-ObjectsFile <путь>` | нет | Файл со списком объектов, одно имя на строку |
| `-All` | нет | Операция над всей конфигурацией — вместо `-Objects`, а не вместе с ним |
| `-WithChildren` | нет | Вместе с подчинёнными объектами на полную глубину |
| `-Comment <текст>` | нет | Комментарий к помещению (`commit`). Многострочный — как есть, с переводами строк |
| `-KeepLocked` | нет | Оставить объекты захваченными после помещения |
| `-Revised` | нет | Получать захваченные объекты, если потребуется |
| `-Force` | нет | Разное по подкомандам — см. ниже |
| `-Extension <имя>` | нет | Работать с хранилищем расширения |
| `-RepositoryPath <путь>` | нет | Хранилище явно, вместо реестра |
| `-RepositoryUser <имя>` | нет | Пользователь хранилища явно |
| `-RepositoryPassword <пароль>` | нет | Пароль пользователя хранилища явно |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы `1cv8.exe` через запятую |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
### `-Force`
| Подкоманда | Что делает |
|------------|------------|
| `unlock` | **Теряет локальные правки**: объекты перезаписываются версией из хранилища |
| `commit` | Пытается очистить ссылки на удалённые объекты вместо ошибки |
| `update` | Подтверждает добавление и удаление объектов конфигурации |
### Имена объектов
Объект — `Справочник.Номенклатура`. Форма, макет, команда — полным путём:
`Документ.ЗаказПокупателя.Форма.ФормаДокумента`, `Справочник.Номенклатура.Макет.Печать`.
Корень конфигурации — `Конфигурация`.
Если объект «не найден», это не всегда опечатка: он мог появиться в хранилище позже, чем
обновлялась база (`/db-repo update`), либо это вовсе не объект хранилища — реквизит или
табличная часть.
## Результат
Нулевой код не означает, что что-то изменилось. Под нулём приходят «уже захвачено», «обновлять
нечего», «помещать нечего» и частичный захват — когда часть объектов занята другими, а остальное
захвачено и его можно править.
**Читай текст вывода, а не только код.** Там же приходит список полученных из хранилища объектов,
который требует перевыгрузки перед правкой.
## Требуют подтверждения пользователя
Перед этими операциями **спроси подтверждение**:
| Операция | Почему |
|----------|--------|
| `lock -All` | Захватывает **всю конфигурацию**: на большой базе идёт долго и блокирует работу всей команде |
| `unlock -Force` | Теряются локальные правки захваченных объектов |
| `disconnect` | Теряется подключение базы к хранилищу, в том числе на стороне хранилища |
| `connect -ForceReplaceCfg` | Конфигурация базы заменяется конфигурацией из хранилища |
`update` не выполнится, если у базы в реестре не объявлено `repository`, а реквизиты не заданы
явно: на неподключённой к хранилищу базе эта команда заменяет всю конфигурацию его содержимым и
рапортует успех.
## Расширения
У расширения своё хранилище со своим путём. Укажи `-Extension "<Имя>"` — реквизиты возьмутся из
`extensions[].repository` записи базы. Подкоманды работают одинаково для основной конфигурации и
для расширения.
## Остальные задачи
| Файл | Про что |
|------|---------|
| [connect.md](references/connect.md) | Подключение и отключение базы от хранилища |
| [history.md](references/history.md) | История версий, отчёт, выгрузка версии в CF |
| [admin.md](references/admin.md) | Создание хранилища, пользователи и права |
| [service.md](references/service.md) | Метки версий, оптимизация, очистка кеша |
## Примеры
```powershell
# Захватить справочник вместе с подчинёнными объектами
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -WithChildren
# Захватить корень — он нужен, чтобы добавить или удалить объект конфигурации
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация"
# Поместить новый объект: он уже существует, поэтому называется вместе с корнем
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-repo/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация,Справочник.Склады" -Comment "Добавлен справочник Склады"
# Поместить с комментарием, оставив захват
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-repo/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -Comment "Добавлен реквизит Артикул" -KeepLocked
# Получить изменения из хранилища
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-repo/scripts/db-repo.ps1" -Command update -InfoBasePath "C:\Bases\MyDB"
# Серверная база, расширение
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Extension "МоёРасширение" -Objects "Справочник.Номенклатура"
```
## После выполнения
- `lock` или `update` сообщил о полученных объектах — выполни `/db-dump-xml -Mode Partial` с
указанным в выводе файлом, и только потом правь исходники
- после `lock` правки идут через `/db-load-xml -Mode Partial` и `/db-update`
- изменения готовы — предложи `/db-repo commit` с комментарием
+45
View File
@@ -0,0 +1,45 @@
# Администрирование хранилища
## create — создать хранилище
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command create -InfoBasePath "C:\Bases\MyDB" -RepositoryPath "C:\Repo\MyApp" -RepositoryUser "Admin" -RepositoryPassword "…"
```
| Параметр | Описание |
|----------|----------|
| `-NoBind` | Не подключать базу к созданному хранилищу |
| `-AllowConfigurationChanges` | Включить возможность изменения, если конфигурация на поддержке без неё |
| `-ChangesAllowedRule <правило>` | Правило для объектов, изменения которых разрешены поставщиком |
| `-ChangesNotRecommendedRule <правило>` | То же для «изменения не рекомендуются» |
Правила: `ObjectNotEditable`, `ObjectIsEditableSupportEnabled`, `ObjectNotSupported`.
Без `-NoBind` база сразу подключается к созданному хранилищу. Создание — это версия 1.
Для расширения: `-Extension "<Имя>"` и отдельный путь — у расширения своё хранилище.
## add-user — создать пользователя
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command add-user -InfoBasePath "C:\Bases\MyDB" -NewUser "Ivanov" -NewUserPassword "…" -Rights LockObjects
```
| Право | Что даёт |
|-------|----------|
| `ReadOnly` | Просмотр |
| `LockObjects` | Захват объектов |
| `ManageConfigurationVersions` | Изменение состава версий |
| `Administration` | Административные функции |
`-RestoreDeletedUser` — восстановить одноимённого удалённого. Если пользователь с таким именем
существует, он **не** будет добавлен. Выполняющий должен иметь административные права.
## copy-users — скопировать пользователей из другого хранилища
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command copy-users -InfoBasePath "C:\Bases\MyDB" -SourcePath "\\srv01\repo\Other" -SourceUser "Admin" -SourcePassword "…"
```
`-SourcePath`, `-SourceUser`, `-SourcePassword` описывают хранилище-**источник**. Удалённые пользователи
не копируются; существующие не перезаписываются.
@@ -0,0 +1,39 @@
# Подключение базы к хранилищу
## connect — подключить
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command connect -InfoBasePath "C:\Bases\MyDB" -RepositoryPath "\\srv01\repo\MyApp" -RepositoryUser "Ivanov" -RepositoryPassword "…"
```
| Параметр | Описание |
|----------|----------|
| `-ForceReplaceCfg` | Конфигурация базы непустая — подтвердить замену её конфигурацией из хранилища. **Спроси подтверждение у пользователя** |
| `-ForceBindAlreadyBindedUser` | Подключить, даже если у этого пользователя уже есть конфигурация, связанная с хранилищем |
На пустой базе `-ForceReplaceCfg` не нужен.
**Переподключение** базы, которая уже была подключена, требует обоих флагов: конфигурация в базе
не пустая (`-ForceReplaceCfg`), а за пользователем хранилища всё ещё числится эта база
(`-ForceBindAlreadyBindedUser`).
После подключения добавь `repository` в запись базы в `.v8-project.json` — иначе остальные
подкоманды придётся каждый раз звать с явными реквизитами, а `update` откажется работать.
## disconnect — отключить
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command disconnect -InfoBasePath "C:\Bases\MyDB"
```
**Спроси подтверждение у пользователя.** Отключение снимает связь и на стороне самого хранилища:
запись о подключении удаляется. Подключить базу обратно можно, но это уже не рядовая операция —
понадобятся оба флага `connect` из раздела выше.
Если в базе есть захваченные и изменённые объекты, операция не выполнится. `-Force` выполняет её
всё равно, и эти изменения теряются.
## Расширения
У расширения своё хранилище: `-Extension "<Имя>"` указывай вместе с путём именно к нему, а не
к хранилищу основной конфигурации.
@@ -0,0 +1,31 @@
# История версий хранилища
## report — отчёт по версиям
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command report -InfoBasePath "C:\Bases\MyDB" -OutputFile "C:\tmp\repo.txt"
```
| Параметр | Описание |
|----------|----------|
| `-OutputFile <путь>` | Куда сохранить отчёт. Необязателен |
| `-NBegin <номер>` | С какой версии. `-1` — только последняя |
| `-NEnd <номер>` | По какую версию |
| `-DateBegin` / `-DateEnd` | Границы по датам |
| `-GroupByObject` | Группировать по объектам |
| `-GroupByComment` | Группировать по комментарию |
| `-ReportFormat <txt\|mxl>` | По умолчанию `txt` |
`txt` — с разделителем-табуляцией, разбирается построчно.
> На боевом хранилище полный отчёт строить не надо — тысячи версий. Нужна головная
> версия — `-NBegin -1`. Длинный отчёт в вывод не печатается: сузьте выборку
> параметрами ниже.
## dump-cfg — выгрузить версию в CF
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command dump-cfg -InfoBasePath "C:\Bases\MyDB" -OutputFile "C:\tmp\v120.cf" -Version 120
```
Без `-Version` (или при `-1`) выгружается последняя версия.
@@ -0,0 +1,31 @@
# Сервисные операции
## set-label — метка на версию
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command set-label -InfoBasePath "C:\Bases\MyDB" -Label "Релиз 1.2" -Version 120 -Comment "Передано в тест"
```
Без `-Version` метка ставится на последнюю версию. Несуществующая версия — ошибка.
## optimize — оптимизация хранения
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command optimize -InfoBasePath "C:\Bases\MyDB"
```
Оптимизирует хранение данных в хранилище. Операция долгая.
## clear-cache — очистка кеша
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command clear-cache -InfoBasePath "C:\Bases\MyDB" -CacheScope local
```
| `-CacheScope` | Что чистит |
|---------------|------------|
| `local` (по умолчанию) | Локальный кеш версий конфигурации |
| `global` | Глобальный кеш версий |
| `db` | Локальную базу данных хранилища |
Пригождается, когда хранилище ведёт себя странно после сбоя сети или отката версии.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
---
name: db-run
description: Запуск 1С:Предприятие. Используй когда нужно запустить 1С, открыть базу, запустить предприятие
argument-hint: "[database]"
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-run — Запуск 1С:Предприятие
Запускает информационную базу в режиме 1С:Предприятие (пользовательский режим).
## Usage
```
/db-run [database]
/db-run dev
/db-run dev /Execute process.epf
/db-run dev /C "параметр запуска"
```
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-run/scripts/db-run.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-Execute <файл.epf>` | нет | Запуск внешней обработки сразу после старта |
| `-CParam <строка>` | нет | Параметр запуска (/C) |
| `-URL <ссылка>` | нет | Навигационная ссылка (формат `e1cib/...`) |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
## Важно
Скрипт запускает 1С в фоне (`Start-Process` без `-Wait`) — управление возвращается сразу.
## Примеры
```powershell
# Простой запуск
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-run/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin"
# Запуск с обработкой
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-run/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Execute "C:\epf\МояОбработка.epf"
# Открыть по навигационной ссылке
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-run/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -URL "e1cib/data/Справочник.Номенклатура"
# Серверная база с параметром запуска
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-run/scripts/db-run.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -CParam "ЗапуститьОбновление"
```
+369
View File
@@ -0,0 +1,369 @@
# db-run v1.10 — Launch 1C:Enterprise
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Запуск 1С:Предприятие
.DESCRIPTION
Запускает информационную базу в режиме 1С:Предприятие (пользовательский режим).
Запуск в фоне не ждёт завершения процесса.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER Execute
Путь к внешней обработке для запуска
.PARAMETER CParam
Параметр запуска (/C)
.PARAMETER URL
Навигационная ссылка (e1cib/...)
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB"
.EXAMPLE
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -Execute "C:\epf\МояОбработка.epf"
.EXAMPLE
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -CParam "ЗапуститьОбновление"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$false)]
[string]$Execute,
[Parameter(Mandatory=$false)]
[string]$CParam,
[Parameter(Mandatory=$false)]
[string]$URL,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$Execute = ConvertTo-CleanPath $Execute '-Execute'
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Resolve additional arguments ---
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
$engine = "1cv8"
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '/Execute' = '-Execute'; '/C' = '-CParam'; '/URL' = '-URL' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
function Format-ArgToken {
# ShellExecute re-joins the argument string, so quote each extra token that needs it.
param([string]$Token)
if ($Token -match '[\s"]') { return ' "' + ($Token -replace '"', '\"') + '"' }
return " $Token"
}
# --- Validate connection ---
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Build arguments as single string ---
# Note: Start-Process without -NoNewWindow uses ShellExecute.
# Passing ArgumentList as array can corrupt Cyrillic when ShellExecute
# re-joins elements. Single string avoids this.
$argString = "ENTERPRISE"
if ($InfoBaseServer -and $InfoBaseRef) {
$argString += " /S `"$InfoBaseServer/$InfoBaseRef`""
} else {
$argString += " /F `"$InfoBasePath`""
}
if ($UserName) { $argString += " /N`"$UserName`"" }
if ($Password) { $argString += " /P`"$Password`"" }
# --- Optional params ---
if ($Execute) {
$ext = [System.IO.Path]::GetExtension($Execute).ToLower()
if ($ext -eq ".erf") {
Write-Host "[WARN] /Execute не поддерживает ERF-файлы (внешние отчёты)." -ForegroundColor Yellow
Write-Host " Откройте отчёт через «Файл -> Открыть»: $Execute" -ForegroundColor Yellow
Write-Host " Запускаю базу без /Execute." -ForegroundColor Yellow
$Execute = ""
}
}
if ($Execute) {
$argString += " /Execute `"$Execute`""
}
if ($CParam) {
$argString += " /C `"$CParam`""
}
if ($URL) {
$argString += " /URL `"$URL`""
}
$argString += " /DisableStartupDialogs"
# The display string is built from the same tokens with secret-prone values redacted.
$displayString = $argString
foreach ($tok in $extraArgs) { $argString += (Format-ArgToken $tok) }
foreach ($tok in (Format-ArgsForDisplay $extraArgs $engine)) { $displayString += (Format-ArgToken $tok) }
# --- Execute (background) ---
# Redact the password/user before printing the command line — never leak secrets.
$displayArg = Protect-Secrets $displayString @($Password, $UserName)
Write-Host "Running: 1cv8.exe $displayArg"
$proc = Start-Process -FilePath $V8Path -ArgumentList $argString -PassThru
# --- Bounded early-exit check ---
# The launch is a background GUI process, so we don't wait for completion. But a process
# that dies within the first ~1.5s never really started (bad base, no display, license) —
# report that honestly instead of a blind "launched".
$deadline = (Get-Date).AddMilliseconds(1500)
while ((Get-Date) -lt $deadline -and -not $proc.HasExited) {
Start-Sleep -Milliseconds 200
}
if ($proc.HasExited) {
Write-Host "Error: 1C:Enterprise exited immediately (code: $($proc.ExitCode))" -ForegroundColor Red
if ($proc.ExitCode -ne 0) { exit $proc.ExitCode } else { exit 1 }
}
Write-Host "PID: $($proc.Id)"
Write-Host "1C:Enterprise launched" -ForegroundColor Green
+383
View File
@@ -0,0 +1,383 @@
#!/usr/bin/env python3
# db-run v1.10 — Launch 1C:Enterprise
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import glob
import json
import os
import re
import subprocess
import sys
import time
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Launch 1C:Enterprise",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="")
parser.add_argument("-InfoBasePath", default="")
parser.add_argument("-InfoBaseServer", default="")
parser.add_argument("-InfoBaseRef", default="")
parser.add_argument("-UserName", default="")
parser.add_argument("-Password", default="")
parser.add_argument("-Execute", default="")
parser.add_argument("-CParam", default="")
parser.add_argument("-URL", default="")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
args.Execute = clean_path(args.Execute, "-Execute")
v8path = resolve_v8path(args.V8Path)
# --- Resolve additional arguments ---
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
engine = "1cv8"
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"/Execute": "-Execute",
"/C": "-CParam",
"/URL": "-URL",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate connection ---
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- Build arguments ---
arguments = ["ENTERPRISE"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
else:
arguments.extend(["/F", args.InfoBasePath])
if args.UserName:
arguments.append(f"/N{args.UserName}")
if args.Password:
arguments.append(f"/P{args.Password}")
# --- Optional params ---
execute = args.Execute
if execute:
ext = os.path.splitext(execute)[1].lower()
if ext == ".erf":
print("[WARN] /Execute does not support ERF files (external reports).")
print(f" Open the report via File -> Open: {execute}")
print(" Launching database without /Execute.")
execute = ""
if execute:
arguments.extend(["/Execute", execute])
if args.CParam:
arguments.extend(["/C", args.CParam])
if args.URL:
arguments.extend(["/URL", args.URL])
arguments.append("/DisableStartupDialogs")
arguments.extend(extra_args)
# --- Execute (background) ---
# Redact the password/user before printing the command line — never leak secrets.
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
proc = subprocess.Popen([v8path] + arguments)
# --- Bounded early-exit check ---
# The launch is a background GUI process, so we don't wait for completion. But a process
# that dies within the first ~1.5s never really started (bad base, no display, license) —
# report that honestly instead of a blind "launched".
deadline = time.monotonic() + 1.5
while time.monotonic() < deadline and proc.poll() is None:
time.sleep(0.2)
rc = proc.poll()
if rc is not None:
print(f"Error: 1C:Enterprise exited immediately (code: {rc})")
sys.exit(rc if rc and rc > 0 else 1)
print(f"PID: {proc.pid}")
print("1C:Enterprise launched")
if __name__ == "__main__":
main()
+88
View File
@@ -0,0 +1,88 @@
---
name: db-update
description: Обновление конфигурации базы данных 1С. Используй когда нужно обновить БД, применить конфигурацию, UpdateDBCfg
argument-hint: "[database]"
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-update — Обновление конфигурации БД
Применяет изменения основной конфигурации к конфигурации базы данных (`/UpdateDBCfg`). Обязательный шаг после `/db-load-cf`, `/db-load-xml`, `/db-load-git`.
## Usage
```
/db-update [database]
/db-update dev
/db-update dev -Dynamic+
```
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-update/scripts/db-update.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-Extension <имя>` | нет | Обновить расширение |
| `-AllExtensions` | нет | Обновить все расширения |
| `-Dynamic <+/->` | нет | `+` — динамическое обновление, `-` — отключить |
| `-Server` | нет | Обновление на стороне сервера |
| `-WarningsAsErrors` | нет | Предупреждения считать ошибками |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
### Фоновое обновление (серверная база)
| Параметр | Описание |
|----------|----------|
| `-BackgroundStart` | Начать фоновое обновление |
| `-BackgroundFinish` | Дождаться окончания |
| `-BackgroundCancel` | Отменить |
| `-BackgroundSuspend` | Приостановить |
| `-BackgroundResume` | Возобновить |
## Предупреждения
- Если обновление **не динамическое** — потребуется **монопольный доступ** к базе (все пользователи должны выйти)
- Для серверных баз рекомендуется `-Dynamic+` для обновления без остановки
- Если структура данных существенно изменилась (удаление реквизитов, изменение типов) — динамическое обновление может быть невозможно
## Примеры
```powershell
# Обычное обновление (файловая база)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-update/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin"
# Динамическое обновление (серверная база)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-update/scripts/db-update.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -Dynamic "+"
# Обновление расширения
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/db-update/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Extension "МоёРасширение"
```
@@ -0,0 +1,665 @@
# db-update v1.19 — Update 1C database configuration
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Обновление конфигурации базы данных 1С
.DESCRIPTION
Применяет изменения основной конфигурации к конфигурации базы данных.
Поддерживает динамическое обновление, обновление расширений.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER Extension
Имя расширения для обновления
.PARAMETER AllExtensions
Обновить все расширения
.PARAMETER Dynamic
Динамическое обновление: "+" включить, "-" отключить
.PARAMETER Server
Обновление на стороне сервера
.PARAMETER WarningsAsErrors
Предупреждения считать ошибками
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB"
.EXAMPLE
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB" -Dynamic "+" -Extension "МоёРасширение"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$false)]
[string]$Extension,
[Parameter(Mandatory=$false)]
[switch]$AllExtensions,
[Parameter(Mandatory=$false)]
[ValidateSet("+", "-")]
[string]$Dynamic,
[Parameter(Mandatory=$false)]
[switch]$Server,
[Parameter(Mandatory=$false)]
[switch]$WarningsAsErrors,
[Parameter(Mandatory=$false)]
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
# но в логе есть отбраковка.
[switch]$StrictLog,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
function Find-V8Project([string]$startDir) {
$d = $startDir
for ($i = 0; $i -lt 20 -and $d; $i++) {
$pj = Join-Path $d ".v8-project.json"
if (Test-Path $pj) { return $pj }
$parent = [System.IO.Path]::GetDirectoryName($d)
if ($parent -eq $d) { break }
$d = $parent
}
return $null
}
function Test-SamePath {
param([string]$A, [string]$B)
if (-not $A -or -not $B) { return $false }
try {
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
} catch { return $false }
}
function Find-ProjectDatabase {
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
$pf = Find-V8Project (Get-Location).Path
if (-not $pf) { return $null }
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
if (-not $proj.databases) { return $null }
foreach ($db in $proj.databases) {
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
}
}
return $null
}
function Resolve-RepositorySettings {
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
$dbRec = Find-ProjectDatabase
$rec = $null
if ($dbRec) {
if ($Extension) {
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
if ($dbRec.extensions) {
foreach ($ext in $dbRec.extensions) {
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
$rec = $ext.repository
break
}
}
}
} else {
$rec = $dbRec.repository
}
}
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
return @{
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
User = $user
Password = $pwd
FromRegistry = [bool]($rec -and $rec.path)
DbRecord = $dbRec
}
}
function Get-RepositoryArgs {
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
param([hashtable]$Repo)
$a = @()
if (-not $Repo -or -not $Repo.Path) { return $a }
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
return $a
}
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
function Get-ExitAnnotation {
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
# half-updated — surface that instead of a plain code. (Windows exception codes only;
# POSIX signals are handled in the .py port.)
param([int]$Code)
$win = @{
-1073741819 = "0xC0000005 (access violation)"
-1073741515 = "0xC0000135 (missing DLL)"
-1073740791 = "0xC0000409 (stack overrun)"
}
if ($win.ContainsKey($Code)) {
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
}
return ""
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
function Assert-InfoBaseExists {
# These skills work on a ready infobase. Saying so up front beats the platform's
# "Неверные или отсутствующие параметры соединения" after a launch.
param([string]$Path)
if (-not $Path) { return }
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
exit 1
}
}
Assert-InfoBaseExists $InfoBasePath
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
#
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Find-SilentRejections {
param([string]$LogText)
$patterns = @(
'Неверное свойство объекта метаданных',
'не входит в состав объекта метаданных',
'Неизвестное имя типа',
'Неизвестный объект метаданных',
'Ни один из документов не является регистратором для регистра',
'Неверное значение перечисления',
'не может быть приведен к типу',
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
'Для работы с конфигурацией необходима версия платформы не меньше'
)
$found = @()
if ($LogText) {
foreach ($line in ($LogText -split "`r?`n")) {
foreach ($pat in $patterns) {
if ($line -match [regex]::Escape($pat)) {
$found += $line.Trim()
break
}
}
}
}
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
return $found
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
# --- Validate connection ---
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "db_update_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch (file infobase only) ---
if ($AllExtensions) {
Write-Host "Error: ibcmd config apply does not support -AllExtensions (use -Extension)" -ForegroundColor Red
exit 1
}
$arguments = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
if ($Dynamic -eq "+") { $arguments += "--dynamic=auto" }
elseif ($Dynamic -eq "-") { $arguments += "--dynamic=disable" }
if ($Extension) { $arguments += "--extension=$Extension" }
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
if ($exitCode -eq 0) {
Write-Host "Database configuration updated successfully" -ForegroundColor Green
} else {
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $output
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/UpdateDBCfg"
# --- Options ---
if ($Dynamic) {
$arguments += "-Dynamic$Dynamic"
}
if ($Server) {
$arguments += "-Server"
}
if ($WarningsAsErrors) {
$arguments += "-WarningsAsErrors"
}
# --- Extensions ---
if ($Extension) {
$arguments += "-Extension", "`"$Extension`""
} elseif ($AllExtensions) {
$arguments += "-AllExtensions"
}
# --- Output ---
$outFile = Join-Path $tempDir "update_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
if ($exitCode -eq 0) {
Write-Host "Database configuration updated successfully" -ForegroundColor Green
} else {
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
}
$logContent = $null
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
$silentFailures = @(Find-SilentRejections $logContent)
if ($silentFailures.Count -gt 0) {
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
}
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,711 @@
#!/usr/bin/env python3
# db-update v1.19 — Update 1C database configuration
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def same_path(a, b):
if not a or not b:
return False
try:
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
except Exception:
return False
def find_project_database(args):
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
pf = _sg_find_v8project(os.getcwd())
if not pf:
return None
try:
with open(pf, encoding="utf-8-sig") as f:
proj = json.load(f)
except Exception:
return None
for db in proj.get("databases") or []:
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
return db
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
if (db["server"].lower() == args.InfoBaseServer.lower()
and db["ref"].lower() == args.InfoBaseRef.lower()):
return db
return None
def resolve_repository_settings(args):
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
db_rec = find_project_database(args)
rec = None
if db_rec:
if args.Extension:
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
for ext in db_rec.get("extensions") or []:
if (ext.get("name") or "").lower() == args.Extension.lower():
rec = ext.get("repository")
break
else:
rec = db_rec.get("repository")
path = args.RepositoryPath or ((rec or {}).get("path") or None)
user = args.RepositoryUser or ((rec or {}).get("user") or None)
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
return {
"path": path.strip().strip('"') if path else None,
"user": user,
"password": pwd,
"from_registry": bool(rec and rec.get("path")),
}
def repository_args(repo):
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
a = []
if not repo or not repo.get("path"):
return a
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
if repo.get("user"):
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
if repo.get("password"):
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
return a
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def find_silent_rejections(log_text):
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
Возвращает подошедшие строки.
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
автономны). Держать копии одинаковыми сознательно: разошедшиеся копии сводят на нет
весь смысл.
"""
patterns = [
"Неверное свойство объекта метаданных",
"не входит в состав объекта метаданных",
"Неизвестное имя типа",
"Неизвестный объект метаданных",
"Ни один из документов не является регистратором для регистра",
"Неверное значение перечисления",
"не может быть приведен к типу",
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
"Для работы с конфигурацией необходима версия платформы не меньше",
]
found = []
if log_text:
for line in log_text.splitlines():
for pat in patterns:
if pat in line:
found.append(line.strip())
break
return found
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def describe_exit(code):
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
if code is None:
return ""
win = {
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
}
if code in win:
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
if -64 <= code < 0:
try:
import signal
name = signal.Signals(-code).name
except (ValueError, AttributeError):
name = f"signal {-code}"
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
"the infobase may be left in an inconsistent state; verify it before retrying")
return ""
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Update 1C database configuration",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="")
parser.add_argument("-InfoBasePath", default="")
parser.add_argument("-InfoBaseServer", default="")
parser.add_argument("-InfoBaseRef", default="")
parser.add_argument("-UserName", default="")
parser.add_argument("-Password", default="")
parser.add_argument("-RepositoryPath", default="")
parser.add_argument("-RepositoryUser", default="")
parser.add_argument("-RepositoryPassword", default="")
parser.add_argument("-Extension", default="")
parser.add_argument("-AllExtensions", action="store_true")
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
parser.add_argument("-Server", action="store_true")
parser.add_argument("-WarningsAsErrors", action="store_true")
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
# но в логе есть отбраковка.
parser.add_argument("-StrictLog", action="store_true")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate connection ---
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1)
# --- ibcmd branch (file infobase only) ---
if engine == "ibcmd":
if args.AllExtensions:
print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)")
sys.exit(1)
arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
if args.Dynamic == "+":
arguments.append("--dynamic=auto")
elif args.Dynamic == "-":
arguments.append("--dynamic=disable")
if args.Extension:
arguments.append(f"--extension={args.Extension}")
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
if result.returncode == 0:
print("Database configuration updated successfully")
else:
print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"db_update_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
else:
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments.append("/UpdateDBCfg")
# --- Options ---
if args.Dynamic:
arguments.append(f"-Dynamic{args.Dynamic}")
if args.Server:
arguments.append("-Server")
if args.WarningsAsErrors:
arguments.append("-WarningsAsErrors")
# --- Extensions ---
if args.Extension:
arguments.extend(["-Extension", f'"{args.Extension}"'])
elif args.AllExtensions:
arguments.append("-AllExtensions")
# --- Output ---
out_file = os.path.join(temp_dir, "update_log.txt")
arguments.extend(["/Out", f'"{out_file}"'])
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
if exit_code == 0:
print("Database configuration updated successfully")
else:
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
log_content = ""
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
silent_failures = find_silent_rejections(log_content)
if silent_failures:
print(
f"[warning] platform reported success, but the log contains "
f"{len(silent_failures)} problem(s):"
)
for line in silent_failures:
print(f" {line}")
if args.StrictLog and exit_code == 0:
exit_code = 1
sys.exit(exit_code)
finally:
if os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+196
View File
@@ -0,0 +1,196 @@
---
name: epf-bsp-add-command
description: Определить команду в БСП‑описании обработки (`СведенияОВнешнейОбработке`) — открытие формы, вызов клиентского/серверного метода, заполнение объекта и т.п. Используй когда нужно зарегистрировать команду в дополнительной обработке БСП
argument-hint: <ProcessorName> <Идентификатор> [ТипКоманды] [Представление]
allowed-tools:
- Read
- Edit
- Glob
- Grep
---
# /epf-bsp-add-command — Добавление команды БСП
Добавляет команду в существующую функцию `СведенияОВнешнейОбработке()` и генерирует соответствующий обработчик.
Предварительно обработка должна быть инициализирована через `/epf-bsp-init`.
## Usage
```
/epf-bsp-add-command <ProcessorName> <Идентификатор> [ТипКоманды] [Представление]
```
| Параметр | Обязательный | По умолчанию | Описание |
|---------------|:------------:|-----------------------|--------------------------------------------|
| ProcessorName | да | — | Имя обработки |
| Идентификатор | да | — | Внутреннее имя команды (латиница) |
| ТипКоманды | нет | из вида обработки | Тип запуска команды (см. маппинг ниже) |
| Представление | нет | = Идентификатор | Отображаемое имя команды для пользователя |
| SrcDir | нет | `src` | Каталог исходников |
## Маппинг типов команд
Пользователь может указать тип в свободной форме:
| Пользователь пишет | ТипКоманды |
|---------------------------------------|-----------------------------------------------------|
| открыть форму, форма | `ТипКомандыОткрытиеФормы()` |
| клиентский метод, на клиенте | `ТипКомандыВызовКлиентскогоМетода()` |
| серверный метод, на сервере | `ТипКомандыВызовСерверногоМетода()` |
| заполнение формы, заполнить форму | `ТипКомандыЗаполнениеФормы()` |
| сценарий, безопасный режим | `ТипКомандыСценарийВБезопасномРежиме()` |
Если пользователь не указал тип — определи по виду обработки из существующего кода `СведенияОВнешнейОбработке()`:
| Вид обработки (из кода) | ТипКоманды по умолчанию |
|----------------------------|-------------------------------------------|
| ДополнительнаяОбработка | `ТипКомандыОткрытиеФормы()` |
| ДополнительныйОтчет | `ТипКомандыОткрытиеФормы()` |
| ЗаполнениеОбъекта | `ТипКомандыВызовСерверногоМетода()` |
| Отчет | `ТипКомандыОткрытиеФормы()` |
| ПечатнаяФорма | `ТипКомандыВызовСерверногоМетода()` |
| СозданиеСвязанныхОбъектов | `ТипКомандыВызовСерверногоМетода()` |
## Шаблон добавления команды
Вставляется в `СведенияОВнешнейОбработке()` **перед** строкой `Возврат ПараметрыРегистрации`:
```bsl
НоваяКоманда = ПараметрыРегистрации.Команды.Добавить();
НоваяКоманда.Представление = НСтр("ru = '{{Представление}}'");
НоваяКоманда.Идентификатор = "{{Идентификатор}}";
НоваяКоманда.Использование = ДополнительныеОтчетыИОбработкиКлиентСервер.{{ТипКоманды}};
НоваяКоманда.ПоказыватьОповещение = Ложь;
```
Для печатных форм (ВидОбработкиПечатнаяФорма) добавь также:
```bsl
НоваяКоманда.Модификатор = "ПечатьMXL";
```
Примечание: в отличие от первой команды (из `/epf-bsp-init`), дополнительные команды используют строковые литералы `НСтр("ru = '...'")` для представления и строку для идентификатора, а не `Метаданные()`.
## Шаблоны обработчиков
### ВызовСерверногоМетода — если обработчик уже есть
Если процедура `ВыполнитьКоманду` уже существует в модуле объекта, добавь ветку перед `КонецЕсли`:
```bsl
ИначеЕсли ИдентификаторКоманды = "{{Идентификатор}}" Тогда
// TODO: Реализация {{Идентификатор}}
```
### ВызовСерверногоМетода — если обработчика нет
Для глобальных обработок (без `ОбъектыНазначения`):
```bsl
Процедура ВыполнитьКоманду(ИдентификаторКоманды, ПараметрыВыполненияКоманды) Экспорт
Если ИдентификаторКоманды = "{{Идентификатор}}" Тогда
// TODO: Реализация {{Идентификатор}}
КонецЕсли;
КонецПроцедуры
```
Для назначаемых обработок (с `ОбъектыНазначения`):
```bsl
Процедура ВыполнитьКоманду(ИдентификаторКоманды, ОбъектыНазначения, ПараметрыВыполненияКоманды) Экспорт
Если ИдентификаторКоманды = "{{Идентификатор}}" Тогда
// TODO: Реализация {{Идентификатор}}
КонецЕсли;
КонецПроцедуры
```
### ПечатнаяФорма — если процедура Печать уже есть
Добавь блок перед `КонецПроцедуры`:
```bsl
ПечатнаяФорма = УправлениеПечатью.СведенияОПечатнойФорме(КоллекцияПечатныхФорм, "{{Идентификатор}}");
Если ПечатнаяФорма <> Неопределено Тогда
ПечатнаяФорма.ТабличныйДокумент = Сформировать{{Идентификатор}}(МассивОбъектов, ОбъектыПечати);
ПечатнаяФорма.СинонимМакета = НСтр("ru = '{{Представление}}'");
КонецЕсли;
```
### ПечатнаяФорма — если процедуры Печать нет
```bsl
Процедура Печать(МассивОбъектов, КоллекцияПечатныхФорм, ОбъектыПечати, ПараметрыВывода) Экспорт
ПечатнаяФорма = УправлениеПечатью.СведенияОПечатнойФорме(КоллекцияПечатныхФорм, "{{Идентификатор}}");
Если ПечатнаяФорма <> Неопределено Тогда
ПечатнаяФорма.ТабличныйДокумент = Сформировать{{Идентификатор}}(МассивОбъектов, ОбъектыПечати);
ПечатнаяФорма.СинонимМакета = НСтр("ru = '{{Представление}}'");
КонецЕсли;
КонецПроцедуры
```
### ВызовКлиентскогоМетода
Добавляется в **модуль формы** (`Forms/<FormName>/Ext/Form/Module.bsl`):
Для глобальных обработок:
```bsl
&НаКлиенте
Процедура ВыполнитьКоманду(ИдентификаторКоманды) Экспорт
Если ИдентификаторКоманды = "{{Идентификатор}}" Тогда
// TODO: Реализация {{Идентификатор}}
КонецЕсли;
КонецПроцедуры
```
Для назначаемых обработок:
```bsl
&НаКлиенте
Процедура ВыполнитьКоманду(ИдентификаторКоманды, ОбъектыНазначенияМассив) Экспорт
Если ИдентификаторКоманды = "{{Идентификатор}}" Тогда
// TODO: Реализация {{Идентификатор}}
КонецЕсли;
КонецПроцедуры
```
Если процедура уже есть — добавь ветку `ИначеЕсли`.
## Инструкции
1. Найди и прочитай `ObjectModule.bsl` через Glob: `src/{{ProcessorName}}/Ext/ObjectModule.bsl`
2. Убедись что `СведенияОВнешнейОбработке()` существует. Если нет — предложи вызвать `/epf-bsp-init`
3. Определи вид обработки из существующего кода (найди строку с `ВидОбработки...()`)
4. Вставь блок команды **перед** `Возврат ПараметрыРегистрации`
5. Добавь обработчик:
- Для серверных обработчиков — в `ObjectModule.bsl`, область `ПрограммныйИнтерфейс`
- Для клиентских обработчиков — в модуль формы (найти через Glob: `src/{{ProcessorName}}/Forms/*/Ext/Form/Module.bsl`)
6. Если обработчик (`ВыполнитьКоманду` / `Печать`) уже есть — добавь ветку, не создавай дубль процедуры
7. Используй табы для отступов
## Пример
Пользователь: `/epf-bsp-add-command МояОбработка ЗаказПокупателя серверный "Заказ покупателя"`
В `СведенияОВнешнейОбработке()` перед `Возврат` добавится:
```bsl
НоваяКоманда = ПараметрыРегистрации.Команды.Добавить();
НоваяКоманда.Представление = НСтр("ru = 'Заказ покупателя'");
НоваяКоманда.Идентификатор = "ЗаказПокупателя";
НоваяКоманда.Использование = ДополнительныеОтчетыИОбработкиКлиентСервер.ТипКомандыВызовСерверногоМетода();
НоваяКоманда.ПоказыватьОповещение = Ложь;
```
И в существующую процедуру `ВыполнитьКоманду` добавится блок обработки.
+208
View File
@@ -0,0 +1,208 @@
---
name: epf-bsp-init
description: Сформировать функцию `СведенияОВнешнейОбработке` в модуле объекта обработки — описание для подключения через подсистему БСП «Дополнительные отчёты и обработки». Используй когда нужно сделать обработку совместимой с БСП, подключаемой через «Дополнительные отчёты и обработки»
argument-hint: <ProcessorName> <Вид>
allowed-tools:
- Read
- Edit
- Glob
- Grep
---
# /epf-bsp-init — Регистрация обработки в БСП
Добавляет в модуль объекта обработки функцию `СведенияОВнешнейОбработке()`, необходимую для регистрации в подсистеме «Дополнительные отчёты и обработки» БСП.
## Usage
```
/epf-bsp-init <ProcessorName> <Вид> [Назначение...]
```
| Параметр | Обязательный | По умолчанию | Описание |
|---------------|:------------:|--------------|---------------------------------------------------------|
| ProcessorName | да | — | Имя обработки (должна быть создана через `/epf-init`) |
| Вид | да | — | Вид обработки (см. маппинг ниже) |
| Назначение | * | — | Объекты метаданных для назначаемых видов |
| SrcDir | нет | `src` | Каталог исходников |
\* Назначение обязательно для видов: ЗаполнениеОбъекта, Отчет, ПечатнаяФорма, СозданиеСвязанныхОбъектов.
## Маппинг вида обработки
Пользователь может указать вид в свободной форме. Определи нужный по контексту:
| Пользователь пишет | Вид | API-метод |
|-------------------------------------------|----------------------------|----------------------------------------------|
| доп обработка, обработка, глобальная | ДополнительнаяОбработка | `ВидОбработкиДополнительнаяОбработка()` |
| доп отчёт, глобальный отчёт | ДополнительныйОтчет | `ВидОбработкиДополнительныйОтчет()` |
| заполнение, заполнить | ЗаполнениеОбъекта | `ВидОбработкиЗаполнениеОбъекта()` |
| отчёт (назначаемый, для объекта) | Отчет | `ВидОбработкиОтчет()` |
| печатная форма, печать | ПечатнаяФорма | `ВидОбработкиПечатнаяФорма()` |
| создание связанных объектов | СозданиеСвязанныхОбъектов | `ВидОбработкиСозданиеСвязанныхОбъектов()` |
## Тип команды по умолчанию
| Вид | ТипКоманды по умолчанию |
|----------------------------|-------------------------------------------|
| ДополнительнаяОбработка | `ТипКомандыОткрытиеФормы()` |
| ДополнительныйОтчет | `ТипКомандыОткрытиеФормы()` |
| ЗаполнениеОбъекта | `ТипКомандыВызовСерверногоМетода()` |
| Отчет | `ТипКомандыОткрытиеФормы()` |
| ПечатнаяФорма | `ТипКомандыВызовСерверногоМетода()` |
| СозданиеСвязанныхОбъектов | `ТипКомандыВызовСерверногоМетода()` |
## Шаблон: СведенияОВнешнейОбработке
Базовый шаблон — одинаковый для всех видов, отличаются только вызовы API-методов и условные секции.
```bsl
Функция СведенияОВнешнейОбработке() Экспорт
МетаданныеОбработки = Метаданные();
ПараметрыРегистрации = ДополнительныеОтчетыИОбработки.СведенияОВнешнейОбработке("2.2.2.1");
ПараметрыРегистрации.Вид = ДополнительныеОтчетыИОбработкиКлиентСервер.{{ВидОбработки}};
ПараметрыРегистрации.Версия = "1.0";
{{СЕКЦИЯ_НАЗНАЧЕНИЕ}}
НоваяКоманда = ПараметрыРегистрации.Команды.Добавить();
НоваяКоманда.Представление = МетаданныеОбработки.Представление();
НоваяКоманда.Идентификатор = МетаданныеОбработки.Имя;
НоваяКоманда.Использование = ДополнительныеОтчетыИОбработкиКлиентСервер.{{ТипКоманды}};
НоваяКоманда.ПоказыватьОповещение = Ложь;
{{СЕКЦИЯ_МОДИФИКАТОР}}
Возврат ПараметрыРегистрации;
КонецФункции
```
### Подстановки
- `{{ВидОбработки}}` — API-метод из таблицы маппинга вида
- `{{ТипКоманды}}` — API-метод из таблицы типа команды по умолчанию
### Условные секции
**`{{СЕКЦИЯ_НАЗНАЧЕНИЕ}}`** — только для назначаемых видов (ЗаполнениеОбъекта, Отчет, ПечатнаяФорма, СозданиеСвязанныхОбъектов). Одна строка на каждый объект:
```bsl
ПараметрыРегистрации.Назначение.Добавить("Документ.СчетНаОплату");
```
Формат имени объекта: `ИмяКлассаОбъектаМетаданного.ИмяОбъекта` (например `Документ.СчетНаОплату`, `Справочник.Контрагенты`).
Для глобальных видов (ДополнительнаяОбработка, ДополнительныйОтчет) — секция не нужна, удалить вместе с пустой строкой.
**`{{СЕКЦИЯ_МОДИФИКАТОР}}`** — только для ПечатнаяФорма:
```bsl
НоваяКоманда.Модификатор = "ПечатьMXL";
```
Для остальных видов — удалить вместе с пустой строкой.
## Шаблоны серверных обработчиков
Для видов с типом команды `ВызовСерверногоМетода` добавь соответствующую процедуру-обработчик в ту же область `ПрограммныйИнтерфейс`, после `СведенияОВнешнейОбработке`.
### Для ЗаполнениеОбъекта / СозданиеСвязанныхОбъектов
```bsl
Процедура ВыполнитьКоманду(ИдентификаторКоманды, ОбъектыНазначения, ПараметрыВыполненияКоманды) Экспорт
// TODO: Реализация
КонецПроцедуры
```
### Для ПечатнаяФорма
```bsl
Процедура Печать(МассивОбъектов, КоллекцияПечатныхФорм, ОбъектыПечати, ПараметрыВывода) Экспорт
// TODO: Реализация
КонецПроцедуры
```
### Для ДополнительнаяОбработка / ДополнительныйОтчет (с ВызовСерверногоМетода)
Если пользователь явно выбрал серверный метод вместо открытия формы:
```bsl
Процедура ВыполнитьКоманду(ИдентификаторКоманды, ПараметрыВыполненияКоманды) Экспорт
// TODO: Реализация
КонецПроцедуры
```
Обрати внимание: у глобальных обработок нет параметра `ОбъектыНазначения`.
## Инструкции
1. Найди `ObjectModule.bsl` через Glob: `src/{{ProcessorName}}/Ext/ObjectModule.bsl`
2. Прочитай файл
3. Если `СведенияОВнешнейОбработке` уже есть — сообщи пользователю и не дублируй
4. Если файл не найден — предложи сначала вызвать `/epf-init`
5. Найди область `#Область ПрограммныйИнтерфейс` ... `#КонецОбласти`
6. Вставь функцию `СведенияОВнешнейОбработке()` внутрь этой области
7. Если вид требует серверный обработчик — вставь его тоже в эту область, после функции
8. Используй табы для отступов (как в исходном файле)
## Пример
Пользователь: `/epf-bsp-init МояОбработка печатная форма для Документ.СчетНаОплату`
Результат в `ObjectModule.bsl`:
```bsl
#Область ОписаниеПеременных
#КонецОбласти
#Область ПрограммныйИнтерфейс
Функция СведенияОВнешнейОбработке() Экспорт
МетаданныеОбработки = Метаданные();
ПараметрыРегистрации = ДополнительныеОтчетыИОбработки.СведенияОВнешнейОбработке("2.2.2.1");
ПараметрыРегистрации.Вид = ДополнительныеОтчетыИОбработкиКлиентСервер.ВидОбработкиПечатнаяФорма();
ПараметрыРегистрации.Версия = "1.0";
ПараметрыРегистрации.Назначение.Добавить("Документ.СчетНаОплату");
НоваяКоманда = ПараметрыРегистрации.Команды.Добавить();
НоваяКоманда.Представление = МетаданныеОбработки.Представление();
НоваяКоманда.Идентификатор = МетаданныеОбработки.Имя;
НоваяКоманда.Использование = ДополнительныеОтчетыИОбработкиКлиентСервер.ТипКомандыВызовСерверногоМетода();
НоваяКоманда.ПоказыватьОповещение = Ложь;
НоваяКоманда.Модификатор = "ПечатьMXL";
Возврат ПараметрыРегистрации;
КонецФункции
Процедура Печать(МассивОбъектов, КоллекцияПечатныхФорм, ОбъектыПечати, ПараметрыВывода) Экспорт
// TODO: Реализация
КонецПроцедуры
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти
```
## Дальнейшие шаги
- Добавить ещё команду: `/epf-bsp-add-command`
- Добавить форму: `/form-add`
- Добавить макет: `/template-add`
- Собрать EPF: `/epf-build`
+71
View File
@@ -0,0 +1,71 @@
---
name: epf-build
description: Собрать внешнюю обработку 1С (EPF/ERF) из XML-исходников. Используй когда пользователь просит собрать, скомпилировать обработку или получить EPF/ERF файл из исходников
argument-hint: <ProcessorName>
allowed-tools:
- Bash
- Read
- Glob
- Grep
---
# /epf-build — Сборка обработки
## Usage
```
/epf-build <ProcessorName> [SrcDir] [OutDir]
```
| Параметр | Обязательный | По умолчанию | Описание |
|---------------|:------------:|--------------|--------------------------------------|
| ProcessorName | да | — | Имя обработки (имя корневого XML) |
| SrcDir | нет | `src` | Каталог исходников |
| OutDir | нет | `build` | Каталог для результата |
## Параметры подключения (опционально)
Предпочтительно использовать конкретную базу — это надёжнее и не требует создания временной базы.
1. Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` и разреши базу:
2. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
3. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
4. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
5. Если ветка не совпала — используй `default`
6. Если `.v8-project.json` нет или база не найдена — не указывай параметры подключения: скрипт автоматически создаст временную базу. Для EPF со ссылочными типами (CatalogRef, DocumentRef и т.д.) генерируются заглушки метаданных. Временная база удаляется после сборки.
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-build/scripts/epf-build.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
| `-OutputFile <путь>` | да | Путь к выходному EPF/ERF-файлу |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
## Примеры
```powershell
# Сборка обработки (файловая база)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-build/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf"
# Серверная база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-build/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf"
```
@@ -0,0 +1,510 @@
# epf-build v1.16 — Build external data processor or report (EPF/ERF) from XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Сборка внешней обработки/отчёта 1С из XML-исходников
.DESCRIPTION
Собирает EPF/ERF-файл из XML-исходников с помощью платформы 1С.
Общий скрипт для epf-build и erf-build.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER SourceFile
Путь к корневому XML-файлу исходников
.PARAMETER OutputFile
Путь к выходному EPF/ERF-файлу
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МояОбработка.xml" -OutputFile "build\МояОбработка.epf"
.EXAMPLE
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$true)]
[string]$SourceFile,
[Parameter(Mandatory=$true)]
[string]$OutputFile,
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$SourceFile = ConvertTo-CleanPath $SourceFile '-SourceFile'
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
function Assert-InfoBaseExists {
# These skills work on a ready infobase. Saying so up front beats the platform's
# "Неверные или отсутствующие параметры соединения" after a launch.
param([string]$Path)
if (-not $Path) { return }
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
exit 1
}
}
Assert-InfoBaseExists $InfoBasePath
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
function Test-OutputNonEmpty {
# Postcondition: the platform must have produced a non-empty output file.
# Exit code 0 without it (broken/headless env) is a false success — reject it.
param([string]$Path)
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
if ($engine -eq "ibcmd" -and $InfoBaseServer -and $InfoBaseRef) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)" -ForegroundColor Red
exit 1
}
# --- Auto-create stub database if no connection specified ---
$autoCreatedBase = $null
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
$sourceDir = Split-Path $SourceFile -Parent
$autoBasePath = Join-Path $env:TEMP "epf_stub_db_$(Get-Random)"
$stubScript = Join-Path $PSScriptRoot "stub-db-create.ps1"
Write-Host "No database specified. Creating temporary stub database..."
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
# explicit ones are forwarded: the stub reads .v8-project.json itself.
# Invoked via -Command, not -File: -File takes the tail literally, so an array
# parameter would arrive as a single comma-glued token.
$q = { param($s) "'" + ($s -replace "'", "''") + "'" }
$stubCmd = "& $(& $q $stubScript) -SourceDir $(& $q $sourceDir) -V8Path $(& $q $V8Path) -TempBasePath $(& $q $autoBasePath)"
if ($AdditionalV8Arguments.Count -gt 0) {
$stubCmd += " -AdditionalV8Arguments " + (($AdditionalV8Arguments | ForEach-Object { & $q $_ }) -join ',')
}
if ($AdditionalIbcmdArguments.Count -gt 0) {
$stubCmd += " -AdditionalIbcmdArguments " + (($AdditionalIbcmdArguments | ForEach-Object { & $q $_ }) -join ',')
}
$stubProc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -Command `"$stubCmd`"" -NoNewWindow -Wait -PassThru
if ($stubProc.ExitCode -ne 0) {
Write-Host "Error: failed to create stub database" -ForegroundColor Red
exit 1
}
$InfoBasePath = $autoBasePath
$autoCreatedBase = $autoBasePath
}
# --- Validate source file ---
if (-not (Test-Path $SourceFile)) {
Write-Host "Error: source file not found: $SourceFile" -ForegroundColor Red
exit 1
}
# --- Ensure output directory exists ---
$outDir = Split-Path $OutputFile -Parent
if ($outDir -and -not (Test-Path $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "epf_build_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch: build EPF/ERF via config import --out ---
$srcDir = Split-Path $SourceFile -Parent
$arguments = @("infobase", "config", "import", "$srcDir", "--out=$OutputFile", "--db-path=$InfoBasePath")
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "External data processor/report built successfully: $OutputFile" -ForegroundColor Green
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
} else {
Write-Host "Error building external data processor/report (code: $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $output
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
$arguments += "/LoadExternalDataProcessorOrReportFromFiles", "`"$SourceFile`"", "`"$OutputFile`""
# --- Output ---
$outFile = Join-Path $tempDir "build_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
# Postcondition: exit 0 without a non-empty output file is a false success.
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "Build completed successfully: $OutputFile" -ForegroundColor Green
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
} else {
Write-Host "Error building (code: $exitCode)" -ForegroundColor Red
}
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
if ($autoCreatedBase -and (Test-Path $autoCreatedBase)) {
Remove-Item -Path $autoCreatedBase -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,566 @@
#!/usr/bin/env python3
# epf-build v1.16 — Build external data processor or report (EPF/ERF) from XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def output_nonempty(path):
"""Postcondition: the platform must have produced a non-empty output file.
Exit code 0 without it (broken/headless env) is a false success reject it."""
return os.path.isfile(path) and os.path.getsize(path) > 0
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Build external data processor or report (EPF/ERF) from XML sources",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password")
parser.add_argument("-SourceFile", required=True, help="Path to root XML source file")
parser.add_argument("-OutputFile", required=True, help="Path to output EPF/ERF file")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
args.SourceFile = clean_path(args.SourceFile, "-SourceFile")
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
# --- Resolve V8Path ---
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef:
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)")
sys.exit(1)
# --- Auto-create stub database if no connection specified ---
auto_created_base = None
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
source_dir = os.path.dirname(os.path.abspath(args.SourceFile))
auto_base_path = os.path.join(tempfile.gettempdir(), f"epf_stub_db_{random.randint(0, 999999)}")
stub_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stub-db-create.py")
print("No database specified. Creating temporary stub database...")
stub_cmd = [sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path,
"-TempBasePath", auto_base_path]
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
# explicit ones are forwarded: the stub reads .v8-project.json itself.
if v8_extra:
stub_cmd += ["-AdditionalV8Arguments"] + list(v8_extra)
if ibcmd_extra:
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
result = subprocess.run(stub_cmd, capture_output=False)
if result.returncode != 0:
print("Error: failed to create stub database")
sys.exit(1)
args.InfoBasePath = auto_base_path
auto_created_base = auto_base_path
# --- Validate source file ---
if not os.path.isfile(args.SourceFile):
print(f"Error: source file not found: {args.SourceFile}")
sys.exit(1)
# --- Ensure output directory exists ---
out_dir = os.path.dirname(args.OutputFile)
if out_dir and not os.path.exists(out_dir):
os.makedirs(out_dir, exist_ok=True)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"epf_build_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
if engine == "ibcmd":
# --- ibcmd branch: build EPF/ERF via config import --out ---
src_dir = os.path.dirname(os.path.abspath(args.SourceFile))
arguments = ["infobase", "config", "import", src_dir, f"--out={args.OutputFile}", f"--db-path={args.InfoBasePath}"]
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
exit_code = result.returncode
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
if out_missing:
exit_code = 1
if exit_code == 0:
print(f"External data processor/report built successfully: {args.OutputFile}")
elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
else:
print(f"Error building external data processor/report (code: {exit_code})")
sys.exit(exit_code)
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
else:
arguments += ["/F", f'"{args.InfoBasePath}"']
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
arguments += ["/LoadExternalDataProcessorOrReportFromFiles", f'"{args.SourceFile}"', f'"{args.OutputFile}"']
# --- Output ---
out_file = os.path.join(temp_dir, "build_log.txt")
arguments += ["/Out", f'"{out_file}"']
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
# Postcondition: exit 0 without a non-empty output file is a false success.
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
if out_missing:
exit_code = 1
if exit_code == 0:
print(f"Build completed successfully: {args.OutputFile}")
elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
else:
print(f"Error building (code: {exit_code})")
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
sys.exit(exit_code)
finally:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if auto_created_base and os.path.exists(auto_created_base):
shutil.rmtree(auto_created_base, ignore_errors=True)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
---
name: epf-dump
description: Разобрать EPF-файл обработки 1С (EPF/ERF) в XML-исходники. Используй когда пользователь просит разобрать, декомпилировать обработку, получить исходники из EPF/ERF файла
argument-hint: <EpfFile>
allowed-tools:
- Bash
- Read
- Glob
- Grep
---
# /epf-dump — Разборка обработки
## Usage
```
/epf-dump <EpfFile> [OutDir]
```
| Параметр | Обязательный | По умолчанию | Описание |
|----------|:------------:|--------------|-------------------------------------|
| EpfFile | да | — | Путь к EPF-файлу |
| OutDir | нет | `src` | Каталог для выгрузки исходников |
## Параметры подключения (обязательно)
Для разборки EPF/ERF требуется информационная база с конфигурацией. Без базы ссылочные типы безвозвратно теряются.
1. Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` и разреши базу:
2. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
3. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
4. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
5. Если ветка не совпала — используй `default`
6. Если `.v8-project.json` нет или база не найдена — **сообщи пользователю об ошибке**. Для dump база обязательна: в пустой базе ссылочные типы (CatalogRef, DocumentRef и т.д.) безвозвратно сбрасываются в строки. Предложи указать базу или зарегистрировать через `/db-list add`.
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-dump/scripts/epf-dump.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-InputFile <путь>` | да | Путь к EPF/ERF-файлу |
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
## Примеры
```powershell
# Разборка обработки (файловая база)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-dump/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МояОбработка.epf" -OutputDir "src"
# Серверная база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-dump/scripts/epf-dump.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МояОбработка.epf" -OutputDir "src"
```
+497
View File
@@ -0,0 +1,497 @@
# epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Разборка внешней обработки/отчёта 1С в XML-исходники
.DESCRIPTION
Разбирает EPF/ERF-файл во XML-исходники с помощью платформы 1С.
Общий скрипт для epf-dump и erf-dump.
.PARAMETER V8Path
Путь к каталогу bin платформы или к 1cv8.exe
.PARAMETER InfoBasePath
Путь к файловой информационной базе
.PARAMETER InfoBaseServer
Сервер 1С (для серверной базы)
.PARAMETER InfoBaseRef
Имя базы на сервере
.PARAMETER UserName
Имя пользователя 1С
.PARAMETER Password
Пароль пользователя
.PARAMETER InputFile
Путь к EPF/ERF-файлу
.PARAMETER OutputDir
Каталог для выгрузки исходников
.PARAMETER Format
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
.PARAMETER AdditionalV8Arguments
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
.PARAMETER AdditionalIbcmdArguments
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
.EXAMPLE
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МояОбработка.epf" -OutputDir "src"
.EXAMPLE
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory=$false)]
[string]$V8Path,
[Parameter(Mandatory=$false)]
[string]$InfoBasePath,
[Parameter(Mandatory=$false)]
[string]$InfoBaseServer,
[Parameter(Mandatory=$false)]
[string]$InfoBaseRef,
[Parameter(Mandatory=$false)]
[string]$UserName,
[Parameter(Mandatory=$false)]
[string]$Password,
[Parameter(Mandatory=$true)]
[string]$InputFile,
[Parameter(Mandatory=$true)]
[string]$OutputDir,
[Parameter(Mandatory=$false)]
[ValidateSet("Hierarchical", "Plain")]
[string]$Format = "Hierarchical",
[Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(),
[Parameter(Mandatory=$false)]
[string[]]$AdditionalIbcmdArguments = @()
)
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Additional platform arguments ---
$script:V8OwnedKeys = @(
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
)
$script:IbcmdOwnedKeys = @(
'--db-path', '--data', '--out', '--file', '--load', '--restore',
'--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password'
)
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch {
# A token matches a key when it equals the key, or starts with it and the next
# character is not a letter — catches glued /N"user" and --password=x, while
# keeping /ClearCache distinct from /C.
param([string]$Token, [string]$Key)
if ($Token.Length -lt $Key.Length) { return $false }
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
if ($Token.Length -eq $Key.Length) { return $true }
return -not [char]::IsLetter($Token[$Key.Length])
}
function Get-ProjectExtraArgs {
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
param([string]$Name)
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
} catch {}
return @()
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return @()
}
function Assert-ExtraArgs {
# The platform accepts only one batch operation, and a duplicate connection or
# output key fails with an opaque 1C error — reject what the skill owns itself.
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
foreach ($tok in $ExtraArgs) {
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
exit 1
}
foreach ($k in $owned) {
if (Test-ArgKeyMatch $tok $k) {
$hint = ''
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
exit 1
}
}
}
}
function Resolve-ExtraArgs {
# Pick the argument list for the selected engine and validate it. An explicitly passed
# parameter for the other engine is an error; the same keys coming from .v8-project.json
# simply do not apply — a project may describe both engines.
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
# space-separated values spill into positional ones, a comma-joined list arrives as a
# single token. So accept the repo's list convention (comma-separated) and split here;
# a native array call keeps working. A value containing a comma is not supported.
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
exit 1
}
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
exit 1
}
if ($Engine -eq 'ibcmd') {
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
} else {
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
}
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
# would nest the array — the tokens would then be glued into one argument.
return $extra
}
function Format-ArgsForDisplay {
# Redact values of secret-prone keys in glued, =-joined and separate forms.
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
# a leaked password does.
param([string[]]$ArgList, [string]$Engine)
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
$res = @()
$maskNext = $false
foreach ($tok in $ArgList) {
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
$hit = $null
foreach ($k in $keys) {
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
}
if (-not $hit) { $res += $tok; continue }
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
else { $res += ($hit + '***') }
}
return ,$res
}
function ConvertTo-CleanPath {
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
# inside afterwards cannot be part of a real path — reject it by name instead of letting
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
param([string]$Value, [string]$ParamName)
if (-not $Value) { return $Value }
$v = $Value.Trim()
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
$v = $v.Substring(1, $v.Length - 2).Trim()
}
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
if ($v.Contains('"')) {
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
exit 1
}
return $v
}
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
$OutputDir = ConvertTo-CleanPath $OutputDir '-OutputDir'
function Assert-InfoBaseExists {
# These skills work on a ready infobase. Saying so up front beats the platform's
# "Неверные или отсутствующие параметры соединения" after a launch.
param([string]$Path)
if (-not $Path) { return }
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
exit 1
}
}
Assert-InfoBaseExists $InfoBasePath
# --- Resolve V8Path ---
function Find-ProjectV8Path {
$dir = (Get-Location).Path
while ($dir) {
$pf = Join-Path $dir ".v8-project.json"
if (Test-Path $pf) {
try {
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
if ($j.v8path) { return [string]$j.v8path }
} catch {}
return $null
}
$parent = Split-Path $dir -Parent
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
return $null
}
if (-not $V8Path) {
$V8Path = Find-ProjectV8Path
}
if (-not $V8Path) {
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
Select-Object -First 1
if ($found) {
$V8Path = $found.FullName
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
} else {
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
exit 1
}
}
if (Test-Path $V8Path -PathType Container) {
$V8Path = Join-Path $V8Path "1cv8.exe"
}
if (-not (Test-Path $V8Path)) {
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
exit 1
}
# --- Validate database connection ---
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef" -ForegroundColor Red
Write-Host "Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly." -ForegroundColor Yellow
exit 1
}
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
function ConvertFrom-PlatformBytes {
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
# one of them outright mangles Cyrillic.
param([byte[]]$Bytes)
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
try {
$strict = New-Object System.Text.UTF8Encoding($false, $true)
return $strict.GetString($Bytes)
} catch {
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
}
}
function Invoke-PlatformProcess {
# Run the platform non-interactively and capture its console output. A closed stdin pipe
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
# text out of our stream until we print it labelled (and out of the wrong encoding).
# Returns @{ Output; ExitCode }.
#
# Quoting differs by engine, so the caller says which it built:
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
# which is where 1C's own parser expects them; quoting again breaks the value.
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = if ($PreQuoted) {
$ProcArgs -join ' '
} else {
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
}
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.Close()
# stderr is drained in parallel: reading the streams one after another deadlocks
# as soon as the other one fills its pipe buffer.
$errMs = New-Object System.IO.MemoryStream
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
$outMs = New-Object System.IO.MemoryStream
$p.StandardOutput.BaseStream.CopyTo($outMs)
$errTask.Wait()
$p.WaitForExit()
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
if ($err) { $out += $err }
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
}
function Write-PlatformOutput {
# Print what the platform wrote to the console as its own labelled block. Silence stays
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
param([string]$Text)
if (-not $Text) { return }
$t = $Text.TrimEnd()
if (-not $t) { return }
$limit = 65536
if ($t.Length -gt $limit) {
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
}
Write-Host "--- Вывод платформы ---"
Write-Host $t
Write-Host "--- End ---"
}
function Test-DirNonEmpty {
# Postcondition: the platform must have written files into the output directory.
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
param([string]$Path)
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
}
function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets)
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
return $Text
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
# --- Resolve additional arguments for the selected engine ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
if ($engine -eq "ibcmd") {
if (-not $InfoBasePath) {
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
exit 1
}
if ($Format -eq "Plain") {
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
exit 1
}
}
# --- Validate input file ---
if (-not (Test-Path $InputFile)) {
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
exit 1
}
# --- Ensure output directory exists ---
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}
# --- Temp dir ---
$tempDir = Join-Path $env:TEMP "epf_dump_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
if ($engine -eq "ibcmd") {
# --- ibcmd branch: dump EPF/ERF via config export --file ---
$arguments = @("infobase", "config", "export", "--file=$InputFile", "$OutputDir", "--db-path=$InfoBasePath")
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += "--data=$tempDir"
$arguments += $extraArgs
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__ib = Invoke-PlatformProcess $V8Path $arguments
$output = $__ib.Output
$exitCode = $__ib.ExitCode
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "External data processor/report dumped successfully to: $OutputDir" -ForegroundColor Green
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
} else {
Write-Host "Error dumping external data processor/report (code: $exitCode)" -ForegroundColor Red
}
Write-PlatformOutput $output
exit $exitCode
}
# --- 1cv8 branch ---
# --- Build arguments ---
$arguments = @("DESIGNER")
if ($InfoBaseServer -and $InfoBaseRef) {
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
} else {
$arguments += "/F", "`"$InfoBasePath`""
}
if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" }
$arguments += "/DumpExternalDataProcessorOrReportToFiles", "`"$OutputDir`"", "`"$InputFile`""
$arguments += "-Format", $Format
# --- Output ---
$outFile = Join-Path $tempDir "dump_log.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $extraArgs
# --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode
# --- Result ---
# Postcondition: exit 0 with an empty output directory is a false success.
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
if ($outMissing) { $exitCode = 1 }
if ($exitCode -eq 0) {
Write-Host "Dump completed successfully to: $OutputDir" -ForegroundColor Green
} elseif ($outMissing) {
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
} else {
Write-Host "Error dumping (code: $exitCode)" -ForegroundColor Red
}
if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) {
Write-Host "--- Log ---"
Write-Host $logContent
Write-Host "--- End ---"
}
}
Write-PlatformOutput $__v8.Output
exit $exitCode
} finally {
if (Test-Path $tempDir) {
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
+556
View File
@@ -0,0 +1,556 @@
#!/usr/bin/env python3
# epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get("v8path")
if v:
return v
except Exception:
pass
return None
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
# --- Additional platform arguments ---
V8_OWNED_KEYS = [
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
IBCMD_OWNED_KEYS = [
"--db-path", "--data", "--out", "--file", "--load", "--restore",
"--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping
/ClearCache distinct from /C."""
if len(token) < len(key):
return False
if token[: len(key)].lower() != key.lower():
return False
if len(token) == len(key):
return True
return not token[len(key)].isalpha()
def project_extra_args(name):
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
d = os.getcwd()
while True:
pf = os.path.join(d, ".v8-project.json")
if os.path.isfile(pf):
try:
with open(pf, encoding="utf-8-sig") as f:
data = json.load(f)
v = data.get(name)
if v:
return [str(x) for x in v]
except Exception:
pass
return []
parent = os.path.dirname(d)
if parent == d:
return []
d = parent
def assert_extra_args(extra, engine, hints):
"""The platform accepts only one batch operation, and a duplicate connection or
output key fails with an opaque 1C error reject what the skill owns itself."""
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
for tok in extra:
if engine == "ibcmd" and not tok.startswith("-"):
print(
f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)",
)
sys.exit(1)
for k in owned:
if arg_key_match(tok, k):
hint = f" (use {hints[k]})" if hints and k in hints else ""
print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
)
sys.exit(1)
def format_args_for_display(arglist, engine):
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
a leaked password does."""
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
res = []
mask_next = False
for tok in arglist:
if mask_next:
res.append("***")
mask_next = False
continue
hit = None
for k in keys:
if tok[: len(k)].lower() == k.lower():
hit = k
break
if hit is None:
res.append(tok)
elif len(tok) == len(hit):
res.append(tok)
mask_next = True
elif tok[len(hit)] == "=":
res.append(hit + "=***")
else:
res.append(hit + "***")
return res
def extract_extra_args(argv, known_opts):
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
rest, v8, ibcmd = [], [], []
i = 0
while i < len(argv):
low = argv[i].lower()
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
target = v8 if low == "-additionalv8arguments" else ibcmd
i += 1
while i < len(argv) and argv[i].lower() not in known_opts:
target.append(argv[i])
i += 1
continue
rest.append(argv[i])
i += 1
return rest, v8, ibcmd
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
"""Pick the argument list for the selected engine and validate it. An explicitly
passed parameter for the other engine is an error; the same keys coming from
.v8-project.json simply do not apply a project may describe both engines.
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
so that form is the documented one and both ports must accept it. A value containing
a comma is not supported."""
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
if engine == "ibcmd" and v8_extra:
print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)",
)
sys.exit(1)
if engine != "ibcmd" and ibcmd_extra:
print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)",
)
sys.exit(1)
if engine == "ibcmd":
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
else:
extra = project_extra_args("v8args") + list(v8_extra)
if extra:
assert_extra_args(extra, engine, hints)
return extra
def _version_dir(p):
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
parent = os.path.dirname(p)
if os.path.basename(parent).lower() == "bin":
parent = os.path.dirname(parent)
return os.path.basename(parent)
def _version_key(p):
"""Numeric sort key from version dir name."""
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
def resolve_v8path(v8path):
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
if not v8path:
v8path = _find_project_v8path()
if not v8path:
if os.name == "nt":
candidates = (
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
)
else:
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
candidates = glob.glob("/opt/1cv8/*/1cv8")
if candidates:
v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else:
print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1)
if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}")
sys.exit(1)
return v8path
IBCMD_NOUSER_HINT = (
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
"call may block instead of failing. If it does not return promptly, abort and "
"re-run with -UserName and -Password.\n"
)
def decode_platform_bytes(data):
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes the locale
code page (what text=True uses) mangles both."""
if not data:
return ""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp866", errors="replace")
def assert_infobase_exists(path):
"""These skills work on a ready infobase. Saying so up front beats the platform's
"Неверные или отсутствующие параметры соединения" after a launch."""
if not path:
return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1)
def clean_path(value, param=""):
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
surrounding quotes that survived shell parsing, a trailing separator. A quote left
inside afterwards cannot be part of a real path reject it by name instead of letting
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
if not value:
return value
v = value.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
v = v[1:-1].strip()
if len(v) > 3 and v[-1] in "\\/":
v = v[:-1]
if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1)
return v
def quote_if_needed(token):
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
verbatim, so a token with a space needs quotes of its own."""
if token and (" " in token or "\t" in token) and '"' not in token:
return f'"{token}"'
return token
def run_v8(v8path, arguments):
"""Run 1cv8 in batch mode and capture its console output.
The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
"""
if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments)
else:
def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def print_platform_output(result):
"""Print what the platform wrote to the console as its own labelled block. Silence stays
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
if not text:
return
limit = 65536
if len(text) > limit:
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
print("--- Вывод платформы ---")
print(text)
print("--- End ---")
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively.
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
On Windows without -UserName ibcmd reads the console directly and may still block
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
"""
if warn_no_user and os.name == "nt" and not has_username:
sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr)
return r
def dir_nonempty(path):
"""Postcondition: the platform must have written files into the output directory.
Exit code 0 with an empty dir (broken/headless env) is a false success reject it."""
return os.path.isdir(path) and any(os.scandir(path))
def _redact(text, *secrets):
"""Redact literal secret values (password, user) from a display string —
precise, never touches lookalike paths."""
for s in secrets:
if s:
text = text.replace(s, "***")
return text
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Dump external data processor or report (EPF/ERF) to XML sources",
allow_abbrev=False,
)
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password")
parser.add_argument("-InputFile", required=True, help="Path to EPF/ERF file")
parser.add_argument("-OutputDir", required=True, help="Directory for dumped XML sources")
parser.add_argument(
"-Format",
default="Hierarchical",
choices=["Hierarchical", "Plain"],
help="Dump format (default: Hierarchical)",
)
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath)
args.InputFile = clean_path(args.InputFile, "-InputFile")
args.OutputDir = clean_path(args.OutputDir, "-OutputDir")
# --- Resolve V8Path ---
v8path = resolve_v8path(args.V8Path)
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
# --- Resolve additional arguments for the selected engine ---
arg_hints = {
"/F": "-InfoBasePath",
"/S": "-InfoBaseServer + -InfoBaseRef",
"/N": "-UserName",
"/P": "-Password",
"--db-path": "-InfoBasePath",
"--user": "-UserName",
"--password": "-Password",
}
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
# --- Validate database connection ---
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef")
print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
sys.exit(1)
if engine == "ibcmd":
if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1)
if args.Format == "Plain":
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1)
# --- Validate input file ---
if not os.path.isfile(args.InputFile):
print(f"Error: input file not found: {args.InputFile}")
sys.exit(1)
# --- Ensure output directory exists ---
if not os.path.exists(args.OutputDir):
os.makedirs(args.OutputDir, exist_ok=True)
# --- Temp dir ---
temp_dir = os.path.join(tempfile.gettempdir(), f"epf_dump_{random.randint(0, 999999)}")
os.makedirs(temp_dir, exist_ok=True)
try:
if engine == "ibcmd":
# --- ibcmd branch: dump EPF/ERF via config export --file ---
arguments = ["infobase", "config", "export", f"--file={args.InputFile}", args.OutputDir, f"--db-path={args.InfoBasePath}"]
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
if args.UserName:
arguments.append(f"--user={args.UserName}")
if args.Password:
arguments.append(f"--password={args.Password}")
arguments.append(f"--data={ib_data}")
arguments.extend(extra_args)
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
exit_code = result.returncode
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
if out_missing:
exit_code = 1
if exit_code == 0:
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
elif out_missing:
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
else:
print(f"Error dumping external data processor/report (code: {exit_code})")
sys.exit(exit_code)
# --- Build arguments ---
arguments = ["DESIGNER"]
if args.InfoBaseServer and args.InfoBaseRef:
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
else:
arguments += ["/F", f'"{args.InfoBasePath}"']
if args.UserName:
arguments.append(f'/N"{args.UserName}"')
if args.Password:
arguments.append(f'/P"{args.Password}"')
arguments += ["/DumpExternalDataProcessorOrReportToFiles", f'"{args.OutputDir}"', f'"{args.InputFile}"']
arguments += ["-Format", args.Format]
# --- Output ---
out_file = os.path.join(temp_dir, "dump_log.txt")
arguments += ["/Out", f'"{out_file}"']
arguments.append("/DisableStartupDialogs")
arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_v8(v8path, arguments)
exit_code = result.returncode
# --- Result ---
# Postcondition: exit 0 with an empty output directory is a false success.
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
if out_missing:
exit_code = 1
if exit_code == 0:
print(f"Dump completed successfully to: {args.OutputDir}")
elif out_missing:
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
else:
print(f"Error dumping (code: {exit_code})")
if os.path.isfile(out_file):
try:
with open(out_file, "r", encoding="utf-8-sig") as f:
log_content = f.read()
if log_content:
print("--- Log ---")
print(log_content)
print("--- End ---")
except Exception:
pass
print_platform_output(result)
sys.exit(exit_code)
finally:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
---
name: epf-init
description: Создать пустую внешнюю обработку 1С (scaffold XML-исходников). Используй когда нужно создать новую внешнюю обработку с нуля
argument-hint: <Name> [Synonym]
allowed-tools:
- Bash
- Read
- Write
- Edit
- Glob
- Grep
---
# /epf-init — Создание новой обработки
Генерирует минимальный набор XML-исходников для внешней обработки 1С: корневой файл метаданных и каталог обработки.
## Usage
```
/epf-init <Name> [Synonym] [SrcDir] [FormatVersion]
```
| Параметр | Обязательный | По умолчанию | Описание |
|---------------|:------------:|--------------|------------------------------------------------|
| Name | да | — | Имя обработки (латиница/кириллица) |
| Synonym | нет | = Name | Синоним (отображаемое имя) |
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
`FormatVersion`**не выше** версии формата платформы, на которой объект будут собирать и открывать:
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
но на них навыки не проверялись — такое значение принимается с предупреждением.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-init/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"]
```
## Дальнейшие шаги
- Добавить форму: `/form-add`
- Добавить макет: `/template-add`
- Добавить справку: `/help-add`
- Собрать EPF: `/epf-build`
+148
View File
@@ -0,0 +1,148 @@
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
[string]$Name,
[string]$Synonym = $Name,
[string]$SrcDir = "src",
# Версия формата выгрузки. Своей конфигурации у автономной обработки нет, наследовать
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри обработки
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
[string]$FormatVersion = "2.17"
)
$ErrorActionPreference = "Stop"
# --- Format version ---
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
# на нечисловое значение: это опечатка, а не версия.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
$formatRank = Get-FormatRank $FormatVersion
function Esc-XmlText {
param([string]$s)
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
if ($formatRank -eq 0) {
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
exit 1
}
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
}
$uuid1 = [guid]::NewGuid().ToString()
$uuid2 = [guid]::NewGuid().ToString()
$uuid3 = [guid]::NewGuid().ToString()
$uuid4 = [guid]::NewGuid().ToString()
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
# платформа держит объявления по алфавиту, дописать в конец нельзя.
if ($formatRank -ge 221) {
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
}
$xml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $xmlnsDecl version="$FormatVersion">
<ExternalDataProcessor uuid="$uuid1">
<InternalInfo>
<xr:ContainedObject>
<xr:ClassId>c3831ec8-d8d5-4f93-8a22-f9bfae07327f</xr:ClassId>
<xr:ObjectId>$uuid2</xr:ObjectId>
</xr:ContainedObject>
<xr:GeneratedType name="ExternalDataProcessorObject.$Name" category="Object">
<xr:TypeId>$uuid3</xr:TypeId>
<xr:ValueId>$uuid4</xr:ValueId>
</xr:GeneratedType>
</InternalInfo>
<Properties>
<Name>$(Esc-XmlText $Name)</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
</v8:item>
</Synonym>
<Comment/>
<DefaultForm/>
<AuxiliaryForm/>
</Properties>
<ChildObjects/>
</ExternalDataProcessor>
</MetaDataObject>
"@
$rootFile = Join-Path $SrcDir "$Name.xml"
$processorDir = Join-Path $SrcDir $Name
if (Test-Path $rootFile) {
Write-Error "Файл уже существует: $rootFile"
exit 1
}
if (-not (Test-Path $SrcDir)) {
New-Item -ItemType Directory -Path $SrcDir -Force | Out-Null
}
$extDir = Join-Path $processorDir "Ext"
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
$enc = New-Object System.Text.UTF8Encoding($true)
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
# --- Модуль объекта ---
$moduleBsl = @"
#Область ОписаниеПеременных
#КонецОбласти
#Область ПрограммныйИнтерфейс
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти
"@
$modulePath = Join-Path $extDir "ObjectModule.bsl"
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
# самого скрипта, а он в репозитории хранится с LF.
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
Write-Host "[OK] Создана обработка: $rootFile"
Write-Host " Каталог: $processorDir"
Write-Host " Модуль: $modulePath"
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C external data processor."""
import sys, os, re, argparse, uuid
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def esc_xml_text(s):
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def new_uuid():
return str(uuid.uuid4())
def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми сознательно: разошедшиеся копии сводят на нет весь смысл.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description='Init 1C external data processor scaffold', allow_abbrev=False)
parser.add_argument('-Name', dest='Name', required=True)
parser.add_argument('-Synonym', dest='Synonym', default=None)
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
args = ci_parse_args(parser)
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
format_rank_value = format_rank(args.FormatVersion)
if format_rank_value == 0:
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
sys.exit(1)
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
f"but was not verified on that platform", file=sys.stderr)
name = args.Name
synonym = args.Synonym if args.Synonym else name
src_dir = args.SrcDir
uuid1 = new_uuid()
uuid2 = new_uuid()
uuid3 = new_uuid()
uuid4 = new_uuid()
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
xmlns_decl = (
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
)
format_version = args.FormatVersion
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
if format_rank(format_version) >= 221:
xmlns_decl = xmlns_decl.replace(
' xmlns:style=',
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {xmlns_decl} version="{format_version}">
\t<ExternalDataProcessor uuid="{uuid1}">
\t\t<InternalInfo>
\t\t\t<xr:ContainedObject>
\t\t\t\t<xr:ClassId>c3831ec8-d8d5-4f93-8a22-f9bfae07327f</xr:ClassId>
\t\t\t\t<xr:ObjectId>{uuid2}</xr:ObjectId>
\t\t\t</xr:ContainedObject>
\t\t\t<xr:GeneratedType name="ExternalDataProcessorObject.{name}" category="Object">
\t\t\t\t<xr:TypeId>{uuid3}</xr:TypeId>
\t\t\t\t<xr:ValueId>{uuid4}</xr:ValueId>
\t\t\t</xr:GeneratedType>
\t\t</InternalInfo>
\t\t<Properties>
\t\t\t<Name>{esc_xml_text(name)}</Name>
\t\t\t<Synonym>
\t\t\t\t<v8:item>
\t\t\t\t\t<v8:lang>ru</v8:lang>
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
\t\t\t\t</v8:item>
\t\t\t</Synonym>
\t\t\t<Comment/>
\t\t\t<DefaultForm/>
\t\t\t<AuxiliaryForm/>
\t\t</Properties>
\t\t<ChildObjects/>
\t</ExternalDataProcessor>
</MetaDataObject>'''
root_file = os.path.join(src_dir, f"{name}.xml")
processor_dir = os.path.join(src_dir, name)
if os.path.exists(root_file):
print(f"Файл уже существует: {root_file}", file=sys.stderr)
sys.exit(1)
os.makedirs(src_dir, exist_ok=True)
ext_dir = os.path.join(processor_dir, "Ext")
os.makedirs(ext_dir, exist_ok=True)
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
# --- Модуль объекта ---
module_bsl = """\
#Область ОписаниеПеременных
#КонецОбласти
#Область ПрограммныйИнтерфейс
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти"""
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без).
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
print(f"[OK] Создана обработка: {root_file}")
print(f" Каталог: {processor_dir}")
print(f" Модуль: {module_path}")
if __name__ == '__main__':
main()
+30
View File
@@ -0,0 +1,30 @@
---
name: epf-validate
description: Валидация внешней обработки 1С (EPF). Используй после создания или модификации обработки для проверки корректности
argument-hint: <ObjectPath> [-Detailed] [-MaxErrors 30]
allowed-tools:
- Bash
- Read
- Glob
---
# /epf-validate — валидация внешней обработки (EPF)
Проверяет структурную корректность XML-исходников внешней обработки: корневую структуру, InternalInfo, свойства, ChildObjects, реквизиты, табличные части, уникальность имён, наличие файлов форм и макетов. Также работает для внешних отчётов (ERF).
## Параметры
| Параметр | Обяз. | Умолч. | Описание |
|------------|:-----:|---------|-------------------------------------------------|
| ObjectPath | да | — | Путь к корневому XML или каталогу обработки |
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
| MaxErrors | нет | 30 | Остановиться после N ошибок |
| OutFile | нет | — | Записать результат в файл (UTF-8 BOM) |
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка/МояОбработка.xml"
```
@@ -0,0 +1,861 @@
# epf-validate v1.6 — Validate 1C external data processor / report structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory, Position=0)]
[Alias('Path')]
[string]$ObjectPath,
[switch]$Detailed,
[int]$MaxErrors = 30,
[string]$OutFile
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Resolve path ---
if (-not [System.IO.Path]::IsPathRooted($ObjectPath)) {
$ObjectPath = Join-Path (Get-Location).Path $ObjectPath
}
if (Test-Path $ObjectPath -PathType Container) {
$dirName = Split-Path $ObjectPath -Leaf
$candidate = Join-Path $ObjectPath "$dirName.xml"
$sibling = Join-Path (Split-Path $ObjectPath) "$dirName.xml"
if (Test-Path $candidate) {
$ObjectPath = $candidate
} elseif (Test-Path $sibling) {
$ObjectPath = $sibling
} else {
$xmlFiles = @(Get-ChildItem $ObjectPath -Filter "*.xml" -File | Select-Object -First 1)
if ($xmlFiles.Count -gt 0) {
$ObjectPath = $xmlFiles[0].FullName
} else {
Write-Host "[ERROR] No XML file found in directory: $ObjectPath"
exit 1
}
}
}
# File not found — check Dir/Name/Name.xml → Dir/Name.xml
if (-not (Test-Path $ObjectPath)) {
$fileName = [System.IO.Path]::GetFileNameWithoutExtension($ObjectPath)
$parentDir = Split-Path $ObjectPath
$parentDirName = Split-Path $parentDir -Leaf
if ($fileName -eq $parentDirName) {
$candidate = Join-Path (Split-Path $parentDir) "$fileName.xml"
if (Test-Path $candidate) { $ObjectPath = $candidate }
}
}
if (-not (Test-Path $ObjectPath)) {
Write-Host "[ERROR] File not found: $ObjectPath"
exit 1
}
$resolvedPath = (Resolve-Path $ObjectPath).Path
$srcDir = Split-Path $resolvedPath -Parent
# --- Output infrastructure ---
$script:errors = 0
$script:warnings = 0
$script:okCount = 0
$script:stopped = $false
$script:output = New-Object System.Text.StringBuilder 8192
function Out-Line {
param([string]$msg)
$script:output.AppendLine($msg) | Out-Null
}
function Report-OK {
param([string]$msg)
$script:okCount++
if ($Detailed) { Out-Line "[OK] $msg" }
}
function Report-Error {
param([string]$msg)
$script:errors++
Out-Line "[ERROR] $msg"
if ($script:errors -ge $MaxErrors) {
$script:stopped = $true
}
}
function Report-Warn {
param([string]$msg)
$script:warnings++
Out-Line "[WARN] $msg"
}
$finalize = {
$checks = $script:okCount + $script:errors + $script:warnings
if ($script:errors -eq 0 -and $script:warnings -eq 0 -and -not $Detailed) {
$result = "=== Validation OK: $shortType.$objName ($checks checks) ==="
} else {
Out-Line ""
Out-Line "=== Result: $($script:errors) errors, $($script:warnings) warnings ($checks checks) ==="
$result = $script:output.ToString()
}
Write-Host $result
if ($OutFile) {
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($OutFile, $result, $utf8Bom)
Write-Host "Written to: $OutFile"
}
}
# --- Format version ---
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# --- Reference tables ---
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
$classIds = @{
"ExternalDataProcessor" = "c3831ec8-d8d5-4f93-8a22-f9bfae07327f"
"ExternalReport" = "e41aff26-25cf-4bb6-b6c1-3f478a75f374"
}
$allowedChildTypes = @("Attribute","TabularSection","Form","Template","Command")
# Expected order of child types in ChildObjects
$childTypeOrder = @{
"Attribute" = 0
"TabularSection" = 1
"Form" = 2
"Template" = 3
"Command" = 4
}
$validPropertyValues = @{
"FillChecking" = @("DontCheck","ShowError","ShowWarning")
"Indexing" = @("DontIndex","Index","IndexWithAdditionalOrder")
}
# --- 1. Parse XML ---
Out-Line ""
$xmlDoc = $null
try {
$xmlDoc = New-Object System.Xml.XmlDocument
$xmlDoc.PreserveWhitespace = $false
$xmlDoc.Load($resolvedPath)
} catch {
Out-Line "=== Validation: (parse failed) ==="
Out-Line ""
Report-Error "1. XML parse failed: $($_.Exception.Message)"
& $finalize
exit 1
}
# --- Register namespaces ---
$ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$ns.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$ns.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
$ns.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable")
$ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
$ns.AddNamespace("xs", "http://www.w3.org/2001/XMLSchema")
$ns.AddNamespace("app", "http://v8.1c.ru/8.2/managed-application/core")
$root = $xmlDoc.DocumentElement
# --- Check 1: Root structure ---
$check1Ok = $true
if ($root.LocalName -ne "MetaDataObject") {
Report-Error "1. Root element is '$($root.LocalName)', expected 'MetaDataObject'"
& $finalize
exit 1
}
$expectedNs = "http://v8.1c.ru/8.3/MDClasses"
if ($root.NamespaceURI -ne $expectedNs) {
Report-Error "1. Root namespace is '$($root.NamespaceURI)', expected '$expectedNs'"
$check1Ok = $false
}
$version = $root.GetAttribute("version")
$versionRank = Get-FormatRank $version
if (-not $version) {
Report-Warn "1. Missing version attribute on MetaDataObject"
} elseif ($versionRank -eq 0) {
Report-Error "1. Malformed version '$version' (expected N.N)"
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
}
# Detect type: ExternalDataProcessor or ExternalReport
$typeNode = $null
$mdType = ""
$childElements = @()
foreach ($child in $root.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.NamespaceURI -eq $expectedNs) {
$childElements += $child
}
}
if ($childElements.Count -eq 0) {
Report-Error "1. No metadata type element found inside MetaDataObject"
& $finalize
exit 1
} elseif ($childElements.Count -gt 1) {
Report-Error "1. Multiple type elements found: $($childElements | ForEach-Object { $_.LocalName })"
$check1Ok = $false
}
$typeNode = $childElements[0]
$mdType = $typeNode.LocalName
if ($mdType -ne "ExternalDataProcessor" -and $mdType -ne "ExternalReport") {
Report-Error "1. Unexpected type '$mdType' (expected ExternalDataProcessor or ExternalReport)"
& $finalize
exit 1
}
$typeUuid = $typeNode.GetAttribute("uuid")
if (-not $typeUuid) {
Report-Error "1. Missing uuid on <$mdType>"
$check1Ok = $false
} elseif ($typeUuid -notmatch $guidPattern) {
Report-Error "1. Invalid uuid '$typeUuid' on <$mdType>"
$check1Ok = $false
}
# Get object name
$propsNode = $typeNode.SelectSingleNode("md:Properties", $ns)
$nameNode = if ($propsNode) { $propsNode.SelectSingleNode("md:Name", $ns) } else { $null }
$objName = if ($nameNode -and $nameNode.InnerText) { $nameNode.InnerText } else { "(unknown)" }
$shortType = if ($mdType -eq "ExternalDataProcessor") { "EPF" } else { "ERF" }
$script:output.Insert(0, "=== Validation: $shortType.$objName ===$([Environment]::NewLine)") | Out-Null
if ($check1Ok) {
Report-OK "1. Root structure: MetaDataObject/$mdType, version $version"
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 2: InternalInfo ---
$internalInfo = $typeNode.SelectSingleNode("md:InternalInfo", $ns)
if (-not $internalInfo) {
Report-Error "2. InternalInfo block missing"
} else {
$check2Ok = $true
# ContainedObject / ClassId
$containedObj = $internalInfo.SelectSingleNode("xr:ContainedObject", $ns)
if (-not $containedObj) {
Report-Error "2. InternalInfo: missing xr:ContainedObject"
$check2Ok = $false
} else {
$classIdNode = $containedObj.SelectSingleNode("xr:ClassId", $ns)
$objectIdNode = $containedObj.SelectSingleNode("xr:ObjectId", $ns)
$expectedClassId = $classIds[$mdType]
if (-not $classIdNode -or -not $classIdNode.InnerText) {
Report-Error "2. Missing ClassId in ContainedObject"
$check2Ok = $false
} elseif ($classIdNode.InnerText -ne $expectedClassId) {
Report-Error "2. ClassId is '$($classIdNode.InnerText)', expected '$expectedClassId' for $mdType"
$check2Ok = $false
}
if ($objectIdNode -and $objectIdNode.InnerText -notmatch $guidPattern) {
Report-Error "2. Invalid ObjectId UUID"
$check2Ok = $false
}
}
# GeneratedType — expect exactly 1 with category "Object"
$genTypes = $internalInfo.SelectNodes("xr:GeneratedType", $ns)
if ($genTypes.Count -eq 0) {
Report-Error "2. No GeneratedType entries found"
$check2Ok = $false
} else {
foreach ($gt in $genTypes) {
$gtName = $gt.GetAttribute("name")
$gtCategory = $gt.GetAttribute("category")
if ($gtCategory -ne "Object") {
Report-Warn "2. Unexpected GeneratedType category '$gtCategory' (expected 'Object')"
}
# Name format: ExternalDataProcessorObject.Name or ExternalReportObject.Name
$expectedPrefix = "${mdType}Object."
if ($gtName -and $objName -ne "(unknown)" -and -not $gtName.StartsWith($expectedPrefix)) {
Report-Warn "2. GeneratedType name '$gtName' does not start with '$expectedPrefix'"
}
$typeId = $gt.SelectSingleNode("xr:TypeId", $ns)
$valueId = $gt.SelectSingleNode("xr:ValueId", $ns)
if ($typeId -and $typeId.InnerText -notmatch $guidPattern) {
Report-Error "2. Invalid TypeId UUID in GeneratedType"
$check2Ok = $false
}
if ($valueId -and $valueId.InnerText -notmatch $guidPattern) {
Report-Error "2. Invalid ValueId UUID in GeneratedType"
$check2Ok = $false
}
}
}
if ($check2Ok) {
Report-OK "2. InternalInfo: ClassId correct, $($genTypes.Count) GeneratedType"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 3: Properties ---
if (-not $propsNode) {
Report-Error "3. Properties block missing"
} else {
$check3Ok = $true
# Name
if (-not $nameNode -or -not $nameNode.InnerText) {
Report-Error "3. Properties: Name is missing or empty"
$check3Ok = $false
} else {
$nameVal = $nameNode.InnerText
if ($nameVal -notmatch $identPattern) {
Report-Error "3. Properties: Name '$nameVal' is not a valid 1C identifier"
$check3Ok = $false
}
if ($nameVal.Length -gt 80) {
Report-Warn "3. Properties: Name '$nameVal' exceeds 80 characters ($($nameVal.Length))"
}
}
# Synonym
$synNode = $propsNode.SelectSingleNode("md:Synonym", $ns)
$synPresent = $false
if ($synNode) {
$synItem = $synNode.SelectSingleNode("v8:item", $ns)
if ($synItem) {
$synContent = $synItem.SelectSingleNode("v8:content", $ns)
if ($synContent -and $synContent.InnerText) {
$synPresent = $true
}
}
}
# DefaultForm cross-reference (collected now, checked after ChildObjects)
$defaultFormNode = $propsNode.SelectSingleNode("md:DefaultForm", $ns)
$defaultFormVal = if ($defaultFormNode -and $defaultFormNode.InnerText.Trim()) { $defaultFormNode.InnerText.Trim() } else { "" }
# AuxiliaryForm cross-reference
$auxFormNode = $propsNode.SelectSingleNode("md:AuxiliaryForm", $ns)
$auxFormVal = if ($auxFormNode -and $auxFormNode.InnerText.Trim()) { $auxFormNode.InnerText.Trim() } else { "" }
# ERF-specific: MainDataCompositionSchema
$mainDCSVal = ""
if ($mdType -eq "ExternalReport") {
$mainDCSNode = $propsNode.SelectSingleNode("md:MainDataCompositionSchema", $ns)
$mainDCSVal = if ($mainDCSNode -and $mainDCSNode.InnerText.Trim()) { $mainDCSNode.InnerText.Trim() } else { "" }
}
if ($check3Ok) {
$synInfo = if ($synPresent) { "Synonym present" } else { "no Synonym" }
$extras = ""
if ($defaultFormVal) { $extras += ", DefaultForm set" }
if ($mainDCSVal) { $extras += ", MainDCS set" }
Report-OK "3. Properties: Name=`"$objName`", $synInfo$extras"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 4: ChildObjects — allowed types and ordering ---
$childObjNode = $typeNode.SelectSingleNode("md:ChildObjects", $ns)
$formNames = @()
$templateNames = @()
$allChildNames = @{}
if ($childObjNode) {
$check4Ok = $true
$childCounts = @{}
$lastOrder = -1
$orderOk = $true
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
$childTag = $child.LocalName
if ($allowedChildTypes -notcontains $childTag) {
Report-Error "4. ChildObjects: disallowed element '$childTag'"
$check4Ok = $false
continue
}
if (-not $childCounts.ContainsKey($childTag)) {
$childCounts[$childTag] = 0
}
$childCounts[$childTag]++
# Check ordering
$thisOrder = $childTypeOrder[$childTag]
if ($thisOrder -lt $lastOrder -and $orderOk) {
Report-Warn "4. ChildObjects: '$childTag' appears after higher-order elements (expected: Attribute, TabularSection, Form, Template, Command)"
$orderOk = $false
}
$lastOrder = $thisOrder
# Collect Form and Template names (simple text content)
if ($childTag -eq "Form") {
$formNames += $child.InnerText.Trim()
} elseif ($childTag -eq "Template") {
$templateNames += $child.InnerText.Trim()
}
}
if ($check4Ok) {
$summary = ($childCounts.GetEnumerator() | Sort-Object { $childTypeOrder[$_.Name] } | ForEach-Object { "$($_.Name)($($_.Value))" }) -join ", "
if ($summary) {
Report-OK "4. ChildObjects: $summary"
} else {
Report-OK "4. ChildObjects: empty"
}
}
} else {
Report-OK "4. ChildObjects: absent"
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 5: DefaultForm / MainDCS cross-references ---
$check5Ok = $true
if ($defaultFormVal) {
# Format: ExternalDataProcessor.Name.Form.FormName or ExternalReport.Name.Form.FormName
$expectedPrefix = "$mdType.$objName.Form."
if ($defaultFormVal.StartsWith($expectedPrefix)) {
$refFormName = $defaultFormVal.Substring($expectedPrefix.Length)
if ($formNames -notcontains $refFormName) {
Report-Error "5. DefaultForm references '$refFormName', but no such Form in ChildObjects"
$check5Ok = $false
}
} else {
Report-Warn "5. DefaultForm value '$defaultFormVal' has unexpected prefix (expected '$expectedPrefix...')"
}
}
if ($auxFormVal) {
$expectedPrefix = "$mdType.$objName.Form."
if ($auxFormVal.StartsWith($expectedPrefix)) {
$refFormName = $auxFormVal.Substring($expectedPrefix.Length)
if ($formNames -notcontains $refFormName) {
Report-Error "5. AuxiliaryForm references '$refFormName', but no such Form in ChildObjects"
$check5Ok = $false
}
}
}
if ($mainDCSVal -and $mdType -eq "ExternalReport") {
$expectedPrefix = "ExternalReport.$objName.Template."
if ($mainDCSVal.StartsWith($expectedPrefix)) {
$refTplName = $mainDCSVal.Substring($expectedPrefix.Length)
if ($templateNames -notcontains $refTplName) {
Report-Error "5. MainDataCompositionSchema references '$refTplName', but no such Template in ChildObjects"
$check5Ok = $false
}
} else {
Report-Warn "5. MainDataCompositionSchema value '$mainDCSVal' has unexpected prefix"
}
}
if ($check5Ok) {
$refs = @()
if ($defaultFormVal) { $refs += "DefaultForm" }
if ($auxFormVal) { $refs += "AuxiliaryForm" }
if ($mainDCSVal) { $refs += "MainDCS" }
if ($refs.Count -gt 0) {
Report-OK "5. Cross-references: $($refs -join ', ') valid"
} else {
Report-OK "5. Cross-references: none to check"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 6: Attributes — UUID, Name, Type ---
function Check-Attribute {
param(
[System.Xml.XmlNode]$node,
[string]$context
)
$uuid = $node.GetAttribute("uuid")
if (-not $uuid) {
Report-Error "6. $context Attribute missing uuid"
return $false
} elseif ($uuid -notmatch $guidPattern) {
Report-Error "6. $context Attribute has invalid uuid '$uuid'"
return $false
}
$elProps = $node.SelectSingleNode("md:Properties", $ns)
if (-not $elProps) {
Report-Error "6. $context Attribute (uuid=$uuid) missing Properties"
return $false
}
$elName = $elProps.SelectSingleNode("md:Name", $ns)
if (-not $elName -or -not $elName.InnerText) {
Report-Error "6. $context Attribute (uuid=$uuid) missing or empty Name"
return $false
}
$nameVal = $elName.InnerText
if ($nameVal -notmatch $identPattern) {
Report-Error "6. $context Attribute '$nameVal' has invalid identifier"
return $false
}
$typeEl = $elProps.SelectSingleNode("md:Type", $ns)
if (-not $typeEl) {
Report-Error "6. $context Attribute '$nameVal' missing Type block"
return $false
}
$v8Types = $typeEl.SelectNodes("v8:Type", $ns)
$v8TypeSets = $typeEl.SelectNodes("v8:TypeSet", $ns)
if ($v8Types.Count -eq 0 -and $v8TypeSets.Count -eq 0) {
Report-Error "6. $context Attribute '$nameVal' Type block has no v8:Type or v8:TypeSet"
return $false
}
return $true
}
if ($childObjNode) {
$attrs = $childObjNode.SelectNodes("md:Attribute", $ns)
$check6Ok = $true
$attrCount = 0
foreach ($attr in $attrs) {
if ($script:stopped) { break }
$ok = Check-Attribute -node $attr -context ""
if (-not $ok) { $check6Ok = $false }
$attrCount++
# Collect name for uniqueness
$ap = $attr.SelectSingleNode("md:Properties/md:Name", $ns)
if ($ap -and $ap.InnerText) {
$allChildNames["Attr:$($ap.InnerText)"] = $ap.InnerText
}
}
if ($attrCount -gt 0) {
if ($check6Ok) {
Report-OK "6. Attributes: $attrCount checked (UUID, Name, Type)"
}
} else {
Report-OK "6. Attributes: none"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 7: TabularSections ---
if ($childObjNode) {
$tsSections = $childObjNode.SelectNodes("md:TabularSection", $ns)
if ($tsSections.Count -gt 0) {
$check7Ok = $true
$tsCount = 0
$tsAttrTotal = 0
foreach ($ts in $tsSections) {
if ($script:stopped) { break }
$tsCount++
$tsUuid = $ts.GetAttribute("uuid")
if (-not $tsUuid -or $tsUuid -notmatch $guidPattern) {
Report-Error "7. TabularSection #${tsCount}: invalid or missing uuid"
$check7Ok = $false
}
$tsProps = $ts.SelectSingleNode("md:Properties", $ns)
$tsNameNode = if ($tsProps) { $tsProps.SelectSingleNode("md:Name", $ns) } else { $null }
$tsName = if ($tsNameNode -and $tsNameNode.InnerText) { $tsNameNode.InnerText } else { "(unnamed)" }
if (-not $tsNameNode -or -not $tsNameNode.InnerText) {
Report-Error "7. TabularSection #${tsCount}: missing or empty Name"
$check7Ok = $false
} elseif ($tsName -notmatch $identPattern) {
Report-Error "7. TabularSection '$tsName': invalid identifier"
$check7Ok = $false
}
$allChildNames["TS:$tsName"] = $tsName
# InternalInfo — expect 2 GeneratedType
$tsIntInfo = $ts.SelectSingleNode("md:InternalInfo", $ns)
if ($tsIntInfo) {
$tsGens = $tsIntInfo.SelectNodes("xr:GeneratedType", $ns)
if ($tsGens.Count -lt 2) {
Report-Warn "7. TabularSection '$tsName': expected 2 GeneratedType, found $($tsGens.Count)"
}
}
# Inner attributes
$tsChildObj = $ts.SelectSingleNode("md:ChildObjects", $ns)
if ($tsChildObj) {
$tsAttrs = $tsChildObj.SelectNodes("md:Attribute", $ns)
$tsAttrNames = @{}
foreach ($ta in $tsAttrs) {
$taOk = Check-Attribute -node $ta -context "TabularSection '$tsName'."
if (-not $taOk) { $check7Ok = $false }
$tsAttrTotal++
$taProps = $ta.SelectSingleNode("md:Properties/md:Name", $ns)
if ($taProps -and $taProps.InnerText) {
if ($tsAttrNames.ContainsKey($taProps.InnerText)) {
Report-Error "7. Duplicate attribute '$($taProps.InnerText)' in TabularSection '$tsName'"
$check7Ok = $false
} else {
$tsAttrNames[$taProps.InnerText] = $true
}
}
}
}
}
if ($check7Ok) {
Report-OK "7. TabularSections: $tsCount sections, $tsAttrTotal inner attributes"
}
} else {
Report-OK "7. TabularSections: none"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 8: Name uniqueness ---
$check8Ok = $true
# Collect all names: attributes + tabular sections + forms + templates + commands
$allNames = @{}
if ($childObjNode) {
$nameKinds = @(
@{ XPath = "md:Attribute"; Kind = "Attribute" },
@{ XPath = "md:TabularSection"; Kind = "TabularSection" },
@{ XPath = "md:Command"; Kind = "Command" }
)
foreach ($nk in $nameKinds) {
$nodes = $childObjNode.SelectNodes($nk.XPath, $ns)
foreach ($node in $nodes) {
$np = $node.SelectSingleNode("md:Properties/md:Name", $ns)
if ($np -and $np.InnerText) {
$nameVal = $np.InnerText
$key = "$($nk.Kind):$nameVal"
if ($allNames.ContainsKey($nameVal)) {
Report-Error "8. Duplicate name '$nameVal' ($($nk.Kind) conflicts with $($allNames[$nameVal]))"
$check8Ok = $false
} else {
$allNames[$nameVal] = $nk.Kind
}
}
}
}
# Forms and Templates are simple text nodes
foreach ($fn in $formNames) {
if ($allNames.ContainsKey($fn)) {
Report-Error "8. Duplicate name '$fn' (Form conflicts with $($allNames[$fn]))"
$check8Ok = $false
} else {
$allNames[$fn] = "Form"
}
}
foreach ($tn in $templateNames) {
if ($allNames.ContainsKey($tn)) {
Report-Error "8. Duplicate name '$tn' (Template conflicts with $($allNames[$tn]))"
$check8Ok = $false
} else {
$allNames[$tn] = "Template"
}
}
}
if ($check8Ok) {
Report-OK "8. Name uniqueness: $($allNames.Count) names, all unique"
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 9: File existence (forms and templates on disk) ---
$check9Ok = $true
$filesChecked = 0
# Object directory: same level as root XML, named after the object
$objDir = Join-Path $srcDir $objName
foreach ($fn in $formNames) {
# FormName.xml — form descriptor
$formMetaXml = Join-Path (Join-Path $objDir "Forms") "$fn.xml"
if (-not (Test-Path $formMetaXml)) {
Report-Error "9. Missing form descriptor: Forms/$fn.xml"
$check9Ok = $false
} else {
$filesChecked++
}
# FormName/Ext/Form.xml — form layout
$formXml = Join-Path (Join-Path (Join-Path (Join-Path $objDir "Forms") $fn) "Ext") "Form.xml"
if (-not (Test-Path $formXml)) {
Report-Error "9. Missing form layout: Forms/$fn/Ext/Form.xml"
$check9Ok = $false
} else {
$filesChecked++
}
}
foreach ($tn in $templateNames) {
# TemplateName.xml — template descriptor
$tplMetaXml = Join-Path (Join-Path $objDir "Templates") "$tn.xml"
if (-not (Test-Path $tplMetaXml)) {
Report-Error "9. Missing template descriptor: Templates/$tn.xml"
$check9Ok = $false
} else {
$filesChecked++
}
# TemplateName/Ext/Template.* — template content (extension varies)
$tplExtDir = Join-Path (Join-Path (Join-Path $objDir "Templates") $tn) "Ext"
if (Test-Path $tplExtDir) {
$tplFiles = @(Get-ChildItem $tplExtDir -Filter "Template.*" -File)
if ($tplFiles.Count -eq 0) {
Report-Error "9. Missing template content: Templates/$tn/Ext/Template.*"
$check9Ok = $false
} else {
$filesChecked++
}
} else {
Report-Error "9. Missing template Ext directory: Templates/$tn/Ext/"
$check9Ok = $false
}
}
# ObjectModule.bsl
$objModule = Join-Path (Join-Path $objDir "Ext") "ObjectModule.bsl"
if (Test-Path $objModule) {
$filesChecked++
}
if ($check9Ok) {
if ($filesChecked -gt 0) {
Report-OK "9. File existence: $filesChecked files verified"
} else {
Report-OK "9. File existence: no forms/templates to check"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 10: Form descriptors structure ---
$check10Ok = $true
$formsChecked = 0
foreach ($fn in $formNames) {
$formMetaXml = Join-Path (Join-Path $objDir "Forms") "$fn.xml"
if (-not (Test-Path $formMetaXml)) { continue }
try {
$fDoc = New-Object System.Xml.XmlDocument
$fDoc.PreserveWhitespace = $false
$fDoc.Load($formMetaXml)
$fRoot = $fDoc.DocumentElement
if ($fRoot.LocalName -ne "MetaDataObject") {
Report-Error "10. Form '$fn': root element is '$($fRoot.LocalName)', expected 'MetaDataObject'"
$check10Ok = $false
continue
}
$fTypeNode = $fRoot.SelectSingleNode("md:Form", $ns)
if (-not $fTypeNode) {
Report-Error "10. Form '$fn': missing <Form> element"
$check10Ok = $false
continue
}
$fUuid = $fTypeNode.GetAttribute("uuid")
if (-not $fUuid -or $fUuid -notmatch $guidPattern) {
Report-Error "10. Form '$fn': invalid or missing uuid"
$check10Ok = $false
}
$fProps = $fTypeNode.SelectSingleNode("md:Properties", $ns)
if ($fProps) {
$fName = $fProps.SelectSingleNode("md:Name", $ns)
if ($fName -and $fName.InnerText -ne $fn) {
Report-Error "10. Form '$fn': Name in descriptor is '$($fName.InnerText)', expected '$fn'"
$check10Ok = $false
}
# FormType should be Managed
$fType = $fProps.SelectSingleNode("md:FormType", $ns)
if ($fType -and $fType.InnerText -ne "Managed") {
Report-Warn "10. Form '$fn': FormType is '$($fType.InnerText)' (expected 'Managed')"
}
}
$formsChecked++
} catch {
Report-Error "10. Form '$fn': XML parse error: $($_.Exception.Message)"
$check10Ok = $false
}
}
if ($check10Ok) {
if ($formsChecked -gt 0) {
Report-OK "10. Form descriptors: $formsChecked checked"
} else {
Report-OK "10. Form descriptors: none to check"
}
}
# --- Final output ---
& $finalize
if ($script:errors -gt 0) {
exit 1
}
exit 0
@@ -0,0 +1,752 @@
#!/usr/bin/env python3
# epf-validate v1.6 — Validate 1C external data processor / report structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
import argparse
import os
import re
import sys
from io import StringIO
from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
V8_NS = "http://v8.1c.ru/8.1/data/core"
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
XS_NS = "http://www.w3.org/2001/XMLSchema"
APP_NS = "http://v8.1c.ru/8.2/managed-application/core"
NSMAP = {"md": MD_NS, "v8": V8_NS, "xr": XR_NS, "xsi": XSI_NS, "xs": XS_NS, "app": APP_NS}
GUID_PATTERN = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
IDENT_PATTERN = re.compile(r'^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$')
CLASS_IDS = {
"ExternalDataProcessor": "c3831ec8-d8d5-4f93-8a22-f9bfae07327f",
"ExternalReport": "e41aff26-25cf-4bb6-b6c1-3f478a75f374",
}
ALLOWED_CHILD_TYPES = {"Attribute", "TabularSection", "Form", "Template", "Command"}
CHILD_TYPE_ORDER = {
"Attribute": 0,
"TabularSection": 1,
"Form": 2,
"Template": 3,
"Command": 4,
}
# ── Format version ───────────────────────────────────────────
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
def localname(el):
return etree.QName(el.tag).localname
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Validate 1C external data processor/report structure", allow_abbrev=False)
parser.add_argument("-ObjectPath", "-Path", required=True)
parser.add_argument("-Detailed", action="store_true")
parser.add_argument("-MaxErrors", type=int, default=30)
parser.add_argument("-OutFile", default=None)
args = ci_parse_args(parser)
max_errors = args.MaxErrors
# --- Resolve path ---
object_path = args.ObjectPath
if not os.path.isabs(object_path):
object_path = os.path.join(os.getcwd(), object_path)
if os.path.isdir(object_path):
dir_name = os.path.basename(object_path)
candidate = os.path.join(object_path, f"{dir_name}.xml")
sibling = os.path.join(os.path.dirname(object_path), f"{dir_name}.xml")
if os.path.isfile(candidate):
object_path = candidate
elif os.path.isfile(sibling):
object_path = sibling
else:
xml_files = [f for f in os.listdir(object_path) if f.lower().endswith(".xml")]
if xml_files:
object_path = os.path.join(object_path, xml_files[0])
else:
print(f"[ERROR] No XML file found in directory: {object_path}")
sys.exit(1)
if not os.path.isfile(object_path):
file_name = os.path.splitext(os.path.basename(object_path))[0]
parent_dir = os.path.dirname(object_path)
parent_dir_name = os.path.basename(parent_dir)
if file_name == parent_dir_name:
candidate = os.path.join(os.path.dirname(parent_dir), f"{file_name}.xml")
if os.path.isfile(candidate):
object_path = candidate
if not os.path.isfile(object_path):
print(f"[ERROR] File not found: {object_path}")
sys.exit(1)
resolved_path = os.path.abspath(object_path)
src_dir = os.path.dirname(resolved_path)
# --- Output infrastructure ---
detailed = args.Detailed
errors = 0
warnings = 0
ok_count = 0
stopped = False
output_lines = []
def out_line(msg):
output_lines.append(msg)
def report_ok(msg):
nonlocal ok_count
ok_count += 1
if detailed:
out_line(f"[OK] {msg}")
def report_error(msg):
nonlocal errors, stopped
errors += 1
out_line(f"[ERROR] {msg}")
if errors >= max_errors:
stopped = True
def report_warn(msg):
nonlocal warnings
warnings += 1
out_line(f"[WARN] {msg}")
def finalize():
checks = ok_count + errors + warnings
if errors == 0 and warnings == 0 and not detailed:
result = f"=== Validation OK: {short_type}.{obj_name} ({checks} checks) ==="
else:
out_line("")
out_line(f"=== Result: {errors} errors, {warnings} warnings ({checks} checks) ===")
result = "\n".join(output_lines)
print(result)
if args.OutFile:
with open(args.OutFile, "w", encoding="utf-8-sig") as fh:
fh.write(result)
print(f"Written to: {args.OutFile}")
# --- 1. Parse XML ---
out_line("")
try:
xml_parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(resolved_path, xml_parser)
except Exception as e:
out_line("=== Validation: (parse failed) ===")
out_line("")
report_error(f"1. XML parse failed: {e}")
finalize()
sys.exit(1)
root = tree.getroot()
# --- Check 1: Root structure ---
check1_ok = True
if localname(root) != "MetaDataObject":
report_error(f"1. Root element is '{localname(root)}', expected 'MetaDataObject'")
finalize()
sys.exit(1)
expected_ns = MD_NS
if root.tag.split("}")[0].lstrip("{") != expected_ns:
report_error(f"1. Root namespace is '{root.tag.split('}')[0].lstrip('{')}', expected '{expected_ns}'")
check1_ok = False
version = root.get("version", "")
version_rank = format_rank(version)
if not version:
report_warn("1. Missing version attribute on MetaDataObject")
elif version_rank == 0:
report_error(f"1. Malformed version '{version}' (expected N.N)")
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
report_warn(f"1. Format version '{version}' is below the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
report_warn(f"1. Format version '{version}' is above the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
# Detect type
child_elements = []
for child in root:
if isinstance(child.tag, str) and child.tag.startswith(f"{{{expected_ns}}}"):
child_elements.append(child)
if not child_elements:
report_error("1. No metadata type element found inside MetaDataObject")
finalize()
sys.exit(1)
elif len(child_elements) > 1:
report_error(f"1. Multiple type elements found: {[localname(c) for c in child_elements]}")
check1_ok = False
type_node = child_elements[0]
md_type = localname(type_node)
if md_type not in ("ExternalDataProcessor", "ExternalReport"):
report_error(f"1. Unexpected type '{md_type}' (expected ExternalDataProcessor or ExternalReport)")
finalize()
sys.exit(1)
type_uuid = type_node.get("uuid", "")
if not type_uuid:
report_error(f"1. Missing uuid on <{md_type}>")
check1_ok = False
elif not GUID_PATTERN.match(type_uuid):
report_error(f"1. Invalid uuid '{type_uuid}' on <{md_type}>")
check1_ok = False
props_node = type_node.find(f"{{{MD_NS}}}Properties")
name_node = props_node.find(f"{{{MD_NS}}}Name") if props_node is not None else None
obj_name = name_node.text if name_node is not None and name_node.text else "(unknown)"
short_type = "EPF" if md_type == "ExternalDataProcessor" else "ERF"
output_lines.insert(0, f"=== Validation: {short_type}.{obj_name} ===")
if check1_ok:
report_ok(f"1. Root structure: MetaDataObject/{md_type}, version {version}")
if stopped:
finalize()
sys.exit(1)
# --- Check 2: InternalInfo ---
internal_info = type_node.find(f"{{{MD_NS}}}InternalInfo")
if internal_info is None:
report_error("2. InternalInfo block missing")
else:
check2_ok = True
contained_obj = internal_info.find(f"{{{XR_NS}}}ContainedObject")
if contained_obj is None:
report_error("2. InternalInfo: missing xr:ContainedObject")
check2_ok = False
else:
class_id_node = contained_obj.find(f"{{{XR_NS}}}ClassId")
object_id_node = contained_obj.find(f"{{{XR_NS}}}ObjectId")
expected_class_id = CLASS_IDS[md_type]
if class_id_node is None or not class_id_node.text:
report_error("2. Missing ClassId in ContainedObject")
check2_ok = False
elif class_id_node.text != expected_class_id:
report_error(f"2. ClassId is '{class_id_node.text}', expected '{expected_class_id}' for {md_type}")
check2_ok = False
if object_id_node is not None and object_id_node.text and not GUID_PATTERN.match(object_id_node.text):
report_error("2. Invalid ObjectId UUID")
check2_ok = False
gen_types = internal_info.findall(f"{{{XR_NS}}}GeneratedType")
if not gen_types:
report_error("2. No GeneratedType entries found")
check2_ok = False
else:
for gt in gen_types:
gt_name = gt.get("name", "")
gt_category = gt.get("category", "")
if gt_category != "Object":
report_warn(f"2. Unexpected GeneratedType category '{gt_category}' (expected 'Object')")
expected_prefix = f"{md_type}Object."
if gt_name and obj_name != "(unknown)" and not gt_name.startswith(expected_prefix):
report_warn(f"2. GeneratedType name '{gt_name}' does not start with '{expected_prefix}'")
type_id = gt.find(f"{{{XR_NS}}}TypeId")
value_id = gt.find(f"{{{XR_NS}}}ValueId")
if type_id is not None and type_id.text and not GUID_PATTERN.match(type_id.text):
report_error("2. Invalid TypeId UUID in GeneratedType")
check2_ok = False
if value_id is not None and value_id.text and not GUID_PATTERN.match(value_id.text):
report_error("2. Invalid ValueId UUID in GeneratedType")
check2_ok = False
if check2_ok:
report_ok(f"2. InternalInfo: ClassId correct, {len(gen_types)} GeneratedType")
if stopped:
finalize()
sys.exit(1)
# --- Check 3: Properties ---
if props_node is None:
report_error("3. Properties block missing")
else:
check3_ok = True
if name_node is None or not name_node.text:
report_error("3. Properties: Name is missing or empty")
check3_ok = False
else:
name_val = name_node.text
if not IDENT_PATTERN.match(name_val):
report_error(f"3. Properties: Name '{name_val}' is not a valid 1C identifier")
check3_ok = False
if len(name_val) > 80:
report_warn(f"3. Properties: Name '{name_val}' exceeds 80 characters ({len(name_val)})")
syn_node = props_node.find(f"{{{MD_NS}}}Synonym")
syn_present = False
if syn_node is not None:
syn_item = syn_node.find(f"{{{V8_NS}}}item")
if syn_item is not None:
syn_content = syn_item.find(f"{{{V8_NS}}}content")
if syn_content is not None and syn_content.text:
syn_present = True
default_form_node = props_node.find(f"{{{MD_NS}}}DefaultForm")
default_form_val = (default_form_node.text or "").strip() if default_form_node is not None else ""
aux_form_node = props_node.find(f"{{{MD_NS}}}AuxiliaryForm")
aux_form_val = (aux_form_node.text or "").strip() if aux_form_node is not None else ""
main_dcs_val = ""
if md_type == "ExternalReport":
main_dcs_node = props_node.find(f"{{{MD_NS}}}MainDataCompositionSchema")
main_dcs_val = (main_dcs_node.text or "").strip() if main_dcs_node is not None else ""
if check3_ok:
syn_info = "Synonym present" if syn_present else "no Synonym"
extras = ""
if default_form_val:
extras += ", DefaultForm set"
if main_dcs_val:
extras += ", MainDCS set"
report_ok(f'3. Properties: Name="{obj_name}", {syn_info}{extras}')
if stopped:
finalize()
sys.exit(1)
# --- Check 4: ChildObjects ---
child_obj_node = type_node.find(f"{{{MD_NS}}}ChildObjects")
form_names = []
template_names = []
if child_obj_node is not None:
check4_ok = True
child_counts = {}
last_order = -1
order_ok = True
for child in child_obj_node:
if not isinstance(child.tag, str):
continue
child_tag = localname(child)
if child_tag not in ALLOWED_CHILD_TYPES:
report_error(f"4. ChildObjects: disallowed element '{child_tag}'")
check4_ok = False
continue
child_counts[child_tag] = child_counts.get(child_tag, 0) + 1
this_order = CHILD_TYPE_ORDER.get(child_tag, -1)
if this_order < last_order and order_ok:
report_warn(f"4. ChildObjects: '{child_tag}' appears after higher-order elements (expected: Attribute, TabularSection, Form, Template, Command)")
order_ok = False
last_order = this_order
if child_tag == "Form":
form_names.append((child.text or "").strip())
elif child_tag == "Template":
template_names.append((child.text or "").strip())
if check4_ok:
summary = ", ".join(f"{k}({v})" for k, v in sorted(child_counts.items(), key=lambda x: CHILD_TYPE_ORDER.get(x[0], 99)))
if summary:
report_ok(f"4. ChildObjects: {summary}")
else:
report_ok("4. ChildObjects: empty")
else:
pass # no ChildObjects — nothing to check
if stopped:
finalize()
sys.exit(1)
# --- Check 5: DefaultForm / MainDCS cross-references ---
check5_ok = True
if default_form_val:
expected_prefix = f"{md_type}.{obj_name}.Form."
if default_form_val.startswith(expected_prefix):
ref_form_name = default_form_val[len(expected_prefix):]
if ref_form_name not in form_names:
report_error(f"5. DefaultForm references '{ref_form_name}', but no such Form in ChildObjects")
check5_ok = False
else:
report_warn(f"5. DefaultForm value '{default_form_val}' has unexpected prefix (expected '{expected_prefix}...')")
if aux_form_val:
expected_prefix = f"{md_type}.{obj_name}.Form."
if aux_form_val.startswith(expected_prefix):
ref_form_name = aux_form_val[len(expected_prefix):]
if ref_form_name not in form_names:
report_error(f"5. AuxiliaryForm references '{ref_form_name}', but no such Form in ChildObjects")
check5_ok = False
if main_dcs_val and md_type == "ExternalReport":
expected_prefix = f"ExternalReport.{obj_name}.Template."
if main_dcs_val.startswith(expected_prefix):
ref_tpl_name = main_dcs_val[len(expected_prefix):]
if ref_tpl_name not in template_names:
report_error(f"5. MainDataCompositionSchema references '{ref_tpl_name}', but no such Template in ChildObjects")
check5_ok = False
else:
report_warn(f"5. MainDataCompositionSchema value '{main_dcs_val}' has unexpected prefix")
if check5_ok:
refs = []
if default_form_val:
refs.append("DefaultForm")
if aux_form_val:
refs.append("AuxiliaryForm")
if main_dcs_val:
refs.append("MainDCS")
if refs:
report_ok(f"5. Cross-references: {', '.join(refs)} valid")
else:
pass # no cross-references to check
if stopped:
finalize()
sys.exit(1)
# --- Check 6: Attributes ---
def check_attribute(node, context):
uuid = node.get("uuid", "")
if not uuid:
report_error(f"6. {context}Attribute missing uuid")
return False
if not GUID_PATTERN.match(uuid):
report_error(f"6. {context}Attribute has invalid uuid '{uuid}'")
return False
el_props = node.find(f"{{{MD_NS}}}Properties")
if el_props is None:
report_error(f"6. {context}Attribute (uuid={uuid}) missing Properties")
return False
el_name = el_props.find(f"{{{MD_NS}}}Name")
if el_name is None or not el_name.text:
report_error(f"6. {context}Attribute (uuid={uuid}) missing or empty Name")
return False
name_val = el_name.text
if not IDENT_PATTERN.match(name_val):
report_error(f"6. {context}Attribute '{name_val}' has invalid identifier")
return False
type_el = el_props.find(f"{{{MD_NS}}}Type")
if type_el is None:
report_error(f"6. {context}Attribute '{name_val}' missing Type block")
return False
v8_types = type_el.findall(f"{{{V8_NS}}}Type")
v8_type_sets = type_el.findall(f"{{{V8_NS}}}TypeSet")
if not v8_types and not v8_type_sets:
report_error(f"6. {context}Attribute '{name_val}' Type block has no v8:Type or v8:TypeSet")
return False
return True
if child_obj_node is not None:
attrs = child_obj_node.findall(f"{{{MD_NS}}}Attribute")
check6_ok = True
attr_count = 0
for attr in attrs:
if stopped:
break
ok = check_attribute(attr, "")
if not ok:
check6_ok = False
attr_count += 1
if attr_count > 0:
if check6_ok:
report_ok(f"6. Attributes: {attr_count} checked (UUID, Name, Type)")
else:
pass # no attributes
else:
pass # no ChildObjects
if stopped:
finalize()
sys.exit(1)
# --- Check 7: TabularSections ---
if child_obj_node is not None:
ts_sections = child_obj_node.findall(f"{{{MD_NS}}}TabularSection")
if ts_sections:
check7_ok = True
ts_count = 0
ts_attr_total = 0
for ts in ts_sections:
if stopped:
break
ts_count += 1
ts_uuid = ts.get("uuid", "")
if not ts_uuid or not GUID_PATTERN.match(ts_uuid):
report_error(f"7. TabularSection #{ts_count}: invalid or missing uuid")
check7_ok = False
ts_props = ts.find(f"{{{MD_NS}}}Properties")
ts_name_node = ts_props.find(f"{{{MD_NS}}}Name") if ts_props is not None else None
ts_name = ts_name_node.text if ts_name_node is not None and ts_name_node.text else "(unnamed)"
if ts_name_node is None or not ts_name_node.text:
report_error(f"7. TabularSection #{ts_count}: missing or empty Name")
check7_ok = False
elif not IDENT_PATTERN.match(ts_name):
report_error(f"7. TabularSection '{ts_name}': invalid identifier")
check7_ok = False
ts_int_info = ts.find(f"{{{MD_NS}}}InternalInfo")
if ts_int_info is not None:
ts_gens = ts_int_info.findall(f"{{{XR_NS}}}GeneratedType")
if len(ts_gens) < 2:
report_warn(f"7. TabularSection '{ts_name}': expected 2 GeneratedType, found {len(ts_gens)}")
ts_child_obj = ts.find(f"{{{MD_NS}}}ChildObjects")
if ts_child_obj is not None:
ts_attrs = ts_child_obj.findall(f"{{{MD_NS}}}Attribute")
ts_attr_names = {}
for ta in ts_attrs:
ta_ok = check_attribute(ta, f"TabularSection '{ts_name}'.")
if not ta_ok:
check7_ok = False
ts_attr_total += 1
ta_props = ta.find(f"{{{MD_NS}}}Properties")
if ta_props is not None:
ta_name_node = ta_props.find(f"{{{MD_NS}}}Name")
if ta_name_node is not None and ta_name_node.text:
if ta_name_node.text in ts_attr_names:
report_error(f"7. Duplicate attribute '{ta_name_node.text}' in TabularSection '{ts_name}'")
check7_ok = False
else:
ts_attr_names[ta_name_node.text] = True
if check7_ok:
report_ok(f"7. TabularSections: {ts_count} sections, {ts_attr_total} inner attributes")
else:
pass # no tabular sections
else:
pass # no ChildObjects
if stopped:
finalize()
sys.exit(1)
# --- Check 8: Name uniqueness ---
check8_ok = True
all_names = {}
if child_obj_node is not None:
name_kinds = [
("Attribute", f"{{{MD_NS}}}Attribute"),
("TabularSection", f"{{{MD_NS}}}TabularSection"),
("Command", f"{{{MD_NS}}}Command"),
]
for kind, xpath in name_kinds:
nodes = child_obj_node.findall(xpath)
for node in nodes:
np = node.find(f"{{{MD_NS}}}Properties")
if np is not None:
nn = np.find(f"{{{MD_NS}}}Name")
if nn is not None and nn.text:
nv = nn.text
if nv in all_names:
report_error(f"8. Duplicate name '{nv}' ({kind} conflicts with {all_names[nv]})")
check8_ok = False
else:
all_names[nv] = kind
for fn in form_names:
if fn in all_names:
report_error(f"8. Duplicate name '{fn}' (Form conflicts with {all_names[fn]})")
check8_ok = False
else:
all_names[fn] = "Form"
for tn in template_names:
if tn in all_names:
report_error(f"8. Duplicate name '{tn}' (Template conflicts with {all_names[tn]})")
check8_ok = False
else:
all_names[tn] = "Template"
if check8_ok:
report_ok(f"8. Name uniqueness: {len(all_names)} names, all unique")
if stopped:
finalize()
sys.exit(1)
# --- Check 9: File existence ---
check9_ok = True
files_checked = 0
obj_dir = os.path.join(src_dir, obj_name)
for fn in form_names:
form_meta_xml = os.path.join(obj_dir, "Forms", f"{fn}.xml")
if not os.path.isfile(form_meta_xml):
report_error(f"9. Missing form descriptor: Forms/{fn}.xml")
check9_ok = False
else:
files_checked += 1
form_xml = os.path.join(obj_dir, "Forms", fn, "Ext", "Form.xml")
if not os.path.isfile(form_xml):
report_error(f"9. Missing form layout: Forms/{fn}/Ext/Form.xml")
check9_ok = False
else:
files_checked += 1
for tn in template_names:
tpl_meta_xml = os.path.join(obj_dir, "Templates", f"{tn}.xml")
if not os.path.isfile(tpl_meta_xml):
report_error(f"9. Missing template descriptor: Templates/{tn}.xml")
check9_ok = False
else:
files_checked += 1
tpl_ext_dir = os.path.join(obj_dir, "Templates", tn, "Ext")
if os.path.isdir(tpl_ext_dir):
tpl_files = [f for f in os.listdir(tpl_ext_dir) if f.startswith("Template.")]
if not tpl_files:
report_error(f"9. Missing template content: Templates/{tn}/Ext/Template.*")
check9_ok = False
else:
files_checked += 1
else:
report_error(f"9. Missing template Ext directory: Templates/{tn}/Ext/")
check9_ok = False
obj_module = os.path.join(obj_dir, "Ext", "ObjectModule.bsl")
if os.path.isfile(obj_module):
files_checked += 1
if check9_ok:
if files_checked > 0:
report_ok(f"9. File existence: {files_checked} files verified")
else:
pass # no forms/templates to check
if stopped:
finalize()
sys.exit(1)
# --- Check 10: Form descriptors structure ---
check10_ok = True
forms_checked = 0
for fn in form_names:
form_meta_xml = os.path.join(obj_dir, "Forms", f"{fn}.xml")
if not os.path.isfile(form_meta_xml):
continue
try:
f_parser = etree.XMLParser(remove_blank_text=True)
f_tree = etree.parse(form_meta_xml, f_parser)
f_root = f_tree.getroot()
if localname(f_root) != "MetaDataObject":
report_error(f"10. Form '{fn}': root element is '{localname(f_root)}', expected 'MetaDataObject'")
check10_ok = False
continue
f_type_node = f_root.find(f"{{{MD_NS}}}Form")
if f_type_node is None:
report_error(f"10. Form '{fn}': missing <Form> element")
check10_ok = False
continue
f_uuid = f_type_node.get("uuid", "")
if not f_uuid or not GUID_PATTERN.match(f_uuid):
report_error(f"10. Form '{fn}': invalid or missing uuid")
check10_ok = False
f_props = f_type_node.find(f"{{{MD_NS}}}Properties")
if f_props is not None:
f_name = f_props.find(f"{{{MD_NS}}}Name")
if f_name is not None and f_name.text != fn:
report_error(f"10. Form '{fn}': Name in descriptor is '{f_name.text}', expected '{fn}'")
check10_ok = False
f_type = f_props.find(f"{{{MD_NS}}}FormType")
if f_type is not None and f_type.text != "Managed":
report_warn(f"10. Form '{fn}': FormType is '{f_type.text}' (expected 'Managed')")
forms_checked += 1
except Exception as e:
report_error(f"10. Form '{fn}': XML parse error: {e}")
check10_ok = False
if check10_ok:
if forms_checked > 0:
report_ok(f"10. Form descriptors: {forms_checked} checked")
else:
pass # no form descriptors to check
# --- Final output ---
finalize()
if errors > 0:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
---
name: erf-build
description: Собрать внешний отчёт 1С (ERF) из XML-исходников. Используй когда пользователь просит собрать, скомпилировать отчёт или получить ERF файл из исходников
argument-hint: <ReportName>
allowed-tools:
- Bash
- Read
- Glob
- Grep
---
# /erf-build — Сборка отчёта
## Usage
```
/erf-build <ReportName> [SrcDir] [OutDir]
```
| Параметр | Обязательный | По умолчанию | Описание |
|------------|:------------:|--------------|--------------------------------------|
| ReportName | да | — | Имя отчёта (имя корневого XML) |
| SrcDir | нет | `src` | Каталог исходников |
| OutDir | нет | `build` | Каталог для результата |
## Параметры подключения (опционально)
Предпочтительно использовать конкретную базу — это надёжнее и не требует создания временной базы.
1. Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` и разреши базу:
2. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
3. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
4. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
5. Если ветка не совпала — используй `default`
6. Если `.v8-project.json` нет или база не найдена — не указывай параметры подключения: скрипт автоматически создаст временную базу. Для ERF со ссылочными типами (CatalogRef, DocumentRef и т.д.) генерируются заглушки метаданных. Временная база удаляется после сборки.
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
Используй общий скрипт из epf-build:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-build/scripts/epf-build.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
| `-OutputFile <путь>` | да | Путь к выходному ERF-файлу |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
## Примеры
```powershell
# Сборка отчёта (файловая база)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-build/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf"
# Серверная база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-build/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf"
```
+73
View File
@@ -0,0 +1,73 @@
---
name: erf-dump
description: Разобрать ERF-файл отчёта 1С в XML-исходники. Используй когда пользователь просит разобрать, декомпилировать отчёт, получить исходники из ERF файла
argument-hint: <ErfFile>
allowed-tools:
- Bash
- Read
- Glob
- Grep
---
# /erf-dump — Разборка отчёта
## Usage
```
/erf-dump <ErfFile> [OutDir]
```
| Параметр | Обязательный | По умолчанию | Описание |
|----------|:------------:|--------------|-------------------------------------|
| ErfFile | да | — | Путь к ERF-файлу |
| OutDir | нет | `src` | Каталог для выгрузки исходников |
## Параметры подключения (обязательно)
Для разборки EPF/ERF требуется информационная база с конфигурацией. Без базы ссылочные типы безвозвратно теряются.
1. Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` и разреши базу:
2. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
3. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
4. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
5. Если ветка не совпала — используй `default`
6. Если `.v8-project.json` нет или база не найдена — **сообщи пользователю об ошибке**. Для dump база обязательна: в пустой базе ссылочные типы (CatalogRef, DocumentRef и т.д.) безвозвратно сбрасываются в строки. Предложи указать базу или зарегистрировать через `/db-list add`.
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
## Команда
Используй общий скрипт из epf-dump:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-dump/scripts/epf-dump.ps1" <параметры>
```
### Параметры скрипта
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Имя пользователя |
| `-Password <пароль>` | нет | Пароль |
| `-InputFile <путь>` | да | Путь к ERF-файлу |
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
## Примеры
```powershell
# Разборка отчёта (файловая база)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-dump/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МойОтчёт.erf" -OutputDir "src"
# Серверная база
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-dump/scripts/epf-dump.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МойОтчёт.erf" -OutputDir "src"
```
+49
View File
@@ -0,0 +1,49 @@
---
name: erf-init
description: Создать пустой внешний отчёт 1С (scaffold XML-исходников). Используй когда нужно создать новый внешний отчёт с нуля
argument-hint: <Name> [Synonym] [--with-skd]
allowed-tools:
- Bash
- Read
- Write
- Edit
- Glob
- Grep
---
# /erf-init — Создание нового отчёта
Генерирует минимальный набор XML-исходников для внешнего отчёта 1С: корневой файл метаданных и каталог отчёта.
## Usage
```
/erf-init <Name> [Synonym] [SrcDir] [FormatVersion] [--with-skd]
```
| Параметр | Обязательный | По умолчанию | Описание |
|---------------|:------------:|--------------|---------------------------------------|
| Name | да | — | Имя отчёта (латиница/кириллица) |
| Synonym | нет | = Name | Синоним (отображаемое имя) |
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
`FormatVersion`**не выше** версии формата платформы, на которой объект будут собирать и открывать:
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
но на них навыки не проверялись — такое значение принимается с предупреждением.
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/erf-init/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"] [-WithSKD]
```
## Дальнейшие шаги
- Добавить форму: `/form-add`
- Добавить макет: `/template-add`
- Добавить справку: `/help-add`
- Собрать ERF: `/erf-build`
+238
View File
@@ -0,0 +1,238 @@
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
[string]$Name,
[string]$Synonym = $Name,
[string]$SrcDir = "src",
[switch]$WithSKD,
# Версия формата выгрузки. Своей конфигурации у автономного отчёта нет, наследовать
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри отчёта
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
[string]$FormatVersion = "2.17"
)
$ErrorActionPreference = "Stop"
# --- Format version ---
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
# на нечисловое значение: это опечатка, а не версия.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
$formatRank = Get-FormatRank $FormatVersion
function Esc-XmlText {
param([string]$s)
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
if ($formatRank -eq 0) {
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
exit 1
}
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
}
$uuid1 = [guid]::NewGuid().ToString()
$uuid2 = [guid]::NewGuid().ToString()
$uuid3 = [guid]::NewGuid().ToString()
$uuid4 = [guid]::NewGuid().ToString()
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
# платформа держит объявления по алфавиту, дописать в конец нельзя.
if ($formatRank -ge 221) {
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
}
# --- Формируем Properties ---
$mainDCSValue = ""
$childObjectsContent = ""
if ($WithSKD) {
$mainDCSValue = "ExternalReport.$Name.Template.ОсновнаяСхемаКомпоновкиДанных"
$childObjectsContent = @"
<Template>ОсновнаяСхемаКомпоновкиДанных</Template>
"@
}
$mainDCSElement = if ($mainDCSValue) {
"<MainDataCompositionSchema>$mainDCSValue</MainDataCompositionSchema>"
} else {
"<MainDataCompositionSchema/>"
}
$childObjectsXml = if ($childObjectsContent) {
"<ChildObjects>$childObjectsContent</ChildObjects>"
} else {
"<ChildObjects/>"
}
$xml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $xmlnsDecl version="$FormatVersion">
<ExternalReport uuid="$uuid1">
<InternalInfo>
<xr:ContainedObject>
<xr:ClassId>e41aff26-25cf-4bb6-b6c1-3f478a75f374</xr:ClassId>
<xr:ObjectId>$uuid2</xr:ObjectId>
</xr:ContainedObject>
<xr:GeneratedType name="ExternalReportObject.$Name" category="Object">
<xr:TypeId>$uuid3</xr:TypeId>
<xr:ValueId>$uuid4</xr:ValueId>
</xr:GeneratedType>
</InternalInfo>
<Properties>
<Name>$(Esc-XmlText $Name)</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
</v8:item>
</Synonym>
<Comment/>
<DefaultForm/>
<AuxiliaryForm/>
$mainDCSElement
<DefaultSettingsForm/>
<AuxiliarySettingsForm/>
<DefaultVariantForm/>
<VariantsStorage/>
<SettingsStorage/>
</Properties>
$childObjectsXml
</ExternalReport>
</MetaDataObject>
"@
$rootFile = Join-Path $SrcDir "$Name.xml"
$reportDir = Join-Path $SrcDir $Name
if (Test-Path $rootFile) {
Write-Error "Файл уже существует: $rootFile"
exit 1
}
if (-not (Test-Path $SrcDir)) {
New-Item -ItemType Directory -Path $SrcDir -Force | Out-Null
}
$extDir = Join-Path $reportDir "Ext"
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
$enc = New-Object System.Text.UTF8Encoding($true)
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
# --- Модуль объекта ---
$moduleBsl = @"
#Область ОписаниеПеременных
#КонецОбласти
#Область ПрограммныйИнтерфейс
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти
"@
$modulePath = Join-Path $extDir "ObjectModule.bsl"
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
# самого скрипта, а он в репозитории хранится с LF.
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
Write-Host "[OK] Создан отчёт: $rootFile"
Write-Host " Каталог: $reportDir"
Write-Host " Модуль: $modulePath"
# --- СКД-макет (если --WithSKD) ---
if ($WithSKD) {
$templatesDir = Join-Path $reportDir "Templates"
$skdName = "ОсновнаяСхемаКомпоновкиДанных"
$skdMetaPath = Join-Path $templatesDir "$skdName.xml"
$skdExtDir = Join-Path (Join-Path $templatesDir $skdName) "Ext"
New-Item -ItemType Directory -Path $skdExtDir -Force | Out-Null
$skdUuid = [guid]::NewGuid().ToString()
$skdMetaXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $xmlnsDecl version="$FormatVersion">
<Template uuid="$skdUuid">
<Properties>
<Name>$skdName</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основная схема компоновки данных</v8:content>
</v8:item>
</Synonym>
<Comment/>
<TemplateType>DataCompositionSchema</TemplateType>
</Properties>
</Template>
</MetaDataObject>
"@
Write-XmlFile $skdMetaPath $skdMetaXml $enc
$skdContent = @"
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"
xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"
xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"
xmlns:v8="http://v8.1c.ru/8.1/data/core"
xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
</DataCompositionSchema>
"@
$skdFilePath = Join-Path $skdExtDir "Template.xml"
Write-XmlFile $skdFilePath $skdContent $enc
Write-Host " СКД: $skdMetaPath"
Write-Host " Тело: $skdFilePath"
}
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C external report."""
import sys, os, re, argparse, uuid
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def esc_xml_text(s):
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def new_uuid():
return str(uuid.uuid4())
def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми сознательно: разошедшиеся копии сводят на нет весь смысл.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description='Init 1C external report scaffold', allow_abbrev=False)
parser.add_argument('-Name', dest='Name', required=True)
parser.add_argument('-Synonym', dest='Synonym', default=None)
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
parser.add_argument('-WithSKD', dest='WithSKD', action='store_true')
args = ci_parse_args(parser)
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
format_rank_value = format_rank(args.FormatVersion)
if format_rank_value == 0:
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
sys.exit(1)
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
f"but was not verified on that platform", file=sys.stderr)
name = args.Name
synonym = args.Synonym if args.Synonym else name
src_dir = args.SrcDir
uuid1 = new_uuid()
uuid2 = new_uuid()
uuid3 = new_uuid()
uuid4 = new_uuid()
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
xmlns_decl = (
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
)
format_version = args.FormatVersion
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
if format_rank(format_version) >= 221:
xmlns_decl = xmlns_decl.replace(
' xmlns:style=',
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
# --- Properties ---
main_dcs_value = ""
child_objects_content = ""
if args.WithSKD:
main_dcs_value = f"ExternalReport.{name}.Template.ОсновнаяСхемаКомпоновкиДанных"
child_objects_content = f"\n\t\t\t<Template>ОсновнаяСхемаКомпоновкиДанных</Template>\n"
main_dcs_element = f"<MainDataCompositionSchema>{main_dcs_value}</MainDataCompositionSchema>" if main_dcs_value else "<MainDataCompositionSchema/>"
child_objects_xml = f"<ChildObjects>{child_objects_content}\t\t</ChildObjects>" if child_objects_content else "<ChildObjects/>"
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {xmlns_decl} version="{format_version}">
\t<ExternalReport uuid="{uuid1}">
\t\t<InternalInfo>
\t\t\t<xr:ContainedObject>
\t\t\t\t<xr:ClassId>e41aff26-25cf-4bb6-b6c1-3f478a75f374</xr:ClassId>
\t\t\t\t<xr:ObjectId>{uuid2}</xr:ObjectId>
\t\t\t</xr:ContainedObject>
\t\t\t<xr:GeneratedType name="ExternalReportObject.{name}" category="Object">
\t\t\t\t<xr:TypeId>{uuid3}</xr:TypeId>
\t\t\t\t<xr:ValueId>{uuid4}</xr:ValueId>
\t\t\t</xr:GeneratedType>
\t\t</InternalInfo>
\t\t<Properties>
\t\t\t<Name>{esc_xml_text(name)}</Name>
\t\t\t<Synonym>
\t\t\t\t<v8:item>
\t\t\t\t\t<v8:lang>ru</v8:lang>
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
\t\t\t\t</v8:item>
\t\t\t</Synonym>
\t\t\t<Comment/>
\t\t\t<DefaultForm/>
\t\t\t<AuxiliaryForm/>
\t\t\t{main_dcs_element}
\t\t\t<DefaultSettingsForm/>
\t\t\t<AuxiliarySettingsForm/>
\t\t\t<DefaultVariantForm/>
\t\t\t<VariantsStorage/>
\t\t\t<SettingsStorage/>
\t\t</Properties>
\t\t{child_objects_xml}
\t</ExternalReport>
</MetaDataObject>'''
root_file = os.path.join(src_dir, f"{name}.xml")
report_dir = os.path.join(src_dir, name)
if os.path.exists(root_file):
print(f"Файл уже существует: {root_file}", file=sys.stderr)
sys.exit(1)
os.makedirs(src_dir, exist_ok=True)
ext_dir = os.path.join(report_dir, "Ext")
os.makedirs(ext_dir, exist_ok=True)
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
# --- Модуль объекта ---
module_bsl = """\
#Область ОписаниеПеременных
#КонецОбласти
#Область ПрограммныйИнтерфейс
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти"""
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без).
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
print(f"[OK] Создан отчёт: {root_file}")
print(f" Каталог: {report_dir}")
print(f" Модуль: {module_path}")
# --- СКД-макет ---
if args.WithSKD:
templates_dir = os.path.join(report_dir, "Templates")
skd_name = "ОсновнаяСхемаКомпоновкиДанных"
skd_meta_path = os.path.join(templates_dir, f"{skd_name}.xml")
skd_ext_dir = os.path.join(templates_dir, skd_name, "Ext")
os.makedirs(skd_ext_dir, exist_ok=True)
skd_uuid = new_uuid()
skd_meta_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {xmlns_decl} version="{format_version}">
\t<Template uuid="{skd_uuid}">
\t\t<Properties>
\t\t\t<Name>{skd_name}</Name>
\t\t\t<Synonym>
\t\t\t\t<v8:item>
\t\t\t\t\t<v8:lang>ru</v8:lang>
\t\t\t\t\t<v8:content>Основная схема компоновки данных</v8:content>
\t\t\t\t</v8:item>
\t\t\t</Synonym>
\t\t\t<Comment/>
\t\t\t<TemplateType>DataCompositionSchema</TemplateType>
\t\t</Properties>
\t</Template>
</MetaDataObject>'''
write_xml_file(skd_meta_path, skd_meta_xml)
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
\t\txmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"
\t\txmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"
\t\txmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"
\t\txmlns:v8="http://v8.1c.ru/8.1/data/core"
\t\txmlns:v8ui="http://v8.1c.ru/8.1/data/ui"
\t\txmlns:xs="http://www.w3.org/2001/XMLSchema"
\t\txmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
\t<dataSource>
\t\t<name>ИсточникДанных1</name>
\t\t<dataSourceType>Local</dataSourceType>
\t</dataSource>
</DataCompositionSchema>'''
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
write_xml_file(skd_file_path, skd_content)
print(f" СКД: {skd_meta_path}")
print(f" Тело: {skd_file_path}")
if __name__ == '__main__':
main()
+32
View File
@@ -0,0 +1,32 @@
---
name: erf-validate
description: Валидация внешнего отчёта 1С (ERF). Используй после создания или модификации отчёта для проверки корректности
argument-hint: <ObjectPath> [-Detailed] [-MaxErrors 30]
allowed-tools:
- Bash
- Read
- Glob
---
# /erf-validate — валидация внешнего отчёта (ERF)
Проверяет структурную корректность XML-исходников внешнего отчёта: корневую структуру, InternalInfo, свойства (включая MainDataCompositionSchema), ChildObjects, реквизиты, табличные части, уникальность имён, наличие файлов форм и макетов.
Использует тот же скрипт, что и `/epf-validate` — автоопределение по типу элемента (ExternalReport).
## Параметры
| Параметр | Обяз. | Умолч. | Описание |
|------------|:-----:|---------|-------------------------------------------------|
| ObjectPath | да | — | Путь к корневому XML или каталогу отчёта |
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
| MaxErrors | нет | 30 | Остановиться после N ошибок |
| OutFile | нет | — | Записать результат в файл (UTF-8 BOM) |
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МойОтчёт"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МойОтчёт/МойОтчёт.xml"
```
+93
View File
@@ -0,0 +1,93 @@
---
name: form-add
description: Добавить пустую управляемую форму к объекту 1С. Используй когда нужно создать у объекта новую форму
argument-hint: <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
allowed-tools:
- Bash
- Read
- Write
- Edit
- Glob
- Grep
---
# /form-add — Добавление формы к объекту конфигурации
Создаёт управляемую форму (metadata XML + Form.xml + Module.bsl) и регистрирует её в корневом XML объекта конфигурации (Document, Catalog, InformationRegister и др.).
## Usage
```
/form-add <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
```
| Параметр | Обязательный | По умолчанию | Описание |
|-------------|:------------:|--------------|----------------------------------------------|
| ObjectPath | да | — | Путь к XML-файлу объекта (Documents/Док.xml) |
| FormName | да | — | Имя формы (ФормаДокумента) |
| Purpose | нет | основная форма вида | Назначение формы — см. таблицу ниже: у справочника это форма объекта, у регистра сведений — форма записи, у журнала — форма списка |
| Synonym | нет | = FormName | Синоним формы |
| -SetDefault | нет | авто | Сделать основной. Без флага основной становится первая форма каждого назначения |
## Команда
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/form-add/scripts/form-add.ps1" -ObjectPath "<ObjectPath>" -FormName "<FormName>" [-Purpose "<Purpose>"] [-Synonym "<Synonym>"] [-SetDefault]
```
## Purpose — назначение формы
| Purpose | Какая форма | Становится основной |
|---------|-------------|---------------------|
| Object | форма объекта (элемента, документа, обработки) | да |
| List | форма списка | да |
| Choice | форма выбора | да |
| Folder | форма группы | да |
| FolderChoice | форма выбора группы | да |
| Record | форма записи | да |
| RecordSet | форма набора записей | нет — в платформе нет такого свойства |
| Save | форма сохранения настроек | да |
| Load | форма загрузки настроек | да |
| Custom | произвольная форма, без привязки к объекту | нет |
### Что доступно типу объекта
| Тип объекта | Назначения |
|-------------|------------|
| Catalog, ChartOfCharacteristicTypes | Object, Folder, List, Choice, FolderChoice, Custom |
| Document, ChartOfAccounts, ChartOfCalculationTypes, ExchangePlan, BusinessProcess, Task | Object, List, Choice, Custom |
| DataProcessor, Report, ExternalDataProcessor, ExternalReport | Object, Custom |
| InformationRegister | Record, List, RecordSet, Custom |
| AccumulationRegister, AccountingRegister, CalculationRegister | List, RecordSet, Custom |
| DocumentJournal, FilterCriterion | List, Custom |
| Enum | List, Choice, Custom |
| SettingsStorage | Save, Load, Custom |
Недопустимое сочетание отклоняется со списком доступных для этого типа. У константы собственных
форм нет — для неё используется общая форма (`CommonForm`).
## Примеры
```
# Форма документа
/form-add Documents/АвансовыйОтчет.xml ФормаДокумента -Purpose Object
# Форма списка каталога
/form-add Catalogs/Контрагенты.xml ФормаСписка -Purpose List
# Форма записи регистра сведений
/form-add InformationRegisters/КурсыВалют.xml ФормаЗаписи -Purpose Record
# Форма выбора с синонимом
/form-add Catalogs/Номенклатура.xml ФормаВыбора -Purpose Choice -Synonym "Выбор номенклатуры"
# Установить как форму по умолчанию
/form-add Documents/Заказ.xml ФормаДокументаНовая -Purpose Object -SetDefault
```
## Workflow
1. `/form-add` — создать каркас формы
2. `/form-compile` или `/form-edit` — наполнить Form.xml элементами
3. `/form-validate` — проверить корректность
4. `/form-info` — проанализировать результат
+795
View File
@@ -0,0 +1,795 @@
# form-add v1.28 — Add managed form to 1C config object
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory)]
[string]$ObjectPath,
[Parameter(Mandatory)]
[string]$FormName,
[string]$Synonym = $FormName,
# Пусто = основная форма вида (Primary в таблице): у справочника это форма объекта,
# у регистра сведений — форма записи, у журнала — форма списка. Жёсткое "Object"
# по умолчанию было бы неверным для видов, у которых формы объекта не бывает.
[string]$Purpose = "",
# Алиас с дефисом внутри имени: вызов вида --set-default PowerShell разбирает как имя
# параметра "set-default" и без алиаса отвечает отказом биндинга. Написания -SetDefault,
# --SetDefault и --setdefault совпадают с именем параметра и так.
[Alias('set-default')]
[switch]$SetDefault
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
# --- Support guard (Ext/ParentConfigurations.bin) ---
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
# read-only configs unless allowed. Trigger = bin present; reaction from
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
# throws — guard errors degrade to allow.
function Get-RootUuid([string]$xmlPath) {
if (-not (Test-Path $xmlPath)) { return $null }
try {
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
$el = $mx.DocumentElement.FirstChild
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
} catch {}
return $null
}
function Test-ExternalObjectRoot([string]$xmlPath) {
if (-not (Test-Path $xmlPath)) { return $false }
try {
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
$el = $mx.DocumentElement.FirstChild
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
} catch {}
return $false
}
function Find-V8Project([string]$startDir) {
$d = $startDir
for ($i = 0; $i -lt 20 -and $d; $i++) {
$pj = Join-Path $d ".v8-project.json"
if (Test-Path $pj) { return $pj }
$parent = [System.IO.Path]::GetDirectoryName($d)
if ($parent -eq $d) { break }
$d = $parent
}
return $null
}
function Get-EditMode([string]$cfgDir) {
try {
$pj = Find-V8Project (Get-Location).Path
if (-not $pj) { $pj = Find-V8Project $cfgDir }
if (-not $pj) { return 'deny' }
$proj = Get-Content -Raw $pj | ConvertFrom-Json
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
if ($proj.databases) {
foreach ($db in $proj.databases) {
if ($db.configSrc) {
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
}
}
}
}
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
return 'deny'
} catch { return 'deny' }
}
function Assert-EditAllowed([string]$targetPath, [string]$require) {
try {
$rp = $targetPath
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
if (Test-ExternalObjectRoot $rp) { return }
$elemUuid = Get-RootUuid $rp
$cfgDir = $null; $binPath = $null
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
for ($i = 0; $i -lt 12 -and $d; $i++) {
if (Test-ExternalObjectRoot "$d.xml") { return }
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
if (-not $cfgDir) {
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
}
if ($elemUuid -and $cfgDir) { break }
$parent = [System.IO.Path]::GetDirectoryName($d)
if ($parent -eq $d) { break }
$d = $parent
}
# New object (no element file): fall back to config root uuid.
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
if (-not $binPath -or -not (Test-Path $binPath)) { return }
$bytes = [System.IO.File]::ReadAllBytes($binPath)
if ($bytes.Length -le 32) { return }
$start = 0
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
if (-not $hm.Success) { return }
$G = [int]$hm.Groups[1].Value
$K = [int]$hm.Groups[2].Value
if ($K -eq 0) { return }
$best = $null
if ($elemUuid) {
$u = [regex]::Escape($elemUuid.ToLower())
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
$f1 = [int]$m.Groups[1].Value
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
}
}
$blocked = $false; $code = ""; $reason = ""
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
elseif ($require -eq 'removed') {
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
}
else {
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
}
if (-not $blocked) { return }
$mode = Get-EditMode $cfgDir
if ($mode -eq 'off') { return }
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
# latter throws and would be swallowed by this function's own catch.
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
if ($code -eq "capability-off") {
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
} elseif ($code -eq "not-removed") {
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
} else {
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
}
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
exit 1
} catch { return }
}
# --- Detect XML format version ---
function Detect-FormatVersion([string]$dir) {
$d = $dir
while ($d) {
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
$extPath = "$d.xml"
if (Test-Path $extPath) {
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
}
$cfgPath = Join-Path $d "Configuration.xml"
if (Test-Path $cfgPath) {
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
}
$parent = Split-Path $d -Parent
if ($parent -eq $d) { break }
$d = $parent
}
return "2.17"
}
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# --- Фаза 1: Определение типа объекта ---
# Resolve ObjectPath (directory → .xml)
if (-not [System.IO.Path]::IsPathRooted($ObjectPath)) {
$ObjectPath = Join-Path (Get-Location).Path $ObjectPath
}
if (Test-Path $ObjectPath -PathType Container) {
$dirName = Split-Path $ObjectPath -Leaf
$candidate = Join-Path $ObjectPath "$dirName.xml"
$sibling = Join-Path (Split-Path $ObjectPath) "$dirName.xml"
if (Test-Path $candidate) { $ObjectPath = $candidate }
elseif (Test-Path $sibling) { $ObjectPath = $sibling }
}
if (-not (Test-Path $ObjectPath)) {
Write-Error "Файл объекта не найден: $ObjectPath"
exit 1
}
$objectXmlFull = Resolve-Path $ObjectPath
Assert-EditAllowed $objectXmlFull.Path 'editable'
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
$script:formatVersion = $null
$objHead = [System.IO.File]::ReadAllText($objectXmlFull.Path, [System.Text.Encoding]::UTF8)
$objHead = $objHead.Substring(0, [Math]::Min(2000, $objHead.Length))
if ($objHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { $script:formatVersion = $Matches[1] }
if (-not $script:formatVersion) { $script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent) }
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
# интерполируют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
$script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
$script:formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
# дописать в конец нельзя.
if ((Get-FormatRank $script:formatVersion) -ge 221) {
$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
$script:formNsDecl = $script:formNsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
}
$xmlDoc = New-Object System.Xml.XmlDocument
$xmlDoc.PreserveWhitespace = $true
$xmlDoc.Load($objectXmlFull.Path)
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
# Определяем тип объекта по корневому тегу внутри MetaDataObject
$metaDataObject = $xmlDoc.SelectSingleNode("//md:MetaDataObject", $nsMgr)
if (-not $metaDataObject) {
# Пробуем без namespace (fallback)
$metaDataObject = $xmlDoc.DocumentElement
}
# --- Таблица видов: вид → допустимые назначения ---
#
# Одна запись на вид вместо разрозненных списков «поддерживаемые типы», «объектные типы»,
# «обработко-подобные» и «карта типов реквизита». Раньше они расходились молча: DocumentJournal
# был среди поддерживаемых, но не в карте типов, и в форму уходило `cfg:.Журнал` — платформа
# такую выгрузку не принимает, а навык рапортовал успех.
#
# MainAttr — тип главного реквизита; `{0}` подставляется именем объекта:
# "DynamicList" — динамический список (добавляется Settings/MainTable);
# $null — произвольная форма, блока Attributes нет вовсе.
# Slot — свойство объекта под «основную форму»; $null — такого свойства у вида нет.
# Эталон таблицы — docs/1c-form-spec.md, сверяется гардом check-form-purposes.mjs.
$formKinds = @{
"Catalog" = @{
"Object" = @{ MainAttr = "CatalogObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"Folder" = @{ MainAttr = "CatalogObject.{1}"; AttrName = "Объект"; Slot = "DefaultFolderForm"; SavedData = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"FolderChoice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultFolderChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ChartOfCharacteristicTypes" = @{
"Object" = @{ MainAttr = "ChartOfCharacteristicTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"Folder" = @{ MainAttr = "ChartOfCharacteristicTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultFolderForm"; SavedData = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"FolderChoice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultFolderChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"Document" = @{
"Object" = @{ MainAttr = "DocumentObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ChartOfAccounts" = @{
"Object" = @{ MainAttr = "ChartOfAccountsObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ChartOfCalculationTypes" = @{
"Object" = @{ MainAttr = "ChartOfCalculationTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ExchangePlan" = @{
"Object" = @{ MainAttr = "ExchangePlanObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"BusinessProcess" = @{
"Object" = @{ MainAttr = "BusinessProcessObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"Task" = @{
"Object" = @{ MainAttr = "TaskObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"DataProcessor" = @{
"Object" = @{ MainAttr = "DataProcessorObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"Report" = @{
"Object" = @{ MainAttr = "ReportObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ExternalDataProcessor" = @{
"Object" = @{ MainAttr = "ExternalDataProcessorObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ExternalReport" = @{
"Object" = @{ MainAttr = "ExternalReportObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"InformationRegister" = @{
"Record" = @{ MainAttr = "InformationRegisterRecordManager.{1}"; AttrName = "Запись"; Slot = "DefaultRecordForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"RecordSet" = @{ MainAttr = "InformationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"AccumulationRegister" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
"RecordSet" = @{ MainAttr = "AccumulationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"AccountingRegister" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
"RecordSet" = @{ MainAttr = "AccountingRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"CalculationRegister" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
"RecordSet" = @{ MainAttr = "CalculationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"DocumentJournal" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"FilterCriterion" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"Enum" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"SettingsStorage" = @{
"Save" = @{ MainAttr = $null; AttrName = $null; Slot = "DefaultSaveForm"; Primary = $true }
"Load" = @{ MainAttr = $null; AttrName = $null; Slot = "DefaultLoadForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
}
# Виды, у которых свойство DefaultForm есть, но собственных форм не бывает — отказ с причиной,
# а не «тип не поддерживается».
$noOwnForms = @{
"Constant" = "у константы нет собственных форм — используйте общую форму (CommonForm)"
}
$supportedTypes = @($formKinds.Keys) + @($noOwnForms.Keys)
# Отдельный факт, не выводимый из таблицы назначений: у форм обработок и отчётов в метаданных
# формы есть <ExtendedPresentation>.
$processorLikeTypes = @("DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport")
# Вид объекта — первый элемент-потомок MetaDataObject, а не первое совпавшее по всему документу
# имя. Поиск по документу зависел от порядка перебора видов: у бизнес-процесса есть свойство
# <Task>, и он определялся как задача, после чего имя объекта не находилось вовсе.
$objectType = $null
$objectNode = $null
foreach ($child in $metaDataObject.ChildNodes) {
if ($child.NodeType -eq [System.Xml.XmlNodeType]::Element) {
$objectType = $child.LocalName
$objectNode = $child
break
}
}
if ($objectType -and -not ($formKinds.ContainsKey($objectType) -or $noOwnForms.ContainsKey($objectType))) {
Write-Error "Тип объекта '$objectType' не поддерживается. Поддерживаемые типы: $(($formKinds.Keys | Sort-Object) -join ', ')"
exit 1
}
if (-not $objectType) {
Write-Error "Не удалось определить тип объекта. Поддерживаемые типы: $(($formKinds.Keys | Sort-Object) -join ', ')"
exit 1
}
if ($noOwnForms.ContainsKey($objectType)) {
Write-Error "$objectType не поддерживается: $($noOwnForms[$objectType])"
exit 1
}
# Имя объекта из Properties/Name
$objectName = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:Name", $nsMgr).InnerText
if (-not $objectName) {
Write-Error "Не удалось определить имя объекта из Properties/Name"
exit 1
}
Write-Host ""
Write-Host "=== form-add ==="
Write-Host ""
Write-Host "Object: $objectType.$objectName"
# --- Фаза 2: Валидация Purpose ---
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell (в py-порту .lower()).
$kindPurposes = $formKinds[$objectType]
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
$purposeSynonyms = @{
"формаобъекта"="Object"; "формаэлемента"="Object"; "формадокумента"="Object"
"объект"="Object"; "элемент"="Object"; "документ"="Object"; "objectform"="Object"
"формасписка"="List"; "список"="List"; "listform"="List"
"формавыбора"="Choice"; "выбор"="Choice"; "choiceform"="Choice"
"формагруппы"="Folder"; "группа"="Folder"; "folderform"="Folder"
"формавыборагруппы"="FolderChoice"; "выборгруппы"="FolderChoice"; "folderchoiceform"="FolderChoice"
"формазаписи"="Record"; "запись"="Record"; "recordform"="Record"
"форманаборазаписей"="RecordSet"; "наборзаписей"="RecordSet"; "recordsetform"="RecordSet"
"формасохранения"="Save"; "формасохранениянастроек"="Save"; "сохранение"="Save"; "saveform"="Save"
"формазагрузки"="Load"; "формазагрузкинастроек"="Load"; "загрузка"="Load"; "loadform"="Load"
"произвольная"="Custom"; "произвольнаяформа"="Custom"; "customform"="Custom"
}
if ($Purpose) {
$purposeProbe = ($Purpose -replace '[\s_-]', '').ToLowerInvariant()
$isKnownPurpose = $false
foreach ($p in $kindPurposes.Keys) {
if ($p.ToLowerInvariant() -eq $Purpose.ToLowerInvariant()) { $isKnownPurpose = $true; break }
}
if (-not $isKnownPurpose -and $purposeSynonyms.ContainsKey($purposeProbe)) {
$Purpose = $purposeSynonyms[$purposeProbe]
}
}
if (-not $Purpose) {
foreach ($p in $kindPurposes.Keys) {
if ($kindPurposes[$p].Primary) { $Purpose = $p; break }
}
}
$purposeKey = $null
foreach ($p in $kindPurposes.Keys) {
if ($p.ToLowerInvariant() -eq $Purpose.ToLowerInvariant()) { $purposeKey = $p; break }
}
if (-not $purposeKey) {
Write-Error "Назначение '$Purpose' недопустимо для $objectType. Допустимые: $(($kindPurposes.Keys | Sort-Object) -join ', ')"
exit 1
}
$Purpose = $purposeKey
$purposeRule = $kindPurposes[$Purpose]
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой MainAttr — это
# произвольная форма (законное состояние), а вот наполовину заполненная запись означала бы, что
# таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
if ($purposeRule.MainAttr -and -not $purposeRule.AttrName) {
Write-Error "Внутренняя ошибка таблицы видов: у $objectType/$Purpose задан MainAttr без AttrName"
exit 1
}
# --- Фаза 3: Создание файлов ---
$objectDir = [System.IO.Path]::ChangeExtension($objectXmlFull.Path, $null).TrimEnd('.')
$formsDir = Join-Path $objectDir "Forms"
$formMetaPath = Join-Path $formsDir "$FormName.xml"
if (Test-Path $formMetaPath) {
Write-Error "Форма уже существует: $formMetaPath"
exit 1
}
$formDir = Join-Path $formsDir $FormName
$formExtDir = Join-Path $formDir "Ext"
$formModuleDir = Join-Path $formExtDir "Form"
New-Item -ItemType Directory -Path $formModuleDir -Force | Out-Null
$encBom = New-Object System.Text.UTF8Encoding($true)
# --- 3a. Метаданные формы ---
$formUuid = [guid]::NewGuid().ToString()
# ExtendedPresentation — only for DataProcessor, Report, ExternalDataProcessor, ExternalReport forms
$extPresentationLine = ""
if ($objectType -in $processorLikeTypes) {
$extPresentationLine = "`n`t`t`t<ExtendedPresentation/>"
}
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
$useInIfcLine = ""
if ((Get-FormatRank $script:formatVersion) -ge 221) {
$useInIfcLine = "`n`t`t`t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>"
}
$formMetaXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $($script:xmlnsDecl) version="$($script:formatVersion)">
<Form uuid="$formUuid">
<Properties>
<Name>$FormName</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>$Synonym</v8:content>
</v8:item>
</Synonym>
<Comment/>
<FormType>Managed</FormType>
<IncludeHelpInContents>false</IncludeHelpInContents>
<UsePurposes>
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
</UsePurposes>$useInIfcLine$extPresentationLine
</Properties>
</Form>
</MetaDataObject>
"@
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
#
# Модуль .bsl сюда НЕ идёт — он пишется отдельно.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile $formMetaPath $formMetaXml $encBom
# --- 3b. Form.xml ---
$formXmlPath = Join-Path $formExtDir "Form.xml"
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
$attributesBlock = ""
if ($purposeRule.MainAttr) {
$mainAttrType = $purposeRule.MainAttr -f $objectType, $objectName
$mainAttrName = $purposeRule.AttrName
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
$tailLines = ""
if ($mainAttrType -eq "DynamicList") {
$mainTable = "$objectType.$objectName"
$tailLines = "`n`t`t`t<Settings xsi:type=""DynamicList"">`n`t`t`t`t<MainTable>$mainTable</MainTable>`n`t`t`t</Settings>"
} elseif ($purposeRule.SavedData) {
$tailLines = "`n`t`t`t<SavedData>true</SavedData>"
}
$attributesBlock = @"
<Attributes>
<Attribute name="$mainAttrName" id="1">
<Type>
<v8:Type>cfg:$mainAttrType</v8:Type>
</Type>
<MainAttribute>true</MainAttribute>$tailLines
</Attribute>
</Attributes>
"@
}
# Произвольная форма (MainAttr = $null) — без блока Attributes вовсе. В типовых это самая
# частая форма после объектной: 907 у справочников, 941 у документов, 3482 у отчётов.
$formXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<Form $($script:formNsDecl) version="$($script:formatVersion)">
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
<Autofill>true</Autofill>
</AutoCommandBar>
<ChildItems/>$attributesBlock
</Form>
"@
if (Test-Path $formXmlPath) {
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
} else {
Write-XmlFile $formXmlPath $formXml $encBom
}
# --- 3c. Module.bsl ---
$modulePath = Join-Path $formModuleDir "Module.bsl"
$moduleBsl = @"
#Область ОбработчикиСобытийФормы
#КонецОбласти
#Область ОбработчикиСобытийЭлементовФормы
#КонецОбласти
#Область ОбработчикиКомандФормы
#КонецОбласти
#Область ОбработчикиОповещений
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти
"@
if (Test-Path $modulePath) {
Write-Host "[SKIP] Module.bsl already exists: $modulePath — not overwriting"
} else {
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
# самого скрипта, а он в репозитории хранится с LF.
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $encBom)
}
# --- Фаза 4: Регистрация в родительском объекте ---
$childObjects = $xmlDoc.SelectSingleNode("//md:${objectType}/md:ChildObjects", $nsMgr)
if (-not $childObjects) {
Write-Error "Не найден элемент ChildObjects в $ObjectPath"
exit 1
}
# Добавить <Form>$FormName</Form> — идемпотентно (не дублировать уже зарегистрированную)
$alreadyRegistered = [bool]$childObjects.SelectSingleNode("md:Form[text()='$FormName']", $nsMgr)
if (-not $alreadyRegistered) {
$formElem = $xmlDoc.CreateElement("Form", "http://v8.1c.ru/8.3/MDClasses")
$formElem.InnerText = $FormName
# Ищем первый <Template> для вставки перед ним
$firstTemplate = $childObjects.SelectSingleNode("md:Template", $nsMgr)
# Ищем первую <TabularSection> для вставки перед ней (если нет Template)
$firstTabular = $childObjects.SelectSingleNode("md:TabularSection", $nsMgr)
# Определяем точку вставки: перед Template, перед TabularSection, или в конец
$insertBefore = $null
if ($firstTemplate) {
$insertBefore = $firstTemplate
} elseif ($firstTabular) {
$insertBefore = $firstTabular
}
if ($insertBefore) {
# Вставить перед найденным элементом, с переносом строки
$whitespace = $xmlDoc.CreateWhitespace("`n`t`t`t")
$childObjects.InsertBefore($formElem, $insertBefore) | Out-Null
$childObjects.InsertBefore($whitespace, $formElem) | Out-Null
# Переставляем: whitespace перед formElem — неправильный порядок
# Правильно: formElem, затем whitespace перед insertBefore
# InsertBefore возвращает вставленный узел, порядок: ... formElem whitespace insertBefore ...
# На самом деле нам нужно: ... \n\t\t\tformElem \n\t\t\tinsertBefore
# Удалим и вставим правильно
$childObjects.RemoveChild($whitespace) | Out-Null
$childObjects.RemoveChild($formElem) | Out-Null
$childObjects.InsertBefore($formElem, $insertBefore) | Out-Null
# Whitespace нужен ДО formElem (перенос строки + отступ)
# Но перед insertBefore уже должен быть whitespace от предыдущего элемента
# Нам нужно добавить whitespace ПОСЛЕ formElem (перед insertBefore)
$ws = $xmlDoc.CreateWhitespace("`n`t`t`t")
$childObjects.InsertBefore($ws, $insertBefore) | Out-Null
} else {
# Добавить в конец ChildObjects
if ($childObjects.ChildNodes.Count -eq 0) {
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t`t")) | Out-Null
$childObjects.AppendChild($formElem) | Out-Null
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t")) | Out-Null
} else {
$lastChild = $childObjects.LastChild
if ($lastChild.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
$childObjects.InsertBefore($xmlDoc.CreateWhitespace("`n`t`t`t"), $lastChild) | Out-Null
$childObjects.InsertBefore($formElem, $lastChild) | Out-Null
} else {
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t`t")) | Out-Null
$childObjects.AppendChild($formElem) | Out-Null
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t")) | Out-Null
}
}
}
}
# --- SetDefault ---
$existingForms = $childObjects.SelectNodes("md:Form", $nsMgr)
$isFirstFormForPurpose = $false
$defaultPropName = $null
$defaultValue = "$objectType.$objectName.Form.$FormName"
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному Purpose без учёта
# вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не находился, навык
# молча ничего не делал.
$defaultPropName = $purposeRule.Slot
$defaultNode = $null
if ($defaultPropName) {
$defaultNode = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:$defaultPropName", $nsMgr)
if ($defaultNode) {
$isFirstFormForPurpose = [string]::IsNullOrWhiteSpace($defaultNode.InnerText)
}
}
$defaultUpdated = $false
if ($SetDefault -or $isFirstFormForPurpose) {
if ($defaultNode) {
$defaultNode.InnerText = $defaultValue
$defaultUpdated = $true
}
}
# Сохранить с BOM
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = $encBom
$settings.Indent = $false
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
$memStream = New-Object System.IO.MemoryStream
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
$xmlDoc.Save($writer)
$writer.Flush(); $writer.Close()
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
$memStream.Close()
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
$targetEol = if ((Test-Path -LiteralPath $objectXmlFull.Path) -and ([System.IO.File]::ReadAllText($objectXmlFull.Path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
[System.IO.File]::WriteAllText($objectXmlFull.Path, $xmlText, $encBom)
# --- Фаза 5: Вывод ---
# Относительные пути для вывода
$basePath = Split-Path $objectXmlFull.Path -Parent
# Определяем корень (ищем родительский каталог типа Documents, Catalogs и т.д.)
$relFormMeta = $formMetaPath.Replace($basePath, "").TrimStart("\", "/")
$relFormXml = $formXmlPath.Replace($basePath, "").TrimStart("\", "/")
$relModule = $modulePath.Replace($basePath, "").TrimStart("\", "/")
$objFileName = [System.IO.Path]::GetFileName($ObjectPath)
$objDirName = Split-Path $ObjectPath -Parent
$objBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ObjectPath)
Write-Host "Created:"
Write-Host " Metadata: $objDirName\$objBaseName\Forms\$FormName.xml"
Write-Host " Form: $objDirName\$objBaseName\Forms\$FormName\Ext\Form.xml"
Write-Host " Module: $objDirName\$objBaseName\Forms\$FormName\Ext\Form\Module.bsl"
Write-Host ""
if ($alreadyRegistered) {
Write-Host "Already registered: <Form>$FormName</Form> in ChildObjects (skipped duplicate)"
} else {
Write-Host "Registered: <Form>$FormName</Form> in ChildObjects"
}
if ($defaultUpdated) {
Write-Host "${defaultPropName}: $defaultValue"
} elseif (-not $defaultPropName) {
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
# у платформы нет (форма набора записей, произвольная форма).
Write-Host "Основной не назначена: у $objectType нет свойства для формы с назначением $Purpose"
}
Write-Host ""
+906
View File
@@ -0,0 +1,906 @@
#!/usr/bin/env python3
# form-add v1.28 — Add managed form to 1C config object (Python port)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import json
import os
import re
import sys
import uuid
from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
# ============================================================
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
# ============================================================
def _sg_root_uuid(xml_path):
if not os.path.isfile(xml_path):
return None
try:
mx = etree.parse(xml_path).getroot()
for child in mx:
if isinstance(child.tag, str) and child.get("uuid"):
return child.get("uuid")
except Exception:
return None
return None
def _sg_is_external_root(xml_path):
if not os.path.isfile(xml_path):
return False
try:
mx = etree.parse(xml_path).getroot()
for child in mx:
if isinstance(child.tag, str):
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
except Exception:
return False
return False
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def _sg_get_edit_mode(cfg_dir):
try:
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
if not pj:
return "deny"
proj = json.loads(open(pj, encoding="utf-8-sig").read())
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
for db in proj.get("databases", []):
src = db.get("configSrc")
if src:
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
if db.get("editingAllowedCheck"):
return db["editingAllowedCheck"]
if proj.get("editingAllowedCheck"):
return proj["editingAllowedCheck"]
return "deny"
except Exception:
return "deny"
def assert_edit_allowed(target_path, require):
try:
rp = os.path.abspath(target_path)
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
if _sg_is_external_root(rp):
return
elem_uuid = _sg_root_uuid(rp)
cfg_dir = None
bin_path = None
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
for _ in range(12):
if not d:
break
if _sg_is_external_root(d + ".xml"):
return
if not elem_uuid:
elem_uuid = _sg_root_uuid(d + ".xml")
if not cfg_dir:
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
cfg_dir = d
bin_path = cand
if elem_uuid and cfg_dir:
break
parent = os.path.dirname(d)
if parent == d:
break
d = parent
if not elem_uuid and cfg_dir:
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
if not bin_path or not os.path.exists(bin_path):
return
data = open(bin_path, "rb").read()
if len(data) <= 32:
return
if data[:3] == b"\xef\xbb\xbf":
data = data[3:]
text = data.decode("utf-8", "replace")
h = re.match(r"\{6,(\d+),(\d+),", text)
if not h:
return
g = int(h.group(1))
k = int(h.group(2))
if k == 0:
return
best = None
if elem_uuid:
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
f1 = int(m.group(1))
if best is None or f1 < best:
best = f1
blocked = False
code = ""
reason = ""
if g == 1:
blocked = True
code = "capability-off"
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
elif require == "removed":
if best is not None and best != 2:
blocked = True
code = "not-removed"
reason = "объект не снят с поддержки — удаление сломает обновления"
else:
if best is not None and best == 0:
blocked = True
code = "locked"
reason = "объект на замке — редактирование сломает обновления"
if not blocked:
return
mode = _sg_get_edit_mode(cfg_dir)
if mode == "off":
return
if mode == "warn":
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
return
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
if code == "capability-off":
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
fix = (
"Либо снять защиту явно (навык support-edit, два шага):\n"
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
)
elif code == "not-removed":
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
fix = (
"Либо сначала снять объект с поддержки, затем удалять:\n"
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
)
else:
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
fix = (
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
)
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
sys.exit(1)
except SystemExit:
raise
except Exception:
return
NSMAP = {
"md": "http://v8.1c.ru/8.3/MDClasses",
"v8": "http://v8.1c.ru/8.1/data/core",
}
def detect_format_version(d):
while d:
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
ext_path = d + ".xml"
if os.path.isfile(ext_path):
with open(ext_path, "r", encoding="utf-8-sig") as f:
ext_head = f.read(2000)
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
if m:
return m.group(1)
cfg_path = os.path.join(d, "Configuration.xml")
if os.path.isfile(cfg_path):
with open(cfg_path, "r", encoding="utf-8-sig") as f:
head = f.read(2000)
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
if m:
return m.group(1)
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return "2.17"
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
def _detect_xml_style(path):
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
финальный перенос. None файл новый (сохранить текущее поведение)."""
try:
raw = open(path, "rb").read()
except OSError:
return None
bom = raw.startswith(b"\xef\xbb\xbf")
body = raw[3:] if bom else raw
crlf = b"\r\n" in body
m = re.search(rb'encoding="([^"]+)"', body[:200])
enc = m.group(1).decode("ascii") if m else "utf-8"
final_nl = body.endswith(b"\n")
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
def _finalize_xml_bytes(xml_bytes, style):
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
enc_decl = style["enc"] if style else "UTF-8"
xml_bytes = xml_bytes.replace(
b"<?xml version='1.0' encoding='UTF-8'?>",
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
# Канонизировать переносы к LF (убирает &#13; от \r в tail'ах)
xml_bytes = (xml_bytes.replace(b"&#13;\n", b"\n").replace(b"&#13;", b"")
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
want_final_nl = style["final_nl"] if style else False
xml_bytes = xml_bytes.rstrip(b"\n")
if want_final_nl:
xml_bytes += b"\n"
# EOL — как в оригинале (новый файл → CRLF, канон #57)
if (style["crlf"] if style else True):
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
return xml_bytes
def save_xml_with_bom(tree, path):
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
style = _detect_xml_style(path)
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
with open(path, "wb") as f:
if style is None or style["bom"]:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми сознательно: разошедшиеся копии сводят на нет весь смысл.
Модуль .bsl сюда НЕ идёт он пишется отдельно.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Add managed form to 1C config object", allow_abbrev=False)
parser.add_argument("-ObjectPath", required=True)
parser.add_argument("-FormName", required=True)
parser.add_argument("-Synonym", default=None)
# Пусто = основная форма вида (primary в таблице): у справочника это форма объекта,
# у регистра сведений — форма записи, у журнала — форма списка.
parser.add_argument("-Purpose", default="")
# Написания с дефисом внутри имени и с двойным дефисом: в PS-порте их принимает алиас
# set-default, здесь — перечисление опций, чтобы порты принимали ровно одно и то же.
parser.add_argument("-SetDefault", "--SetDefault", "--set-default", "-set-default",
dest="SetDefault", action="store_true")
args = ci_parse_args(parser)
object_path = args.ObjectPath
form_name = args.FormName
synonym = args.Synonym if args.Synonym is not None else form_name
purpose = args.Purpose
set_default = args.SetDefault
# --- Phase 1: Determine object type ---
# Resolve ObjectPath (directory → .xml)
if not os.path.isabs(object_path):
object_path = os.path.join(os.getcwd(), object_path)
if os.path.isdir(object_path):
dir_name = os.path.basename(object_path.rstrip("/\\"))
candidate = os.path.join(object_path, dir_name + ".xml")
sibling = os.path.join(os.path.dirname(object_path.rstrip("/\\")), dir_name + ".xml")
if os.path.isfile(candidate):
object_path = candidate
elif os.path.isfile(sibling):
object_path = sibling
if not os.path.isfile(object_path):
print(f"Файл объекта не найден: {object_path}", file=sys.stderr)
sys.exit(1)
object_xml_full = os.path.abspath(object_path)
assert_edit_allowed(object_xml_full, "editable")
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
format_version = None
with open(object_xml_full, "r", encoding="utf-8-sig") as f:
obj_head = f.read(2000)
m_ver = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', obj_head)
if m_ver:
format_version = m_ver.group(1)
if not format_version:
format_version = detect_format_version(os.path.dirname(object_xml_full))
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
# подставляют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
xmlns_decl = (
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
)
form_ns_decl = (
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
)
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
# дописать в конец нельзя.
if format_rank(format_version) >= 221:
pal = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
xmlns_decl = xmlns_decl.replace(' xmlns:style=', pal)
form_ns_decl = form_ns_decl.replace(' xmlns:style=', pal)
parser_xml = etree.XMLParser(remove_blank_text=False)
tree = etree.parse(object_xml_full, parser_xml)
root = tree.getroot()
# --- Таблица видов: вид -> допустимые назначения ---
#
# Зеркало $formKinds из PS-порта. Одна запись на вид вместо разрозненных списков
# «поддерживаемые типы», «объектные типы», «обработко-подобные» и «карта типов реквизита»:
# раньше они расходились молча, и для DocumentJournal в форму уходило `cfg:.Журнал`.
#
# main_attr — тип главного реквизита, {0} = вид, {1} = имя объекта;
# "DynamicList" — динамический список (добавляется Settings/MainTable);
# None — произвольная форма, блока Attributes нет вовсе.
# slot — свойство объекта под «основную форму»; None — такого свойства у вида нет.
# Эталон таблицы — docs/1c-form-spec.md, сверяется гардом check-form-purposes.mjs.
form_kinds = {
"Catalog": {
"Object": {"main_attr": "CatalogObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"Folder": {"main_attr": "CatalogObject.{1}", "attr_name": "Объект",
"slot": "DefaultFolderForm", "saved_data": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"FolderChoice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultFolderChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ChartOfCharacteristicTypes": {
"Object": {"main_attr": "ChartOfCharacteristicTypesObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"Folder": {"main_attr": "ChartOfCharacteristicTypesObject.{1}", "attr_name": "Объект",
"slot": "DefaultFolderForm", "saved_data": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"FolderChoice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultFolderChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"Document": {
"Object": {"main_attr": "DocumentObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ChartOfAccounts": {
"Object": {"main_attr": "ChartOfAccountsObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ChartOfCalculationTypes": {
"Object": {"main_attr": "ChartOfCalculationTypesObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ExchangePlan": {
"Object": {"main_attr": "ExchangePlanObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"BusinessProcess": {
"Object": {"main_attr": "BusinessProcessObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"Task": {
"Object": {"main_attr": "TaskObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"DataProcessor": {
"Object": {"main_attr": "DataProcessorObject.{1}", "attr_name": "Объект",
"slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"Report": {
"Object": {"main_attr": "ReportObject.{1}", "attr_name": "Объект",
"slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ExternalDataProcessor": {
"Object": {"main_attr": "ExternalDataProcessorObject.{1}", "attr_name": "Объект",
"slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ExternalReport": {
"Object": {"main_attr": "ExternalReportObject.{1}", "attr_name": "Объект",
"slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"InformationRegister": {
"Record": {"main_attr": "InformationRegisterRecordManager.{1}", "attr_name": "Запись",
"slot": "DefaultRecordForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"RecordSet": {"main_attr": "InformationRegisterRecordSet.{1}", "attr_name": "Набор",
"slot": None, "saved_data": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"AccumulationRegister": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
"RecordSet": {"main_attr": "AccumulationRegisterRecordSet.{1}", "attr_name": "Набор",
"slot": None, "saved_data": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"AccountingRegister": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
"RecordSet": {"main_attr": "AccountingRegisterRecordSet.{1}", "attr_name": "Набор",
"slot": None, "saved_data": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"CalculationRegister": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
"RecordSet": {"main_attr": "CalculationRegisterRecordSet.{1}", "attr_name": "Набор",
"slot": None, "saved_data": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"DocumentJournal": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"FilterCriterion": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"Enum": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"SettingsStorage": {
"Save": {"main_attr": None, "attr_name": None, "slot": "DefaultSaveForm", "primary": True},
"Load": {"main_attr": None, "attr_name": None, "slot": "DefaultLoadForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
}
# Виды, у которых свойство DefaultForm есть, но собственных форм не бывает.
no_own_forms = {
"Constant": "у константы нет собственных форм — используйте общую форму (CommonForm)",
}
supported_types = list(form_kinds) + list(no_own_forms)
# Отдельный факт, не выводимый из таблицы назначений: у форм обработок и отчётов в
# метаданных формы есть <ExtendedPresentation>.
processor_like_types = ["DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport"]
# Вид объекта — первый элемент-потомок MetaDataObject, а не первое совпавшее по всему
# документу имя. Поиск по документу зависел от порядка перебора видов: у бизнес-процесса
# есть свойство <Task>, и он определялся как задача, после чего имя объекта не находилось.
object_type = None
object_node = None
for child in root:
if isinstance(child.tag, str):
object_type = etree.QName(child).localname
object_node = child
break
if object_type is not None and object_type not in form_kinds and object_type not in no_own_forms:
print(f"Тип объекта '{object_type}' не поддерживается. "
f"Поддерживаемые типы: {', '.join(sorted(form_kinds))}", file=sys.stderr)
sys.exit(1)
if object_type is None:
print(f"Не удалось определить тип объекта. Поддерживаемые типы: {', '.join(sorted(form_kinds))}",
file=sys.stderr)
sys.exit(1)
if object_type in no_own_forms:
print(f"{object_type} не поддерживается: {no_own_forms[object_type]}", file=sys.stderr)
sys.exit(1)
# Object name from Properties/Name
name_node = root.find(f".//md:{object_type}/md:Properties/md:Name", NSMAP)
if name_node is None or not name_node.text:
print("Не удалось определить имя объекта из Properties/Name", file=sys.stderr)
sys.exit(1)
object_name = name_node.text
print()
print("=== form-add ===")
print()
print(f"Object: {object_type}.{object_name}")
# --- Phase 2: Validate Purpose ---
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell.
kind_purposes = form_kinds[object_type]
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
purpose_synonyms = {
"формаобъекта": "Object", "формаэлемента": "Object", "формадокумента": "Object",
"объект": "Object", "элемент": "Object", "документ": "Object", "objectform": "Object",
"формасписка": "List", "список": "List", "listform": "List",
"формавыбора": "Choice", "выбор": "Choice", "choiceform": "Choice",
"формагруппы": "Folder", "группа": "Folder", "folderform": "Folder",
"формавыборагруппы": "FolderChoice", "выборгруппы": "FolderChoice",
"folderchoiceform": "FolderChoice",
"формазаписи": "Record", "запись": "Record", "recordform": "Record",
"форманаборазаписей": "RecordSet", "наборзаписей": "RecordSet", "recordsetform": "RecordSet",
"формасохранения": "Save", "формасохранениянастроек": "Save", "сохранение": "Save",
"saveform": "Save",
"формазагрузки": "Load", "формазагрузкинастроек": "Load", "загрузка": "Load",
"loadform": "Load",
"произвольная": "Custom", "произвольнаяформа": "Custom", "customform": "Custom",
}
if purpose:
purpose_probe = re.sub(r"[\s_-]", "", purpose).lower()
is_known_purpose = any(k.lower() == purpose.lower() for k in kind_purposes)
if not is_known_purpose and purpose_probe in purpose_synonyms:
purpose = purpose_synonyms[purpose_probe]
if not purpose:
for k, rule in kind_purposes.items():
if rule.get("primary"):
purpose = k
break
purpose_key = None
for k in kind_purposes:
if k.lower() == purpose.lower():
purpose_key = k
break
if purpose_key is None:
print(f"Назначение '{purpose}' недопустимо для {object_type}. "
f"Допустимые: {', '.join(sorted(kind_purposes))}", file=sys.stderr)
sys.exit(1)
purpose = purpose_key
purpose_rule = kind_purposes[purpose]
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой main_attr —
# это произвольная форма (законное состояние), а наполовину заполненная запись означала бы,
# что таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
if purpose_rule.get("main_attr") and not purpose_rule.get("attr_name"):
print(f"Внутренняя ошибка таблицы видов: у {object_type}/{purpose} задан main_attr без attr_name",
file=sys.stderr)
sys.exit(1)
# --- Phase 3: Create files ---
object_dir = os.path.splitext(object_xml_full)[0]
forms_dir = os.path.join(object_dir, "Forms")
form_meta_path = os.path.join(forms_dir, f"{form_name}.xml")
if os.path.exists(form_meta_path):
print(f"Форма уже существует: {form_meta_path}", file=sys.stderr)
sys.exit(1)
form_dir = os.path.join(forms_dir, form_name)
form_ext_dir = os.path.join(form_dir, "Ext")
form_module_dir = os.path.join(form_ext_dir, "Form")
os.makedirs(form_module_dir, exist_ok=True)
# --- 3a. Form metadata ---
form_uuid = str(uuid.uuid4())
form_meta_xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<MetaDataObject {xmlns_decl} version="{format_version}">\n'
f'\t<Form uuid="{form_uuid}">\n'
'\t\t<Properties>\n'
f'\t\t\t<Name>{form_name}</Name>\n'
'\t\t\t<Synonym>\n'
'\t\t\t\t<v8:item>\n'
'\t\t\t\t\t<v8:lang>ru</v8:lang>\n'
f'\t\t\t\t\t<v8:content>{synonym}</v8:content>\n'
'\t\t\t\t</v8:item>\n'
'\t\t\t</Synonym>\n'
'\t\t\t<Comment/>\n'
'\t\t\t<FormType>Managed</FormType>\n'
'\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>\n'
'\t\t\t<UsePurposes>\n'
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>\n'
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>\n'
'\t\t\t</UsePurposes>\n'
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
+ ('\t\t\t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>\n'
if format_rank(format_version) >= 221 else '')
+ ('\t\t\t<ExtendedPresentation/>\n' if object_type in processor_like_types else '')
+ '\t\t</Properties>\n'
'\t</Form>\n'
'</MetaDataObject>'
)
write_xml_file(form_meta_path, form_meta_xml)
# --- 3b. Form.xml ---
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
attributes_block = ''
if purpose_rule.get("main_attr"):
main_attr_type = purpose_rule["main_attr"].format(object_type, object_name)
main_attr_name = purpose_rule["attr_name"]
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
tail_lines = ''
if main_attr_type == "DynamicList":
main_table = f"{object_type}.{object_name}"
tail_lines = ('\t\t\t<Settings xsi:type="DynamicList">\n'
f'\t\t\t\t<MainTable>{main_table}</MainTable>\n'
'\t\t\t</Settings>\n')
elif purpose_rule.get("saved_data"):
tail_lines = '\t\t\t<SavedData>true</SavedData>\n'
attributes_block = (
'\t<Attributes>\n'
f'\t\t<Attribute name="{main_attr_name}" id="1">\n'
'\t\t\t<Type>\n'
f'\t\t\t\t<v8:Type>cfg:{main_attr_type}</v8:Type>\n'
'\t\t\t</Type>\n'
'\t\t\t<MainAttribute>true</MainAttribute>\n'
f'{tail_lines}'
'\t\t</Attribute>\n'
'\t</Attributes>\n'
)
# Произвольная форма (main_attr=None) — без блока Attributes вовсе. В типовых это самая
# частая форма после объектной: 907 у справочников, 941 у документов, 3482 у отчётов.
form_xml = (
f'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<Form {form_ns_decl} version="{format_version}">\n'
'\t<AutoCommandBar name="\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c" id="-1">\n'
'\t\t<Autofill>true</Autofill>\n'
'\t</AutoCommandBar>\n'
'\t<ChildItems/>\n'
f'{attributes_block}'
'</Form>'
)
if os.path.exists(form_xml_path):
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
else:
write_xml_file(form_xml_path, form_xml)
# --- 3c. Module.bsl ---
module_path = os.path.join(form_module_dir, "Module.bsl")
module_bsl = (
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u0421\u043e\u0431\u044b\u0442\u0438\u0439\u0424\u043e\u0440\u043c\u044b\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
'\n'
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u0421\u043e\u0431\u044b\u0442\u0438\u0439\u042d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0424\u043e\u0440\u043c\u044b\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
'\n'
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u041a\u043e\u043c\u0430\u043d\u0434\u0424\u043e\u0440\u043c\u044b\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
'\n'
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u041e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0439\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
'\n'
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u0421\u043b\u0443\u0436\u0435\u0431\u043d\u044b\u0435\u041f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b\u0418\u0424\u0443\u043d\u043a\u0446\u0438\u0438\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438'
)
if os.path.exists(module_path):
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
else:
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без).
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
# --- Phase 4: Register in parent object ---
ns = "http://v8.1c.ru/8.3/MDClasses"
child_objects = root.find(f".//md:{object_type}/md:ChildObjects", NSMAP)
if child_objects is None:
print(f"Не найден элемент ChildObjects в {object_path}", file=sys.stderr)
sys.exit(1)
# Add <Form>$FormName</Form> — idempotent (do not duplicate already-registered form)
already_registered = child_objects.find(f"md:Form[.='{form_name}']", NSMAP) is not None
if not already_registered:
form_elem = etree.Element(f"{{{ns}}}Form")
form_elem.text = form_name
# Find first <Template> to insert before it
first_template = child_objects.find("md:Template", NSMAP)
# Find first <TabularSection> to insert before it (if no Template)
first_tabular = child_objects.find("md:TabularSection", NSMAP)
# Determine insertion point: before Template, before TabularSection, or at end
insert_before = None
if first_template is not None:
insert_before = first_template
elif first_tabular is not None:
insert_before = first_tabular
if insert_before is not None:
# Insert before the found element
idx = list(child_objects).index(insert_before)
child_objects.insert(idx, form_elem)
# Whitespace: form_elem gets "\n\t\t\t" as tail (indent before insert_before)
form_elem.tail = "\n\t\t\t"
else:
# Add to end of ChildObjects
children = list(child_objects)
if len(children) == 0 and (child_objects.text is None or child_objects.text.strip() == ""):
# Empty ChildObjects (self-closing)
child_objects.text = "\n\t\t\t"
child_objects.append(form_elem)
form_elem.tail = "\n\t\t"
else:
if len(children) > 0:
last_child = children[-1]
old_tail = last_child.tail
last_child.tail = "\n\t\t\t"
child_objects.append(form_elem)
form_elem.tail = old_tail if old_tail else "\n\t\t"
else:
child_objects.text = (child_objects.text or "") + "\n\t\t\t"
child_objects.append(form_elem)
form_elem.tail = "\n\t\t"
# --- SetDefault ---
is_first_form_for_purpose = False
default_value = f"{object_type}.{object_name}.Form.{form_name}"
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному purpose без
# учёта вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не
# находился, навык молча ничего не делал.
default_prop_name = purpose_rule.get("slot")
default_node = None
if default_prop_name:
default_node = root.find(f".//md:{object_type}/md:Properties/md:{default_prop_name}", NSMAP)
if default_node is not None:
is_first_form_for_purpose = not (default_node.text or "").strip()
default_updated = False
if set_default or is_first_form_for_purpose:
if default_node is not None:
default_node.text = default_value
default_updated = True
# Save with BOM
save_xml_with_bom(tree, object_xml_full)
# --- Phase 5: Output ---
obj_dir_name = os.path.dirname(object_path)
obj_base_name = os.path.splitext(os.path.basename(object_path))[0]
print("Created:")
print(f" Metadata: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}.xml")
print(f" Form: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form.xml")
print(f" Module: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form\\Module.bsl")
print()
if already_registered:
print(f"Already registered: <Form>{form_name}</Form> in ChildObjects (skipped duplicate)")
else:
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
if default_updated:
print(f"{default_prop_name}: {default_value}")
elif not default_prop_name:
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
# у платформы нет (форма набора записей, произвольная форма).
print(f"Основной не назначена: у {object_type} нет свойства для формы с назначением {purpose}")
print()
if __name__ == "__main__":
main()
+568
View File
@@ -0,0 +1,568 @@
---
name: form-compile
description: Компиляция управляемой формы 1С из JSON-определения или из метаданных объекта. Используй когда нужно создать форму с нуля по описанию элементов или сгенерировать типовую форму
argument-hint: <JsonPath> <OutputPath> | -FromObject <OutputPath>
allowed-tools:
- Bash
- Read
- Write
- Glob
---
# /form-compile — Генерация Form.xml
Два режима:
1. **JSON DSL** — из JSON-определения формы
2. **From object** (`-FromObject`) — автоматически из метаданных объекта 1С по пресету ERP
> **При проектировании формы с нуля (5+ элементов или нечёткие требования)** — вызовите `/form-patterns` для загрузки справочника. Для простых форм (1–3 поля) — не нужно.
## Параметры
| Параметр | Обязательный | Описание |
|------------|:------------:|---------------------------------|
| JsonPath | режим 1 | Путь к JSON-определению формы |
| OutputPath | да | Путь к выходному Form.xml |
| FromObject | режим 2 | Флаг (без значения) — генерация по метаданным объекта |
## Команда
```powershell
# Режим JSON DSL
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/form-compile/scripts/form-compile.ps1" -JsonPath "<json>" -OutputPath "<Form.xml>"
# Режим from-object (объект и purpose выводятся из OutputPath; Document и Catalog)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".codex/skills/form-compile/scripts/form-compile.ps1" -FromObject -OutputPath "<.../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml>"
```
## JSON DSL — справка
### Структура верхнего уровня
```json
{
"title": "Заголовок формы",
"properties": { "autoTitle": false, ... },
"events": { "OnCreateAtServer": "ПриСозданииНаСервере" },
"excludedCommands": ["Reread"],
"elements": [ ... ],
"attributes": [ ... ],
"commands": [ ... ],
"parameters": [ ... ]
}
```
- `title` — заголовок формы (multilingual). Можно указать и в `properties`, но лучше на верхнем уровне
- `properties` — свойства формы: `autoTitle`, `windowOpeningMode`, `commandBarLocation`, `saveDataInSettings`, `width`, `height` и др.
- `events` — обработчики событий формы (ключ: имя события 1С, значение: имя процедуры)
- `excludedCommands` — исключённые стандартные команды
### Элементы (ключ определяет тип)
| DSL ключ | XML элемент | Значение ключа |
|--------------|-------------------|---------------------------------------------------|
| `"group"` | UsualGroup | ориентация: `"vertical"` / `"horizontalIfPossible"` / `"alwaysHorizontal"` (поведение — отдельный ключ `behavior`) |
| `"columnGroup"` | ColumnGroup | `"horizontal"` / `"vertical"` / `"inCell"` — только внутри `columns` таблицы |
| `"input"` | InputField | имя элемента |
| `"check"` | CheckBoxField | имя |
| `"radio"` | RadioButtonField | имя |
| `"label"` | LabelDecoration | имя (текст задаётся через `title`) |
| `"labelField"` | LabelField | имя |
| `"table"` | Table | имя |
| `"pages"` | Pages | имя |
| `"page"` | Page | имя |
| `"button"` | Button | имя |
| `"picture"` | PictureDecoration | имя |
| `"picField"` | PictureField | имя |
| `"calendar"` | CalendarField | имя |
| `"cmdBar"` | CommandBar | имя |
| `"autoCmdBar"` | AutoCommandBar формы | имя — наполняет главную АКП формы (id=-1), не попадает в `<ChildItems>` |
| `"popup"` | Popup | имя |
### Общие свойства (все типы элементов)
| Ключ | Описание |
|------|----------|
| `name` | Переопределить имя (по умолчанию = значение ключа типа). Имена уникальны во всех коллекциях формы (элементы, реквизиты, команды, колонки) |
| `title` | Заголовок элемента |
| `tooltip` | Всплывающая подсказка элемента (строка или `{ru,en}`) |
| `visible: false` | Скрыть (синоним: `hidden: true`) |
| `enabled: false` | Сделать недоступным (синоним: `disabled: true`) |
| `readOnly: true` | Только чтение |
| `events: {...}` | Обработчики событий: `{ "OnChange": "ИмяОбработчика" }`. Тот же формат, что у событий формы. Значение `null` → имя обработчика сгенерируется автоматически |
### Допустимые имена событий (`events`)
Компилятор предупреждает о неизвестных событиях. Имена регистрозависимы — используйте точно как указано.
**Форма** (`events`): `OnCreateAtServer`, `OnOpen`, `BeforeClose`, `OnClose`, `NotificationProcessing`, `ChoiceProcessing`, `OnReadAtServer`, `BeforeWriteAtServer`, `OnWriteAtServer`, `AfterWriteAtServer`, `BeforeWrite`, `AfterWrite`, `FillCheckProcessingAtServer`, `BeforeLoadDataFromSettingsAtServer`, `OnLoadDataFromSettingsAtServer`, `ExternalEvent`, `Opening`
**input / picField**: `OnChange`, `StartChoice`, `ChoiceProcessing`, `AutoComplete`, `TextEditEnd`, `Clearing`, `Creating`, `EditTextChange`
**check / radio**: `OnChange`
**table**: `OnStartEdit`, `OnEditEnd`, `OnChange`, `Selection`, `ValueChoice`, `BeforeAddRow`, `BeforeDeleteRow`, `AfterDeleteRow`, `BeforeRowChange`, `BeforeEditEnd`, `OnActivateRow`, `OnActivateCell`, `Drag`, `DragStart`, `DragCheck`, `DragEnd`
**label / picture**: `Click`, `URLProcessing`
**labelField**: `OnChange`, `StartChoice`, `ChoiceProcessing`, `Click`, `URLProcessing`, `Clearing`
**button**: `Click`
**pages**: `OnCurrentPageChange`
### Поле ввода (input)
| Ключ | Описание | Пример |
|------|----------|--------|
| `path` | DataPath — привязка к данным | `"Объект.Организация"` |
| `titleLocation` | Размещение заголовка | `"none"`, `"left"`, `"right"`, `"top"`, `"bottom"`, `"auto"` |
| `multiLine: true` | Многострочное поле | текстовое поле, комментарий |
| `passwordMode: true` | Режим пароля (звёздочки) | поле ввода пароля |
| `choiceButton: true` | Кнопка выбора ("...") | ссылочное поле |
| `clearButton: true` | Кнопка очистки ("X") | |
| `spinButton: true` | Кнопка прокрутки | числовые поля |
| `dropListButton: true` | Кнопка выпадающего списка | |
| `markIncomplete: true` | Пометка незаполненного | обязательные поля |
| `skipOnInput: true` | Пропускать при обходе Tab | |
| `inputHint` | Подсказка в пустом поле | `"Введите наименование..."` |
| `width` / `height` | Размер | числа |
| `autoMaxWidth: false` | Снять авто-ограничение ширины (поле растянется) | |
| `maxWidth` / `maxHeight` | Жёсткое ограничение размера | числа; обычно вместе с `autoMaxWidth: false` |
| `horizontalStretch: true` | Растягивать по ширине | |
### Чекбокс (check)
| Ключ | Описание |
|------|----------|
| `path` | DataPath |
| `titleLocation` | Размещение заголовка |
### Поле переключателя (radio)
Радиокнопки или тумблер для выбора одного значения из списка.
| Ключ | Описание | Пример |
|------|----------|--------|
| `path` | DataPath — привязка к реквизиту | `"СпособКурса"` |
| `radioButtonType` | Вид переключателя | `"Auto"` (по умолчанию), `"RadioButtons"`, `"Tumbler"` |
| `columnsCount` | Число колонок раскладки | `1`, `2`, ... |
| `titleLocation` | Размещение заголовка | по умолчанию `"none"` |
| `choiceList` | Список вариантов: массив `{value, presentation}` | см. ниже |
`choiceList[*]`:
| Ключ | Описание |
|------|----------|
| `value` | Значение варианта. Строка/число/булево; для перечисления — `"Enum.ИмяТипа.EnumValue.ИмяЗначения"` |
| `presentation` | Текст рядом с переключателем. Строка (русский) либо объект `{ru, en, ...}` для мультиязычности |
```json
{
"radio": "СпособКурса",
"path": "Объект.СпособУстановкиКурса",
"radioButtonType": "Auto",
"choiceList": [
{ "value": "Enum.СпособыКурса.EnumValue.Авто", "presentation": { "ru": "Автоматически", "en": "Automatic" } },
{ "value": "Enum.СпособыКурса.EnumValue.Ручной", "presentation": "вручную" }
]
}
```
### Надпись-декорация (label)
| Ключ | Описание |
|------|----------|
| `title` | Текст надписи (обязательно) |
| `hyperlink: true` | Сделать ссылкой |
| `width` / `height` | Размер |
### Группа (group)
Значение ключа задаёт **ориентацию**: `"vertical"`, `"horizontalIfPossible"`, `"alwaysHorizontal"`.
| Ключ | Описание |
|------|----------|
| `behavior` | Поведение группы: `"collapsible"` (сворачиваемая) / `"popup"` (всплывающая). Опустить = обычная |
| `showTitle: true` | Показывать заголовок группы |
| `united: false` | Левый край полей ввода выравнивается только в пределах этой группы (по умолчанию `true` — сквозное выравнивание по самому длинному заголовку, в т.ч. с соседними группами) |
| `collapsed: true` | Для `behavior: "collapsible"` / `"popup"` — группа создаётся свёрнутой |
| `controlRepresentation` | Отображение управления свёрткой (`behavior: "collapsible"`): `"TitleHyperlink"` (гиперссылка заголовка, по умолчанию) / `"Picture"` (картинка) |
| `representation` | `"none"`, `"normal"`, `"weak"`, `"strong"` |
| `children: [...]` | Вложенные элементы |
### Таблица (table)
**Важно**: таблица требует связанный реквизит формы типа `ValueTable` с колонками (см. раздел "Связки").
| Ключ | Описание |
|------|----------|
| `path` | DataPath (привязка к реквизиту-таблице) |
| `columns: [...]` | Колонки — массив элементов (обычно `input`) |
| `changeRowSet: true` | Разрешить добавление/удаление строк |
| `changeRowOrder: true` | Разрешить перемещение строк |
| `height` | Высота в строках таблицы |
| `header: false` | Скрыть шапку |
| `footer: true` | Показать подвал |
| `commandBarLocation` | `"None"`, `"Top"`, `"Bottom"`, `"Auto"` |
| `searchStringLocation` | `"None"`, `"Top"`, `"Bottom"`, `"CommandBar"`, `"PullFromTop"`, `"Auto"` |
| `choiceMode: true` | Режим выбора (для форм выбора) |
| `initialTreeView` | `"ExpandTopLevel"` и др. (иерархические списки) |
| `enableDrag: true` | Разрешить перетаскивание |
| `enableStartDrag: true` | Разрешить начало перетаскивания |
| `rowPictureDataPath` | Путь к картинке строки (напр. `"Список.DefaultPicture"`) |
| `tableAutofill: false` | Управление Autofill внутреннего AutoCommandBar |
Колонки можно группировать через `columnGroup` (см. ниже).
### Группа колонок (columnGroup)
Используется только внутри `columns` таблицы. Значение ключа задаёт ориентацию: `"horizontal"`, `"vertical"`, `"inCell"` (склеивает колонки в одну ячейку шапки). Допускается вложение `columnGroup` в `columnGroup`.
| Ключ | Описание |
|------|----------|
| `name` | Имя элемента (рекомендуется задавать явно) |
| `title` | Заголовок группы |
| `showTitle: false` | Скрыть заголовок |
| `showInHeader: true/false` | Показывать ли группу в шапке таблицы |
| `width` | Ширина |
| `horizontalStretch: false` | Растягивание |
| `children: [...]` | Колонки внутри группы (`input`, `labelField`, `picField`, вложенный `columnGroup` …) |
```json
{ "table": "Список", "path": "Список", "columns": [
{ "columnGroup": "horizontal", "name": "ГруппаДата", "title": "Срок", "children": [
{ "input": "СрокИсполнения", "path": "Список.СрокИсполнения" },
{ "labelField": "Просрочено", "path": "Список.Просрочено" }
]},
{ "columnGroup": "inCell", "name": "ГруппаИсполнитель", "showInHeader": true, "children": [
{ "input": "Исполнитель", "path": "Список.Исполнитель" }
]},
{ "input": "Комментарий", "path": "Список.Комментарий" }
]}
```
### Картинка-поле (picField)
PictureField, привязанный к булеву/числу, рисует иконку только при заданном `valuesPicture`:
| Ключ | Описание |
|------|----------|
| `valuesPicture` | Ref картинки значения: `"StdPicture.Favorites"`, `"CommonPicture.X"` |
| `loadTransparent: true` | Скрыть кадр «нет значения» |
### Страницы (pages + page)
| Ключ (pages) | Описание |
|------|----------|
| `pagesRepresentation` | `"None"`, `"TabsOnTop"`, `"TabsOnBottom"` и др. |
| `children: [...]` | Массив `page` |
| Ключ (page) | Описание |
|------|----------|
| `title` | Заголовок вкладки |
| `group` | Ориентация внутри страницы |
| `children: [...]` | Содержимое страницы |
### Кнопка (button)
| Ключ | Описание |
|------|----------|
| `command` | Имя команды формы → `Form.Command.Имя` |
| `stdCommand` | Стандартная команда: `"Close"``Form.StandardCommand.Close`; с точкой: `"Товары.Add"``Form.Item.Товары.StandardCommand.Add` |
| `defaultButton: true` | Кнопка по умолчанию |
| `type` | `"usual"`, `"hyperlink"`. По умолчанию `"usual"`. Конкретный XML-вид (UsualButton/Hyperlink/CommandBarButton/CommandBarHyperlink) подставляется автоматически по контексту |
| `picture` | Картинка кнопки |
| `representation` | `"Auto"`, `"Text"`, `"Picture"`, `"PictureAndText"` |
| `locationInCommandBar` | `"Auto"`, `"InCommandBar"`, `"InAdditionalSubmenu"` |
### Командная панель (cmdBar)
Дополнительная пользовательская панель команд, размещается как обычный элемент в layout формы.
| Ключ | Описание |
|------|----------|
| `autofill: true` | Автозаполнение стандартными командами |
| `children: [...]` | Кнопки панели |
### Главная автокомандная панель формы (autoCmdBar)
Наполняет встроенную AutoCommandBar формы (id=-1) кастомными кнопками. Указывать только если нужно добавить свои кнопки на главную панель или явно управлять автозаполнением.
| Ключ | Описание |
|------|----------|
| `autofill: true/false` | Автозаполнение стандартными командами |
| `horizontalAlign` | `"Left"` / `"Center"` / `"Right"` |
| `children: [...]` | Кнопки/popup |
```json
{ "autoCmdBar": "ФормаКоманднаяПанель", "autofill": true, "children": [
{ "button": "ИзменитьВыделенные", "command": "ИзменитьВыделенные",
"locationInCommandBar": "InAdditionalSubmenu" }
]}
```
Кнопки основных действий формы и подменю размещают здесь, а не в отдельной группе на форме. Отдельной кнопкой в layout — только если она логически привязана к конкретному полю или группе.
### Выпадающее меню (popup)
| Ключ | Описание |
|------|----------|
| `title` | Заголовок подменю |
| `children: [...]` | Кнопки подменю |
Используется внутри `cmdBar` для группировки кнопок в подменю:
```json
{ "cmdBar": "Панель", "children": [
{ "popup": "Добавить", "title": "Добавить", "children": [
{ "button": "ДобавитьСтроку", "stdCommand": "Товары.Add" },
{ "button": "ДобавитьИзДокумента", "command": "ДобавитьИзДокумента", "title": "Из документа" }
]}
]}
```
### Реквизиты (attributes)
```json
{ "name": "Объект", "type": "DataProcessorObject.Загрузка", "main": true }
{ "name": "Список", "type": "DynamicList", "main": true, "settings": {
"mainTable": "Catalog.Номенклатура", "dynamicDataRead": true
}}
{ "name": "Итого", "type": "decimal(15,2)" }
{ "name": "Таблица", "type": "ValueTable", "columns": [
{ "name": "Номенклатура", "type": "CatalogRef.Номенклатура" },
{ "name": "Количество", "type": "decimal(10,3)" }
]}
```
- `savedData: true` — сохраняемые данные
- `main: true` — главный реквизит формы (например, основной `*Object.*`, `DynamicList`, `*RecordSet.*`)
### Команды (commands)
```json
{ "name": "Загрузить", "action": "ЗагрузитьОбработка", "shortcut": "Ctrl+Enter" }
```
- `title` — заголовок (если отличается от name)
- `picture` — картинка команды
### Система типов
**Примитивные:**
| DSL | XML |
|------------------------|----------------------------------------|
| `"string"` / `"string(100)"` | `xs:string` + StringQualifiers |
| `"decimal(15,2)"` | `xs:decimal` + NumberQualifiers |
| `"decimal(10,0,nonneg)"` | с AllowedSign=Nonnegative |
| `"boolean"` | `xs:boolean` |
| `"date"` / `"dateTime"` / `"time"` | `xs:dateTime` + DateFractions |
**Ссылочные и объектные (`cfg:Prefix.Name`):**
| DSL | Описание |
|-----|----------|
| `"CatalogRef.XXX"` / `"CatalogObject.XXX"` | Справочник |
| `"DocumentRef.XXX"` / `"DocumentObject.XXX"` | Документ |
| `"EnumRef.XXX"` | Перечисление |
| `"DataProcessorObject.XXX"` / `"ReportObject.XXX"` | Обработка / Отчёт |
| `"InformationRegisterRecordSet.XXX"` | Набор записей регистра сведений |
| `"AccumulationRegisterRecordSet.XXX"` | Набор записей регистра накопления |
| `"DynamicList"` | Динамический список |
Также допустимы: `ChartOfAccountsRef/Object`, `ChartOfCharacteristicTypesRef/Object`, `ChartOfCalculationTypesRef/Object`, `ExchangePlanRef/Object`, `BusinessProcessRef/Object`, `TaskRef/Object`, `AccountingRegisterRecordSet`, `InformationRegisterRecordManager`, `ConstantsSet`.
**Платформенные:**
| DSL | XML |
|-----|-----|
| `"ValueTable"` | `v8:ValueTable` |
| `"ValueTree"` | `v8:ValueTree` |
| `"ValueList"` | `v8:ValueListType` |
| `"TypeDescription"` | `v8:TypeDescription` |
| `"UUID"` | `v8:UUID` |
| `"FormattedString"` | `v8ui:FormattedString` |
| `"Picture"` / `"Color"` / `"Font"` | `v8ui:*` |
| `"DataCompositionSettings"` | `dcsset:DataCompositionSettings` |
| `"Type1 \| Type2"` | составной тип (несколько `<v8:Type>`) |
**Недопустимые типы (XDTO-ошибка при загрузке):**
> `FormDataStructure`, `FormDataCollection`, `FormDataTree` — runtime-типы 1С, не существуют в XML-схеме. Вместо них используйте `CatalogObject.XXX`, `DocumentObject.XXX`, `DataProcessorObject.XXX`, `ValueTable`, `ValueTree`.
## Связки: элемент + реквизит
Таблица и некоторые поля требуют связанный реквизит. Элемент ссылается на реквизит через `path`.
**Таблица** — элемент `table` + реквизит `ValueTable`:
```json
{
"elements": [
{ "table": "Товары", "path": "Объект.Товары", "columns": [
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура" }
]}
],
"attributes": [
{ "name": "Объект", "type": "DataProcessorObject.Загрузка", "main": true,
"columns": [
{ "name": "Товары", "type": "ValueTable", "columns": [
{ "name": "Номенклатура", "type": "CatalogRef.Номенклатура" }
]}
]
}
]
}
```
Или, если таблица привязана к реквизиту формы (не к Объект):
```json
{
"elements": [
{ "table": "ТаблицаДанных", "path": "ТаблицаДанных", "columns": [
{ "input": "Наименование", "path": "ТаблицаДанных.Наименование" }
]}
],
"attributes": [
{ "name": "ТаблицаДанных", "type": "ValueTable", "columns": [
{ "name": "Наименование", "type": "string(150)" }
]}
]
}
```
## Паттерны
### Диалог загрузки файла
```json
{
"title": "Загрузка из файла",
"properties": { "autoTitle": false },
"events": { "OnCreateAtServer": "ПриСозданииНаСервере" },
"elements": [
{ "group": "horizontal", "name": "ГруппаФайл", "children": [
{ "input": "ИмяФайла", "path": "ИмяФайла", "title": "Файл", "inputHint": "Выберите файл...", "choiceButton": true, "events": { "StartChoice": "ИмяФайлаНачалоВыбора" } },
{ "check": "ПерваяСтрокаЗаголовок", "path": "ПерваяСтрокаЗаголовок" }
]},
{ "input": "Результат", "path": "Результат", "multiLine": true, "height": 8, "readOnly": true, "title": "Лог" },
{ "autoCmdBar": "ФормаКоманднаяПанель", "children": [
{ "button": "Загрузить", "command": "Загрузить", "defaultButton": true },
{ "button": "Закрыть", "stdCommand": "Close" }
]}
],
"attributes": [
{ "name": "Объект", "type": "ExternalDataProcessorObject.ЗагрузкаИзФайла", "main": true },
{ "name": "ИмяФайла", "type": "string" },
{ "name": "ПерваяСтрокаЗаголовок", "type": "boolean" },
{ "name": "Результат", "type": "string" }
],
"commands": [
{ "name": "Загрузить", "action": "ЗагрузитьОбработка", "shortcut": "Ctrl+Enter" }
]
}
```
### Мастер (wizard) с шагами
```json
{
"title": "Мастер настройки",
"properties": { "autoTitle": false },
"elements": [
{ "pages": "СтраницыМастера", "pagesRepresentation": "None", "children": [
{ "page": "Шаг1", "title": "Параметры", "children": [
{ "input": "Параметр1", "path": "Параметр1" }
]},
{ "page": "Шаг2", "title": "Результат", "children": [
{ "input": "Итог", "path": "Итог", "readOnly": true }
]}
]},
{ "group": "horizontal", "name": "Навигация", "children": [
{ "button": "Назад", "command": "Назад", "title": "< Назад" },
{ "button": "Далее", "command": "Далее", "title": "Далее >" }
]}
],
"attributes": [
{ "name": "Объект", "type": "ExternalDataProcessorObject.Мастер", "main": true },
{ "name": "Параметр1", "type": "string" },
{ "name": "Итог", "type": "string" }
],
"commands": [
{ "name": "Назад", "action": "НазадОбработка" },
{ "name": "Далее", "action": "ДалееОбработка" }
]
}
```
### Список с фильтром и таблицей
```json
{
"title": "Просмотр данных",
"elements": [
{ "group": "horizontal", "name": "Фильтр", "children": [
{ "input": "Период", "path": "Период", "events": { "OnChange": "ПериодПриИзменении" } },
{ "input": "Организация", "path": "Организация", "events": { "OnChange": "ОрганизацияПриИзменении" } }
]},
{ "table": "Данные", "path": "Данные", "changeRowSet": true, "columns": [
{ "input": "Дата", "path": "Данные.Дата" },
{ "input": "Сумма", "path": "Данные.Сумма" },
{ "input": "Комментарий", "path": "Данные.Комментарий" }
]}
],
"attributes": [
{ "name": "Объект", "type": "ExternalDataProcessorObject.Просмотр", "main": true },
{ "name": "Период", "type": "date" },
{ "name": "Организация", "type": "string" },
{ "name": "Данные", "type": "ValueTable", "columns": [
{ "name": "Дата", "type": "date" },
{ "name": "Сумма", "type": "decimal(15,2)" },
{ "name": "Комментарий", "type": "string(200)" }
]}
]
}
```
## Продвинутые конструкции (по необходимости)
Описанного выше хватает для большинства форм. Под конкретную задачу подгрузите файл из `references/`:
- `dynamic-list.md` — форма списка: источник, отбор, сортировка, группировки, параметры запроса
- `appearance.md` — условное и статическое оформление элементов (цвета/шрифты/рамки)
- `choice-params.md` — параметры и связи выбора у полей ввода
- `command-interface.md` — командный интерфейс формы
- `roles-access.md` — пользовательская видимость и доступ по ролям
- `companion-panels.md` — контент расширенной подсказки и контекстного меню
- `special-fields.md` — поля документа/датчика (HTML, текст, индикатор, ползунок)
- `charts.md` — диаграммы и планировщик
- `report-form.md` — свойства формы отчёта
- `type-system-advanced.md` — наборы и составные типы
- `table-advanced.md` — расширенные свойства таблиц
- `layout-advanced.md` — тонкая компоновка и геометрия
## Автогенерация
- **Companion-элементы**: ContextMenu, ExtendedTooltip и др. создаются автоматически
- **Namespace**: все 17 namespace-деклараций
- **ID**: последовательная нумерация, AutoCommandBar = id="-1"
- **Unknown keys**: выводится предупреждение о нераспознанных ключах
## Workflow
1. **Каркас**: `/form-add` создаёт метаданные формы (`ФормаСписка.xml`), `Module.bsl` и регистрирует форму у объекта.
2. **Компиляция**: `/form-compile` наполняет `Form.xml` элементами.
3. **Проверка**: `/form-validate`, `/form-info`.
## Верификация
```
/form-validate <OutputPath> — проверка корректности XML
/form-info <OutputPath> — визуальная сводка структуры
```
## Особенности для внешних обработок (EPF)
- **Тип главного реквизита**: `ExternalDataProcessorObject.ИмяОбработки` (не `DataProcessorObject`)
- **DataPath**: используйте реквизиты формы (`ИмяРеквизита`), а не `Объект.ИмяРеквизита` — у внешних обработок нет реквизитов объекта в метаданных
- **Ссылочные типы**: `CatalogRef.XXX`, `DocumentRef.XXX` допустимы в XML, но для сборки EPF потребуется база с целевой конфигурацией (см. `/epf-build`)
@@ -0,0 +1,126 @@
# Form Presets
Пресеты управляют раскладкой форм, генерируемых в режиме `--from-object`.
## Как работает
Цепочка merge (каждый следующий уровень перезаписывает предыдущий через deep merge):
1. **Hardcoded defaults** -- встроены в скрипт, ориентированы на ERP
2. **Built-in preset** -- файл из этой папки (`erp-standard.json` по умолчанию)
3. **Project-level preset** -- файл `presets/skills/form/<name>.json`, поиск вверх от OutputPath
Имя пресета задаётся параметром `--preset` (по умолчанию `erp-standard`).
## Project-level пресет
Чтобы переопределить стандартный пресет в своём проекте, создайте файл:
```
<project-root>/presets/skills/form/erp-standard.json
```
Скрипт ищет этот файл, поднимаясь от OutputPath к корню. Первый найденный файл применяется поверх built-in через deep merge -- не нужно копировать весь пресет, достаточно указать только переопределяемые ключи.
## Секции
Ключи верхнего уровня в JSON -- секции вида `{тип}.{назначение}`:
| Секция | Тип объекта | Назначение формы |
|--------|-------------|------------------|
| `document.item` | Document | Форма документа |
| `document.list` | Document | Форма списка |
| `document.choice` | Document | Форма выбора |
| `catalog.item` | Catalog | Форма элемента |
| `catalog.folder` | Catalog | Форма группы |
| `catalog.list` | Catalog | Форма списка |
| `catalog.choice` | Catalog | Форма выбора |
| `informationRegister.record` | InformationRegister | Форма записи |
| `informationRegister.list` | InformationRegister | Форма списка |
| `accumulationRegister.list` | AccumulationRegister | Форма списка |
| `chartOfCharacteristicTypes.*` | ChartOfCharacteristicTypes | item/folder/list/choice |
| `exchangePlan.*` | ExchangePlan | item/list/choice |
| `chartOfAccounts.*` | ChartOfAccounts | item/folder/list/choice |
### basedOn
Секция может наследовать от другой:
```json
{
"document.choice": {
"basedOn": "document.list",
"properties": { "windowOpeningMode": "LockOwnerWindow" }
}
}
```
## Ключи секций
### Форма объекта (Item/Record)
| Ключ | Описание | Допустимые значения |
|------|----------|---------------------|
| `header.position` | Где размещать шапку | `"insidePage"` -- на первой странице, `"abovePages"` -- над страницами |
| `header.layout` | Колонки шапки | `"1col"`, `"2col"` |
| `header.distribute` | Распределение в 2 колонках | `"even"`, `"left"`, `"right"` |
| `header.dateTitle` | Заголовок даты (Document) | строка, напр. `"от"` |
| `footer.fields` | Поля в подвале | массив имён реквизитов, напр. `["Комментарий"]` |
| `footer.position` | Где размещать подвал | `"insidePage"`, `"belowPages"`, `"none"` |
| `tabularSections.container` | Контейнер табчастей | `"pages"` -- на вкладках, `"inline"` -- в корне, `"single-no-pages"` -- одна ТЧ без страниц |
| `tabularSections.exclude` | Исключить табчасти | массив имён, напр. `["ДополнительныеРеквизиты"]` |
| `tabularSections.lineNumber` | Колонка НомерСтроки | `true` / `false` |
| `additional.position` | Блок доп. реквизитов | `"page"` -- отдельная вкладка, `"below"` -- под табчастями, `"none"` -- не создавать |
| `additional.layout` | Колонки доп. блока | `"1col"`, `"2col"` |
| `additional.bspGroup` | Группа ДополнительныеРеквизиты | `true` / `false` |
| `codeDescription.layout` | Код + Наименование | `"horizontal"`, `"vertical"` |
| `codeDescription.order` | Порядок Код/Наименование | `"descriptionFirst"`, `"codeFirst"` |
| `parent.title` | Заголовок поля Родитель | строка, напр. `"Входит в группу"` |
| `parent.position` | Позиция поля Родитель | `"beforeCodeDescription"`, `"afterCodeDescription"`, `"inHeader"` |
| `owner.readOnly` | Владелец только для чтения | `true` / `false` |
| `owner.position` | Позиция поля Владелец | `"first"` |
| `fieldDefaults.ref.choiceButton` | Кнопка выбора для ссылок | `true` / `false` |
| `fieldDefaults.boolean.element` | Элемент для Boolean | `"check"` (флажок) |
| `commandBar` | Командная панель формы | `"auto"`, `"none"` |
| `properties` | Свойства формы | объект: `autoTitle`, `windowOpeningMode` и др. |
### Форма списка (List/Choice)
| Ключ | Описание | Допустимые значения |
|------|----------|---------------------|
| `columns` | Какие колонки показывать | `"all"` -- все реквизиты, или массив имён |
| `columnType` | Тип элемента колонки | `"labelField"`, `"input"` |
| `hiddenRef` | Скрытая колонка Ref | `true` / `false` |
| `tableCommandBar` | Командная панель таблицы | `"auto"`, `"none"` |
| `commandBar` | Командная панель формы | `"auto"`, `"none"` |
| `choiceMode` | Режим выбора (ChoiceForm) | `true` / `false` |
| `properties` | Свойства формы | объект: `windowOpeningMode` и др. |
## Пример project-level пресета
```json
{
"name": "my-project",
"description": "Стиль форм нашего проекта",
"document.item": {
"header": {
"layout": "1col"
},
"tabularSections": {
"exclude": ["ДополнительныеРеквизиты", "СведенияОСертификатах"]
},
"additional": {
"position": "none"
}
},
"catalog.item": {
"codeDescription": {
"order": "codeFirst"
}
}
}
```
Этот файл переопределяет только указанные ключи -- остальное наследуется из built-in пресета.
@@ -0,0 +1,68 @@
{
"name": "erp-standard",
"description": "ERP 8.3.24 standard form layout",
"document.item": {
"header": {
"position": "insidePage",
"layout": "2col",
"distribute": "even",
"dateTitle": "от"
},
"footer": {
"fields": ["Комментарий"],
"position": "insidePage"
},
"tabularSections": {
"container": "pages",
"exclude": ["ДополнительныеРеквизиты"],
"lineNumber": true
},
"additional": {
"position": "page",
"layout": "2col",
"bspGroup": true
},
"properties": {
"autoTitle": false
}
},
"catalog.item": {
"codeDescription": {
"layout": "horizontal",
"order": "descriptionFirst"
},
"parent": {
"title": "Входит в группу",
"position": "afterCodeDescription"
},
"tabularSections": {
"exclude": ["ДополнительныеРеквизиты", "Представления"]
}
},
"informationRegister.record": {
"properties": {
"windowOpeningMode": "LockOwnerWindow"
}
},
"informationRegister.list": {},
"accumulationRegister.list": {},
"chartOfCharacteristicTypes.item": {
"basedOn": "catalog.item"
},
"exchangePlan.item": {
"basedOn": "catalog.item"
},
"chartOfAccounts.item": {
"parent": {
"title": "Подчинен счету"
}
}
}
@@ -0,0 +1,123 @@
# Оформление
Два независимых механизма: **оформление элемента** (постоянные цвета/шрифт/граница на конкретном элементе) и **условное оформление формы** (`conditionalAppearance` — правила, применяемые при выполнении условия).
## Оформление элемента (цвета / шрифты / граница)
Свойства задаются прямо на элементе. Применимо к полям (`input`/`check`/`radio`/`labelField`/`picField`/`calendar`), декорациям (`label`/`picture`), кнопкам (`button`), группам (`group`/`columnGroup`), страницам (`page`/`pages`), попапам (`popup`) и таблицам (`table`). Каждое свойство необязательно.
| Ключ | Что задаёт |
|------|-----------|
| `textColor` | Цвет текста |
| `backColor` | Цвет фона |
| `borderColor` | Цвет рамки |
| `font` | Шрифт |
| `border` | Граница |
| `titleTextColor` / `titleBackColor` / `titleFont` | Цвет текста / цвет фона / шрифт заголовка колонки (`labelField`, колонки таблицы); у `page`/`pages`/`popup``titleTextColor`/`titleFont` заголовка страницы/попапа |
| `footerTextColor` / `footerBackColor` / `footerFont` | Цвет текста / цвет фона / шрифт подвала колонки |
Те же свойства доступны и через словарь `appearance` элемента — под русскими именами параметров платформы: `ЦветТекста`, `ЦветФона`, `ЦветРамки`, `Шрифт`, `Граница`, `ЦветТекстаЗаголовка`, `ЦветФонаЗаголовка`, `ШрифтЗаголовка`, `ЦветТекстаПодвала`, `ЦветФонаПодвала`, `ШрифтПодвала`. Это та же запись, что и в правилах условного оформления (ниже) и в `appearance` поля дин-списка.
### Цвет
Строка в одной из форм:
| Форма | Значение |
|-------|----------|
| `web:Имя` | Цвет из web-палитры, напр. `web:Red`, `web:FireBrick`, `web:HoneyDew` |
| `win:Имя` | Системный цвет Windows, напр. `win:MenuBar`, `win:ButtonText`, `win:DisabledText` |
| `style:ИмяСтиля` | Ссылка на элемент стиля конфигурации/платформы, напр. `style:FormBackColor`, `style:BorderColor` |
| `#RRGGBB` | RGB-hex, напр. `#FF0000` |
Имя должно существовать в своей палитре (несуществующий web-/win-цвет или ссылка на отсутствующий `style:`-элемент — ошибка загрузки формы).
### Шрифт (`font` / `titleFont` / `footerFont`)
- Строка `"style:ИмяСтиля"` — шрифт из элемента стиля. Минимальная форма.
- Объект — задаются только нужные атрибуты:
| Ключ | Назначение |
|------|-----------|
| `ref` | Ссылка на стиль (`"style:X"`) или системный шрифт (`"sys:…"`) |
| `faceName` | Имя гарнитуры (для собственного шрифта) |
| `height` | Размер |
| `bold` / `italic` / `underline` / `strikeout` | `true`/`false` — начертание |
| `scale` | Масштаб, % |
| `kind` | `Absolute` (собственный шрифт — с `faceName`+`height`) / `WindowsFont` (системный — с `ref:"sys:…"`) |
```json
{ "label": "Внимание!", "textColor": "web:FireBrick",
"font": { "faceName": "Arial", "height": 12, "bold": true, "kind": "Absolute", "scale": 100 } }
```
### Граница (`border`)
- Строка `"style:ИмяСтиля"` (или объект `{ "ref": "style:X" }`) — граница из стиля.
- Объект `{ "width": N, "style": "..." }` — собственная граница. `style` — один из: `Single`, `Double`, `Underline`, `DoubleUnderline`, `Overline`, `Embossed`, `Indented`, `WithoutBorder`.
```json
{ "input": "Цена", "path": "Объект.Цена", "textColor": "#FF0000",
"borderColor": "style:BorderColor", "border": { "width": 1, "style": "Single" } }
{ "labelField": "Код", "titleTextColor": "web:HoneyDew", "border": "style:ControlBorder" }
```
## Условное оформление формы (`conditionalAppearance`)
Форменный ключ верхнего уровня — массив правил. Каждое правило применяет оформление к перечисленным полям, когда выполняется его условие.
```json
"conditionalAppearance": [
{ "selection": ["ОбычноеПоле"], "filter": ["ЧисловоеПоле > 100"],
"appearance": { "ЦветФона": "style:FormBackColor" },
"presentation": { "ru": "Подсветка", "en": "Highlight" } }
]
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `selection` | array | Имена форматируемых полей формы |
| `filter` | array | Условие применения (грамматика — ниже) |
| `appearance` | object | Словарь «параметр платформы: значение» |
| `presentation` | string / object | Подпись правила в списке настроек |
| `use` | bool | `false` — правило отключено |
| `viewMode` | string | Режим отображения настройки |
| `userSettingID` | string | Идентификатор пользовательской настройки; `"auto"` — сгенерировать |
### filter
Та же грамматика, что в отборе списка — shorthand `"Поле оператор значение @флаги"` или объект:
```json
"filter": [
"Статус = 3",
{ "field": "Сумма", "op": ">=", "value": 1000 },
{ "group": "Or", "items": [ "Просрочено = true", "Заблокирован = true" ] }
]
```
- **Операторы:** `=` `<>` `>` `>=` `<` `<=`, `in` / `notIn`, `inHierarchy`, `contains` / `notContains`, `beginsWith` / `notBeginsWith`, `like` / `notLike` (`%`-шаблон), `filled` / `notFilled`.
- **Флаги:** `@off` (отключён), `@user`, `@quickAccess`; `_` = пустое значение.
- **Группа:** `{ "group": "And"|"Or"|"Not", "items": [...], "use"? }`.
- **Дата-значение:** ISO-дата `"2024-01-01T00:00:00"` — фиксированная дата; именованный относительный период — строкой `"BeginningOfThisWeek"` с `"valueType": "v8:StandardBeginningDate"` (варианты `BeginningOfThisDay`/`BeginningOfThisWeek`/`BeginningOfThisMonth`/`BeginningOfThisYear`/…).
### appearance
Словарь «параметр платформы: значение». Имена параметров — русские: `ЦветТекста`, `ЦветФона`, `Шрифт`, `Граница`, `Текст`, `Заголовок`, `Формат`, `ВидимостьЭлемента`, `Доступность` и другие параметры оформления компоновки.
Значения:
- **Цвет** (`ЦветТекста`/`ЦветФона`/…) и **шрифт** (`Шрифт`) — те же формы, что в оформлении элемента выше (`web:`/`win:`/`style:`/`#RRGGBB`; шрифт — строка `"style:X"` или объект).
- **Текстовые параметры** (`Текст`/`Заголовок`/`Формат`) — по форме значения:
- голая строка → нелокализованный литерал (`""` → пустое значение);
- объект `{ "ru": "...", "en": "..." }` → локализуемая строка;
- объект `{ "field": "путь" }` → ссылка на поле компоновки.
```json
"conditionalAppearance": [
{ "selection": ["Остаток"], "filter": ["Остаток < 0"],
"appearance": { "ЦветТекста": "web:Red", "Шрифт": { "bold": true } } },
{ "selection": ["Комментарий"], "filter": ["Комментарий notFilled"],
"appearance": { "Текст": { "ru": "— нет данных —" }, "ЦветТекста": "win:DisabledText" } }
]
```
> Условное оформление **самого дин-списка** задаётся не здесь, а в `settings.conditionalAppearance` реквизита-списка — см. `references/dynamic-list.md`.
@@ -0,0 +1,143 @@
# Диаграммы, диаграмма Ганта, планировщик
Поле-диаграмма (`chart` / `ganttChart`), поле-планировщик (`planner`) и дендрограмма выводят значение из реквизита соответствующего типа. Конструкция всегда двойная:
1. **Реквизит** chart/planner-типа (несёт данные и, при необходимости, design-time конфиг).
2. **Элемент** формы, привязанный к реквизиту через `path`.
Минимум — реквизит нужного типа плюс элемент с тем же `path`:
```json
"attributes": [ { "name": "Диаграмма", "type": "d5p1:Chart" } ],
"items": [ { "chart": "ПолеДиаграммы", "path": "Диаграмма" } ]
```
Реквизит, заполняемый в коде (без встроенной настройки), достаточно объявить типом — элемент привязывается и работает.
## Типы реквизита и элемента
| Элемент | Ключ типа | Тип реквизита | Что несёт элемент дополнительно |
|---------|-----------|---------------|---------------------------------|
| Диаграмма | `chart` | `d5p1:Chart` | — |
| Диаграмма Ганта | `ganttChart` | `d5p1:GanttChart` | `ganttTable` — вложенная таблица (см. ниже) |
| Планировщик | `planner` | `pl:Planner` | — |
| График. схема | `graphicalSchema` | `d5p1:FlowchartContextType` | `edit`, `warningOnEditRepresentation` |
| Период | `periodField` | `v8:StandardPeriod` | — |
| Дендрограмма | `dendrogram` | — | — |
Имя элемента — значение ключа (`"chart": "ПолеДиаграммы"`); `path` — короткое имя реквизита.
### Элемент диаграммы Ганта (`ganttTable`)
У поля Ганта внутри лежит полноценная таблица — задаётся ключом `ganttTable` (та же грамматика, что у обычной `table`):
```json
{ "ganttChart": "Ганта", "path": "Ганта",
"ganttTable": { "table": "ТаблицаГанта", "path": "Ганта", "height": 3 } }
```
## Design-time конфиг диаграммы (`chart`)
Реквизит типа `d5p1:Chart` / `d5p1:GanttChart` может нести встроенную настройку диаграммы — объект `chart` на реквизите. Платформа всегда пишет полный набор свойств (~127: тип, серии, легенда, заголовок, шкалы, цвета, шрифты, оси), поэтому **авторинг с нуля непрактичен** — возьмите рабочую диаграмму за основу и правьте смысловое ядро.
Ключи `chart` = канонические имена свойств диаграммы; задавайте только те, что меняете:
```json
{ "name": "Диаграмма", "type": "d5p1:Chart", "chart": {
"chartType": "Line",
"isSeriesDesign": true, "realSeriesCount": "2",
"realSeriesData": [
{ "id": "1", "color": "auto", "line": {"width":2,"gap":false,"style":"Solid"},
"marker": "Auto", "text": "Серия 1", "strIsChanged": false, "isExpand": false,
"isIndicator": false, "colorPriority": false }
],
"isShowTitle": true, "title": "Продажи",
"isShowLegend": true, "legendPlacement": "Bottom",
"paletteKind": "Auto"
} }
```
Смысловое ядро для правки:
| Ключ | Назначение |
|------|------------|
| `chartType` | Тип: `Line` / `Pie` / `Bar` / `Histogram` / `Column` / `Area` / … |
| `realSeriesData` | Массив серий — объекты `{ id, text, color, line, marker, … }` |
| `isShowTitle` + `title` | Показ и текст заголовка |
| `isShowLegend` + `legendPlacement` | Показ и расположение легенды (`Bottom` / `Right` / …) |
| `paletteKind` | Палитра (`Auto` / …) |
| `bkgColor` / `labelsColor` / … | Базовые цвета |
Формы значений внутри `chart`:
- **Цвета** — verbatim: `auto`, `style:ИмяСтиля`, `web:Red`, `#hex`.
- **`line`** — `{ width, gap, style }` (стиль линии: `Solid` / …).
- **`border`** — `{ width, style }`.
- **`font`** — `{ kind: "AutoFont" }` либо атрибуты шрифта.
- **Локализуемые строки** (`title`, `vsFormat`, `lbFormat`, `labelFormat`, серия `text`, …) — голая строка либо `{ "ru": "…", "en": "…" }`.
- **Области** (`elementsChart` / `elementsLegend` / `elementsTitle`) — `{ left, right, top, bottom }`.
- **Серии** (`realSeriesData` / `realExSeriesData`) — массивы объектов.
Любое из ~127 свойств переопределяется по каноническому имени; остальное оставляйте дефолтным (не указывайте — берётся из основы).
### Диаграмма Ганта (`d5p1:GanttChart`)
Реквизит типа `d5p1:GanttChart` использует **тот же** ключ `chart`. Внутри — вложенный полный `chart`-блок плюс гант-специфика (`points` / `series` / `timeScale` / `drawEmpty` / …). Так же берите рабочую диаграмму Ганта за основу.
> **Ограничение.** Диаграммы (Chart/Gantt) с заполненными **точками/осями** (`realPointData` / `realDataItems`, заполненные `valuesAxis` / `pointsAxis`) генерик-движком не поддержаны — это редкий вариант. Частые дашборд-диаграммы и диаграммы Ганта (серии / легенда / оформление / шкалы) поддержаны полностью.
## Design-time конфиг планировщика (`planner`)
Реквизит типа `pl:Planner` несёт встроенную настройку планировщика — объект `planner`. Компилятор подставляет умолчания для пропущенных ключей, поэтому авторинг может быть кратким:
```json
{ "name": "Планировщик", "type": "pl:Planner", "planner": {
"items": [
{ "text": "Встреча", "begin": "2026-06-09T01:00:00", "end": "2026-06-09T04:00:00",
"borderColor": "auto", "backColor": "auto", "deleted": false, "editMode": "EnableEdit" }
],
"period": { "begin": "2026-06-09T00:00:00", "end": "2026-06-09T23:59:59" },
"displayCurrentDate": true, "itemsTimeRepresentation": "BeginTime",
"timeScale": { "placement": "Left", "levels": [ { "measure": "Hour", "interval": 1 } ] }
} }
```
Минимум — один `item`:
```json
"planner": { "items": [ { "text": "Встреча", "begin": "2026-06-09T01:00:00", "end": "2026-06-09T04:00:00" } ] }
```
| Ключ `planner` | Тип | Назначение |
|----------------|-----|------------|
| `items` | array | Элементы расписания. Поля элемента: `text`, `tooltip`, `begin`, `end`, `value`, `borderColor`, `backColor`, `textColor`, `font`, `border`, `replacementDate`, `deleted` (bool), `editMode` (`EnableEdit` / …), `id` (необязательно — авто-GUID), `textFormatted` |
| `dimensions` | array | Измерения (разрезы) планировщика. Поля: `value` (объект разреза — ссылка `Enum.X.EnumValue.Y` / `Справочник.X`; опустить → пусто), `text` (заголовок), `borderColor`, `backColor`, `textColor`, `font`, `textFormatted`, `elements`. `elements` — элементы измерения, рекурсивны (могут нести вложенные `elements`): `value`, `text`, цвета, `font`, `showOnlySubordinatesAreas` (bool), `textFormatted` |
| `period` | object | Отображаемый период `{ begin, end }` (необязательно) |
| `timeScale` | object | Шкала времени (см. ниже) |
| `borderColor` / `backColor` / `textColor` / `lineColor` | color | Цвета (умолч. `auto`) |
| `font` | font | Шрифт (умолч. `{ kind: "AutoFont" }`) |
| `border` | border | Рамка `{ width, style }` |
| `beginOfRepresentationPeriod` / `endOfRepresentationPeriod` | dateTime | Период представления |
| `displayCurrentDate` / `displayWrapHeaders` / `displayTimeScaleWrapHeaders` / `alignElementsOfTimeScale` | bool | Флаги отображения |
| `timeScaleWrapHeadersFormat` | ML | Формат перенесённых заголовков шкалы |
| `timeScaleWrapBeginIndent` / `timeScaleWrapEndIndent` | int | Отступы переноса шкалы |
| `periodicVariantUnit` / `periodicVariantRepetition` | value / int | Единица и кратность периодического варианта |
| `itemsTimeRepresentation` | value | Представление времени элементов (`BeginTime` / …) |
| `itemsBehaviorWhenSpaceInsufficient` / `newItemsTextType` / `fixDimensionsHeader` / `fixTimeScaleHeader` | value | Поведение элементов и заголовков |
| `autoMinColumnWidth` / `autoMinRowHeight` | bool | Авто-минимум размеров |
| `minColumnWidth` / `minRowHeight` | int | Минимальные размеры |
Шкала времени (`timeScale`):
```json
"timeScale": {
"placement": "Left",
"levels": [ { "measure": "Hour", "interval": 1 } ]
}
```
Ключи: `placement`, `levels` (массив уровней), `transparent`, `backColor`, `textColor`, `currentLevel`. Уровень: `measure` (`Hour` / `Day` / …), `interval`, `show`, `line` (`{ width, gap, style }`), `scaleColor`, `dayFormatRule`, `format` (ML), `labels` (`{ ticks }`), `backColor`, `textColor`, `showPereodicalLabels`.
Формы значений в `planner` те же, что у диаграммы: цвета verbatim (`auto` / `style:X` / `web:Red` / `#hex`); шрифт `{ kind: "AutoFont" }` либо ref-строка; граница `{ width, style }`; ML-форматы — строка или `{ "ru": …, "en": … }`.
> **Ограничение.** Привязка элемента расписания к элементам измерений (`item.dimensionValues`) пока всегда пустая. Сами измерения (`dimensions`) задавать можно.
@@ -0,0 +1,73 @@
# Параметры выбора и связь по типу
Свойства поля ввода (`input`), управляющие выбором значения: чем ограничен список выбора и каким будет тип значения. Имена параметров — строки 1С как есть (`"Отбор.Х"`).
```json
{ "input": "Контрагент", "path": "Объект.Контрагент",
"choiceParameters": [
{ "name": "Отбор.Активный", "value": true },
{ "name": "Отбор.ВидПродукции", "value": ["Enum.Виды.Агрохимикат", "Enum.Виды.Пестицид"] }
],
"choiceParameterLinks": [
{ "name": "Отбор.Организация", "dataPath": "Объект.Организация" },
{ "name": "Отбор.Тип", "dataPath": "Объект.Тип", "valueChange": "DontChange" }
],
"typeLink": { "dataPath": "Объект.ЗначениеДата", "linkItem": 0 }
}
```
## Параметры выбора (`choiceParameters`)
Фиксированные значения параметров выбора, отбирающие список значений независимо от данных формы. Массив объектов `{ name, value }`:
- `name` — имя параметра (`"Отбор.Активный"`).
- `value` — значение. Допустимы: bool, число, строка, ISO-дата (`"2020-01-01T00:00:00"`), ссылка-путь (`Enum.X.Y`, `Catalog.X`). **Массив** значений задаёт фиксированный массив.
Короткая форма — строки `"name=value"`; значение с запятыми становится массивом, `true`/`false` → bool, число → число, остальное → строка/ссылка:
```json
"choiceParameters": [
"Отбор.Активный=true",
"Отбор.ВидПродукции=Enum.Виды.Агрохимикат, Enum.Виды.Пестицид"
]
```
## Связи параметров выбора (`choiceParameterLinks`)
Параметры выбора, значение которых берётся из **другого поля формы** (а не задано фиксированно). Типовой случай — отбор списка договоров по выбранному контрагенту. Массив объектов `{ name, dataPath, valueChange? }`:
- `name` — имя параметра выбора.
- `dataPath` — путь к полю формы, чьё значение подставляется в параметр.
- `valueChange` — что делать с уже выбранным значением при смене источника: `Clear` (очистить, необязательно — поведение по умолчанию) / `DontChange` (не менять).
```json
{ "input": "Договор", "path": "Объект.Договор",
"choiceParameterLinks": [
{ "name": "Отбор.Владелец", "dataPath": "Объект.Контрагент" }
]
}
```
Короткая форма — строки `"name=dataPath"`, опциональный хвост `:Clear` / `:DontChange`:
```json
"choiceParameterLinks": [ "Отбор.Организация=Объект.Организация", "Отбор.Тип=Объект.Тип:DontChange" ]
```
## Связь по типу (`typeLink`)
Тип значения поля определяется другим полем формы (напр. поле «Значение» субконто, тип которого задаётся выбранным видом субконто). Объект `{ dataPath, linkItem }`:
- `dataPath` — путь к полю, задающему тип.
- `linkItem` — индекс элемента связи (необязательно, по умолчанию `0`).
```json
"typeLink": { "dataPath": "Объект.ВидСубконто", "linkItem": 0 }
```
Короткая форма — строка `"dataPath"` либо `"dataPath#linkItem"`:
```json
"typeLink": "Объект.ВидСубконто"
"typeLink": "Объект.ВидСубконто#1"
```
@@ -0,0 +1,86 @@
# Командный интерфейс формы
Форменный ключ `commandInterface` управляет расстановкой команд по двум панелям формы:
- `commandBar` — командная панель формы;
- `navigationPanel` — панель навигации.
Указывать нужно **только команды, у которых меняется расстановка по умолчанию** (видимость, группа, порядок). Команды, которые платформа размещает автоматически и без изменений, в блок не включают.
```json
"commandInterface": {
"commandBar": [
{ "command": "Form.Command.Печать", "defaultVisible": false, "group": "FormCommandBarImportant",
"visible": { "common": false, "roles": { "Бухгалтер": true } } },
"CommonCommand.История"
],
"navigationPanel": {
"important": [ { "command": "CommonCommand.СвязанныеДокументы", "defaultVisible": false, "visible": false } ],
"seeAlso": [ { "command": "CommonCommand.Заметки", "defaultVisible": false, "visible": false } ]
}
}
```
## Элемент-команда
Каждый элемент панели — объект, либо строка-shorthand (= голый `command` со всеми остальными свойствами по умолчанию):
```json
"CommonCommand.История"
```
| Ключ | Тип | Назначение |
|------|-----|-----------|
| `command` | string | Ссылка на команду дословно: `CommonCommand.X`, `Document.X.StandardCommand.Y`, `Form.Command.X`, `Form.StandardCommand.OK`, `"0"` (пустой / разделитель) |
| `type` | string | `Auto` (по умолчанию, необязательно) или `Added` |
| `defaultVisible` | bool | Видимость по умолчанию. На практике задаётся только `false` — чтобы скрыть команду, которая иначе видна |
| `visible` | bool / object | Видимость с исключениями по ролям: `bool` либо `{ "common": bool, "roles": { "Имя": bool } }` |
| `group` | string | Группа размещения дословно: предопределённая (`FormCommandBarImportant`, `FormNavigationPanelGoTo`, …), именованная (`CommandGroup.X`) или GUID-группа расширения |
| `index` | int | Порядок команды внутри группы |
| `attribute` | string | Путь реквизита для элемента панели навигации |
## Две формы записи панели
Панель можно описать **плоским массивом** или **деревом по группам** — выбирайте любую.
**Плоский массив** — каждый элемент при необходимости несёт собственный `group`:
```json
"commandBar": [
{ "command": "Form.Command.Печать", "group": "FormCommandBarImportant", "defaultVisible": false },
{ "command": "CommonCommand.История", "group": "FormCommandBarImportant", "index": 1 }
]
```
**Дерево** — объект `{ группа: [команды] }`; группа берётся из ключа, элементы её не повторяют:
```json
"navigationPanel": {
"important": [ "CommonCommand.СвязанныеДокументы" ],
"goTo": [ { "command": "Document.Заказ.StandardCommand.Movements", "defaultVisible": false, "visible": false } ],
"seeAlso": [ "CommonCommand.Заметки" ]
}
```
Ключи-группы дерева зависят от панели:
- `navigationPanel`: `important`, `goTo`, `seeAlso` (можно по-русски — `важное`, `перейти`, `смТакже`);
- `commandBar`: `important`, `createBasedOn`;
- любой другой ключ (`CommandGroup.X` или GUID) подставляется в группу дословно.
## Скрыть видимую команду
Самый частый случай — убрать команду, которую платформа показывает по умолчанию:
```json
"commandBar": [
{ "command": "Form.Command.Печать", "defaultVisible": false, "visible": false }
]
```
Показать команду только некоторым ролям:
```json
{ "command": "Form.Command.Печать", "defaultVisible": false,
"visible": { "common": false, "roles": { "Бухгалтер": true } } }
```

Some files were not shown because too many files have changed in this diff Show More