Compare commits

..
Author SHA1 Message Date
github-actions[bot] 380c69423a Auto-build: roo (powershell) from 057c104 2026-09-04 16:49:25 +00:00
4834 changed files with 13915 additions and 288068 deletions
-32
View File
@@ -1,32 +0,0 @@
{
"name": "cc-1c-skills",
"interface": {
"displayName": "1C Skills"
},
"plugins": [
{
"name": "1c-skills",
"source": {
"source": "url",
"url": "https://github.com/Nikolay-Shirokov/cc-1c-skills.git",
"ref": "port-codex"
},
"policy": {
"installation": "AVAILABLE"
},
"category": "Development"
},
{
"name": "1c-skills-py",
"source": {
"source": "url",
"url": "https://github.com/Nikolay-Shirokov/cc-1c-skills.git",
"ref": "port-codex-py"
},
"policy": {
"installation": "AVAILABLE"
},
"category": "Development"
}
]
}
-24
View File
@@ -1,24 +0,0 @@
{
"$schema": "https://json.schemastore.org/claude-code-marketplace-manifest.json",
"name": "cc-1c-skills",
"description": "Маркетплейс навыков для разработки на платформе 1С:Предприятие",
"owner": {
"name": "Nikolay Shirokov"
},
"plugins": [
{
"name": "1c-skills",
"source": "./",
"description": "[PowerShell] Навыки для разработки на 1С:Предприятие 8.3 — абстракции над XML-форматами и CLI конфигуратора, плюс глаза и руки для тестирования через веб-клиент."
},
{
"name": "1c-skills-py",
"source": {
"source": "github",
"repo": "Nikolay-Shirokov/cc-1c-skills",
"ref": "port-claude-code-py"
},
"description": "[Python] То же — для Linux/Mac или когда PowerShell недоступен."
}
]
}
-31
View File
@@ -1,31 +0,0 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "1c-skills",
"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": "./.claude/skills/"
}
-71
View File
@@ -1,71 +0,0 @@
---
name: form-add
description: Добавить пустую управляемую форму к объекту 1С. Используй когда нужно создать у объекта новую форму
argument-hint: <ObjectPath> <FormName> [Purpose] [--set-default]
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] [Synonym] [--set-default]
```
| Параметр | Обязательный | По умолчанию | Описание |
|-------------|:------------:|--------------|----------------------------------------------|
| ObjectPath | да | — | Путь к XML-файлу объекта (Documents/Док.xml) |
| FormName | да | — | Имя формы (ФормаДокумента) |
| Purpose | нет | Object | Назначение: Object, List, Choice, Record |
| Synonym | нет | = FormName | Синоним формы |
| --set-default | нет | авто | Установить как форму по умолчанию |
## Команда
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-add.ps1" -ObjectPath "<ObjectPath>" -FormName "<FormName>" [-Purpose "<Purpose>"] [-Synonym "<Synonym>"] [-SetDefault]
```
## Purpose — назначение формы
| Purpose | Допустимые типы объектов | Основной реквизит | DefaultForm-свойство |
|---------|-------------------------|-------------------|---------------------|
| Object | Document, Catalog, DataProcessor, Report, ExternalDataProcessor, ExternalReport, ChartOf*, ExchangePlan, BusinessProcess, Task | Объект (тип: *Object.Имя) | DefaultObjectForm (DefaultForm для DataProcessor/Report/ExternalDataProcessor/ExternalReport) |
| List | Все кроме DataProcessor | Список (DynamicList) | DefaultListForm |
| Choice | Document, Catalog, ChartOf*, ExchangePlan, BusinessProcess, Task | Список (DynamicList) | DefaultChoiceForm |
| Record | InformationRegister | Запись (InformationRegisterRecordManager) | DefaultRecordForm |
## Примеры
```
# Форма документа
/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 --set-default
```
## Workflow
1. `/form-add` — создать каркас формы
2. `/form-compile` или `/form-edit` — наполнить Form.xml элементами
3. `/form-validate` — проверить корректность
4. `/form-info` — проанализировать результат
@@ -1,114 +0,0 @@
# form-remove v1.9 — Remove form from 1C object
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
[Alias("ProcessorName")]
[string]$ObjectName,
[Parameter(Mandatory)]
[string]$FormName,
[string]$SrcDir = "src"
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
# --- Проверки ---
$rootXmlPath = Join-Path $SrcDir "$ObjectName.xml"
if (-not (Test-Path $rootXmlPath)) {
Write-Error "Корневой файл обработки не найден: $rootXmlPath"
exit 1
}
$processorDir = Join-Path $SrcDir $ObjectName
$formsDir = Join-Path $processorDir "Forms"
$formMetaPath = Join-Path $formsDir "$FormName.xml"
$formDir = Join-Path $formsDir $FormName
if (-not (Test-Path $formMetaPath)) {
Write-Error "Метаданные формы не найдены: $formMetaPath"
exit 1
}
# --- Удаление файлов ---
if (Test-Path $formDir) {
Remove-Item -Path $formDir -Recurse -Force
Write-Host "[OK] Удалён каталог: $formDir"
}
Remove-Item -Path $formMetaPath -Force
Write-Host "[OK] Удалён файл: $formMetaPath"
# --- Модификация корневого XML ---
$rootXmlFull = Resolve-Path $rootXmlPath
$xmlDoc = New-Object System.Xml.XmlDocument
$xmlDoc.PreserveWhitespace = $true
$xmlDoc.Load($rootXmlFull.Path)
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
# Удалить <Form>FormName</Form> из ChildObjects
$formNodes = $xmlDoc.SelectNodes("//md:ChildObjects/md:Form", $nsMgr)
foreach ($node in $formNodes) {
if ($node.InnerText -eq $FormName) {
$parent = $node.ParentNode
# Удалить предшествующий whitespace
$prev = $node.PreviousSibling
if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
$parent.RemoveChild($prev) | Out-Null
}
$parent.RemoveChild($node) | Out-Null
# Опустевший контейнер: остаётся отступ-whitespace, и XmlWriter пишет пару
# <ChildObjects>\n\t\t</ChildObjects>. Платформа пишет только <ChildObjects/>
# (1394 самозакрывающихся на acc+erp, пустых пар ни в одной форме — 0).
if ($parent.SelectNodes("*").Count -eq 0) { $parent.IsEmpty = $true }
break
}
}
# Очистить любые Default*/Auxiliary* form-слоты, указывавшие на удалённую форму
# (form-add пишет свойство по назначению: DefaultObjectForm/DefaultListForm/
# DefaultChoiceForm/DefaultRecordForm/DefaultForm — не только generic DefaultForm).
$formRefRe = "Form\.$([regex]::Escape($FormName))$"
foreach ($node in $xmlDoc.SelectNodes("//md:*", $nsMgr)) {
if ($node.LocalName -like "*Form" -and $node.InnerText -and $node.InnerText -match $formRefRe) {
# IsEmpty, а не InnerText="": пустая строка сериализуется парой <Tag></Tag>, а
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
$node.IsEmpty = $true
}
}
# Сохранить с BOM
$encBom = New-Object System.Text.UTF8Encoding($true)
$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 $rootXmlFull.Path) -and ([System.IO.File]::ReadAllText($rootXmlFull.Path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
[System.IO.File]::WriteAllText($rootXmlFull.Path, $xmlText, $encBom)
Write-Host "[OK] Форма $FormName удалена из $rootXmlPath"
@@ -1,170 +0,0 @@
#!/usr/bin/env python3
# form-remove v1.9 — Remove form from 1C object
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import os
import re
import shutil
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)
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
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 main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Remove form from 1C object", allow_abbrev=False)
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
parser.add_argument("-FormName", required=True)
parser.add_argument("-SrcDir", default="src")
args = ci_parse_args(parser)
object_name = args.ObjectName
form_name = args.FormName
src_dir = args.SrcDir
# --- Checks ---
root_xml_path = os.path.join(src_dir, f"{object_name}.xml")
if not os.path.exists(root_xml_path):
print(f"Корневой файл обработки не найден: {root_xml_path}", file=sys.stderr)
sys.exit(1)
processor_dir = os.path.join(src_dir, object_name)
forms_dir = os.path.join(processor_dir, "Forms")
form_meta_path = os.path.join(forms_dir, f"{form_name}.xml")
form_dir = os.path.join(forms_dir, form_name)
if not os.path.exists(form_meta_path):
print(f"Метаданные формы не найдены: {form_meta_path}", file=sys.stderr)
sys.exit(1)
# --- Delete files ---
if os.path.isdir(form_dir):
shutil.rmtree(form_dir)
print(f"[OK] Удалён каталог: {form_dir}")
os.remove(form_meta_path)
print(f"[OK] Удалён файл: {form_meta_path}")
# --- Modify root XML ---
root_xml_full = os.path.abspath(root_xml_path)
parser_xml = etree.XMLParser(remove_blank_text=False)
tree = etree.parse(root_xml_full, parser_xml)
root = tree.getroot()
# Remove <Form>FormName</Form> from ChildObjects
for node in root.findall(".//md:ChildObjects/md:Form", NSMAP):
if node.text and node.text.strip() == form_name:
parent = node.getparent()
prev = node.getprevious()
if prev is not None:
# Whitespace is in prev.tail
if prev.tail and prev.tail.strip() == "":
prev.tail = ""
else:
# First child — whitespace is in parent.text
if parent.text and parent.text.strip() == "":
parent.text = ""
parent.remove(node)
# Опустевший контейнер: text="" сериализуется парой <ChildObjects></ChildObjects>,
# а нужен <ChildObjects/> — PS-порт через DOM даёт именно его.
if len(parent) == 0 and not (parent.text or "").strip():
parent.text = None
break
# Clear any Default*/Auxiliary* form slot that pointed to the removed form
# (form-add writes the purpose-specific property: DefaultObjectForm / DefaultListForm /
# DefaultChoiceForm / DefaultRecordForm / DefaultForm — not just generic DefaultForm).
ref_re = re.compile(rf"Form\.{re.escape(form_name)}$")
for el in root.iter():
if not isinstance(el.tag, str):
continue
if etree.QName(el).localname.endswith("Form") and el.text and ref_re.search(el.text):
# text=None, а не "": пустая строка сериализуется парой <Tag></Tag>, а
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
el.text = None
# Save with BOM
save_xml_with_bom(tree, root_xml_full)
print(f"[OK] Форма {form_name} удалена из {root_xml_path}")
if __name__ == "__main__":
main()
-7
View File
@@ -1,7 +0,0 @@
# Коммиты, которые git blame должен «проскакивать» (механические правки —
# не меняют авторство содержимого). Включить локально:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# GitHub/GitLab уважают этот файл в blame-UI автоматически.
# chore(repo): нормализация EOL к LF + .gitattributes
26888a07d58351755fb8e487727c00d5b611eb95
-28
View File
@@ -1,28 +0,0 @@
# EOL policy
# ─────────────────────────────────────────────────────────────────────────────
# Авторский контент нормализуем к LF: инструмент правки (Edit) всегда пишет LF,
# поэтому единый LF убирает EOL-шум в диффах и ловушку «не правь CRLF-файл».
# git с eol=lf конвертит ТОЛЬКО CR<->LF и не трогает BOM (BOM — байты контента),
# поэтому BOM на .ps1 сохраняется.
*.ps1 text eol=lf
*.psm1 text eol=lf
*.py text eol=lf
*.mjs text eol=lf
*.md text eol=lf
*.json text eol=lf
.gitignore text eol=lf
# .bsl уже целиком LF — пин фиксирует статус-кво от будущего дрейфа.
*.bsl text eol=lf
# Данные 1С НЕ трогаем. *.xml — реальные выгрузки 1С (EOL местами значим,
# правим не мы, а навыки): оставляем как есть, под управление не берём.
# autocrlf=false и отсутствие text-атрибута => git хранит их байты как есть.
# Бинарники 1С
*.bin binary
# Package.bin пакетов XDTO — текстовый XML, несмотря на расширение. Оставляем
# под правилом *.bin binary (байты не нормализуются), но включаем текстовый diff,
# иначе изменение модели пакета в истории выглядит как «Binary files differ».
XDTOPackages/**/Package.bin diff
-26
View File
@@ -1,26 +0,0 @@
# 1C Skills for {{PLATFORM_LABEL}} ({{RUNTIME_LABEL}})
Автоматическая сборка из [main]({{MAIN_REPO_URL}}) — навыки 1С:Предприятие 8.3 для AI-агента **{{PLATFORM_LABEL}}** с рантаймом **{{RUNTIME_LABEL}}**.
> Эта ветка генерируется CI на каждый push в main. **Не редактируйте напрямую** — все правки идут в [main]({{MAIN_REPO_URL}}).
## Установка
1. Скачайте ZIP этой ветки: **Code → Download ZIP** (или `git archive`).
2. Распакуйте в корень своего проекта — должна появиться папка `{{PLATFORM_DIR}}/`.
3. Запустите {{PLATFORM_LABEL}} из этого проекта — навыки станут доступны.
## Требования
{{RUNTIME_REQUIREMENTS}}
- **1С:Предприятие 8.3** — для сборки/разборки EPF/ERF и работы с базами.
- **Node.js 18+** — для `/web-test`.
## Документация
Полные гайды, спецификации и описание навыков — в [main]({{MAIN_REPO_URL}}).
---
Source: {{MAIN_REPO_URL}}
Build commit: `{{COMMIT_SHA}}`
-31
View File
@@ -1,31 +0,0 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "{{PLUGIN_NAME}}",
"description": "[Python] Навыки для разработки на 1С:Предприятие 8.3 — абстракции над XML-форматами и CLI конфигуратора, плюс глаза и руки для тестирования через веб-клиент. Linux/Mac или когда PowerShell недоступен.",
"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": "./.claude/skills/"
}
-36
View File
@@ -1,36 +0,0 @@
{
"name": "{{PLUGIN_NAME}}",
"version": "{{VERSION}}",
"description": "[{{RUNTIME_LABEL}}] Навыки для разработки на 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 ({{RUNTIME_LABEL}})",
"shortDescription": "{{SHORT_DESCRIPTION}}",
"category": "Development"
}
}
-246
View File
@@ -1,246 +0,0 @@
name: Build port branches
on:
push:
branches: [main]
paths:
- '.claude/skills/**'
- 'scripts/switch.py'
- 'requirements.txt'
- '.github/templates/README.port.md.tmpl'
- '.github/templates/codex-plugin.json.tmpl'
- '.github/templates/claude-plugin.json.tmpl'
- '.github/workflows/build-ports.yml'
- 'LICENSE'
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- platform: claude-code
runtime: python
branch: port-claude-code-py
label: Claude Code
target_dir: .claude/skills
- platform: cursor
runtime: powershell
branch: port-cursor
label: Cursor
target_dir: .cursor/skills
- platform: cursor
runtime: python
branch: port-cursor-py
label: Cursor
target_dir: .cursor/skills
- platform: codex
runtime: powershell
branch: port-codex
label: Codex
target_dir: .codex/skills
- platform: codex
runtime: python
branch: port-codex-py
label: Codex
target_dir: .codex/skills
- platform: copilot
runtime: powershell
branch: port-copilot
label: GitHub Copilot
target_dir: .github/skills
- platform: copilot
runtime: python
branch: port-copilot-py
label: GitHub Copilot
target_dir: .github/skills
- platform: augment
runtime: powershell
branch: port-augment
label: Augment
target_dir: .augment/skills
- platform: augment
runtime: python
branch: port-augment-py
label: Augment
target_dir: .augment/skills
- platform: cline
runtime: powershell
branch: port-cline
label: Cline
target_dir: .cline/skills
- platform: cline
runtime: python
branch: port-cline-py
label: Cline
target_dir: .cline/skills
- platform: kilo
runtime: powershell
branch: port-kilo
label: Kilo Code
target_dir: .kilocode/skills
- platform: kilo
runtime: python
branch: port-kilo-py
label: Kilo Code
target_dir: .kilocode/skills
- platform: kiro
runtime: powershell
branch: port-kiro
label: Kiro
target_dir: .kiro/skills
- platform: kiro
runtime: python
branch: port-kiro-py
label: Kiro
target_dir: .kiro/skills
- platform: gemini
runtime: powershell
branch: port-gemini
label: Gemini CLI
target_dir: .gemini/skills
- platform: gemini
runtime: python
branch: port-gemini-py
label: Gemini CLI
target_dir: .gemini/skills
- platform: opencode
runtime: powershell
branch: port-opencode
label: OpenCode
target_dir: .opencode/skills
- platform: opencode
runtime: python
branch: port-opencode-py
label: OpenCode
target_dir: .opencode/skills
- platform: roo
runtime: powershell
branch: port-roo
label: Roo Code
target_dir: .roo/skills
- platform: roo
runtime: python
branch: port-roo-py
label: Roo Code
target_dir: .roo/skills
- platform: windsurf
runtime: powershell
branch: port-windsurf
label: Windsurf
target_dir: .windsurf/skills
- platform: windsurf
runtime: python
branch: port-windsurf-py
label: Windsurf
target_dir: .windsurf/skills
- platform: codeassistant
runtime: powershell
branch: port-codeassistant
label: Yandex Code Assistant
target_dir: .codeassistant/skills
- platform: codeassistant
runtime: python
branch: port-codeassistant-py
label: Yandex Code Assistant
target_dir: .codeassistant/skills
- platform: agents
runtime: powershell
branch: port-agents
label: Agent Skills
target_dir: .agents/skills
- platform: agents
runtime: python
branch: port-agents-py
label: Agent Skills
target_dir: .agents/skills
steps:
- name: Checkout main
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Build skills tree for ${{ matrix.platform }} (${{ matrix.runtime }})
run: |
python scripts/switch.py "${{ matrix.platform }}" \
--project-dir build \
--runtime "${{ matrix.runtime }}"
- name: Render port README
env:
PLATFORM_LABEL: ${{ matrix.label }}
PLATFORM_DIR: ${{ matrix.target_dir }}
RUNTIME: ${{ matrix.runtime }}
RUNTIME_LABEL: ${{ matrix.runtime == 'powershell' && 'PowerShell' || 'Python' }}
COMMIT_SHA: ${{ github.sha }}
MAIN_REPO_URL: https://github.com/${{ github.repository }}
run: |
if [ "$RUNTIME" = "powershell" ]; then
RUNTIME_REQUIREMENTS='- **Windows** с PowerShell 5.1+ (входит в Windows).'
else
RUNTIME_REQUIREMENTS='- **Python 3.9+**. Установка зависимостей: `pip install -r requirements.txt` (lxml, Pillow, psutil).'
fi
sed \
-e "s|{{PLATFORM_LABEL}}|${PLATFORM_LABEL}|g" \
-e "s|{{PLATFORM_DIR}}|${PLATFORM_DIR}|g" \
-e "s|{{RUNTIME_LABEL}}|${RUNTIME_LABEL}|g" \
-e "s|{{RUNTIME_REQUIREMENTS}}|${RUNTIME_REQUIREMENTS}|g" \
-e "s|{{COMMIT_SHA}}|${COMMIT_SHA}|g" \
-e "s|{{MAIN_REPO_URL}}|${MAIN_REPO_URL}|g" \
.github/templates/README.port.md.tmpl > build/README.md
- name: Render Codex plugin manifest
if: matrix.platform == 'codex'
env:
PLUGIN_NAME: ${{ matrix.runtime == 'python' && '1c-skills-py' || '1c-skills' }}
RUNTIME_LABEL: ${{ matrix.runtime == 'powershell' && 'PowerShell' || 'Python' }}
SHORT_DESCRIPTION: ${{ matrix.runtime == 'python' && 'Python runtime (Linux/Mac/Windows)' || 'PowerShell runtime (Windows-first)' }}
COMMIT_SHA: ${{ github.sha }}
run: |
VERSION="$(date -u +%Y.%-m.%-d)+${COMMIT_SHA::7}"
mkdir -p build/.codex-plugin
sed \
-e "s|{{PLUGIN_NAME}}|${PLUGIN_NAME}|g" \
-e "s|{{VERSION}}|${VERSION}|g" \
-e "s|{{RUNTIME_LABEL}}|${RUNTIME_LABEL}|g" \
-e "s|{{SHORT_DESCRIPTION}}|${SHORT_DESCRIPTION}|g" \
.github/templates/codex-plugin.json.tmpl > build/.codex-plugin/plugin.json
- name: Render Claude plugin manifest (Py variant)
if: matrix.platform == 'claude-code' && matrix.runtime == 'python'
env:
PLUGIN_NAME: 1c-skills-py
run: |
mkdir -p build/.claude-plugin
sed -e "s|{{PLUGIN_NAME}}|${PLUGIN_NAME}|g" \
.github/templates/claude-plugin.json.tmpl > build/.claude-plugin/plugin.json
- name: Copy LICENSE
run: cp LICENSE build/LICENSE
- name: Copy requirements.txt (Python builds only)
if: matrix.runtime == 'python'
run: cp requirements.txt build/requirements.txt
- name: Force-push orphan snapshot to ${{ matrix.branch }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd build
git init -q -b master
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -q -m "Auto-build: ${{ matrix.platform }} (${{ matrix.runtime }}) from ${GITHUB_SHA::7}"
git push --force \
"https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" \
"master:${{ matrix.branch }}"
-55
View File
@@ -1,55 +0,0 @@
# Реальные выгрузки обработок (примеры, не для версионирования)
upload/
# Результаты сборки
build/
base/
*.epf
*.log
# Временные файлы тестов
test-tmp/
# Локальные настройки Claude Code
.claude/settings.local.json
# Инструменты (portable Apache и т.д.)
tools/
# Отладка навыков (eval, trigger-test, run_loop результаты)
debug/
# Кэш тестов навыков
tests/skills/.cache/
# Python кэш
__pycache__/
# Локальный реестр баз данных 1С
.v8-project.json
# web-test: Node.js зависимости и runtime-артефакты
.claude/skills/web-test/scripts/node_modules/
.claude/skills/web-test/.browser-session.json
# Маркер отработавшего prepare() в фикстуре _suite-root
tests/web-test/_suite-root/prepare-ran.txt
# Скриншоты и видео (артефакты тестирования web-test)
*.png
*.mp4
# Навыки, скопированные для других AI-платформ (генерируются scripts/switch.py)
.agents/skills/
.augment/
.cline/
.codex/
.cursor/
.gemini/
.github/skills/
.kilocode/
.kiro/
.opencode/
.roo/
.windsurf/
debug-templates.txt
@@ -24,7 +24,7 @@ allowed-tools:
| `NoValidate` | Пропустить авто-валидацию | | `NoValidate` | Пропустить авто-валидацию |
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-edit.ps1" -ConfigPath '<path>' -Operation modify-property -Value 'Version=1.0.0.1' powershell.exe -NoProfile -File ".roo/skills/cf-edit/scripts/cf-edit.ps1" -ConfigPath '<path>' -Operation modify-property -Value 'Version=1.0.0.1'
``` ```
## Операции ## Операции
@@ -34,6 +34,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-edit.ps1" -Confi
| `modify-property` | `Ключ=Значение` (batch `;;`) | Изменить свойство | | `modify-property` | `Ключ=Значение` (batch `;;`) | Изменить свойство |
| `add-childObject` | `Type.Name` (batch `;;`) | Зарегистрировать уже существующий файл объекта в ChildObjects. Для создания нового объекта используй `/meta-compile`, `/role-compile`, `/subsystem-compile` — они регистрируют автоматически | | `add-childObject` | `Type.Name` (batch `;;`) | Зарегистрировать уже существующий файл объекта в ChildObjects. Для создания нового объекта используй `/meta-compile`, `/role-compile`, `/subsystem-compile` — они регистрируют автоматически |
| `remove-childObject` | `Type.Name` (batch `;;`) | Удалить объект из ChildObjects | | `remove-childObject` | `Type.Name` (batch `;;`) | Удалить объект из ChildObjects |
| `sort-childObjects` | вид, напр. `Catalog` (batch `;;`), либо пусто | Упорядочить ChildObjects по имени внутри вида. Без значения — все виды, кроме четырёх (см. reference) |
| `add-defaultRole` | `Role.Name` или `Name` | Добавить роль по умолчанию | | `add-defaultRole` | `Role.Name` или `Name` | Добавить роль по умолчанию |
| `remove-defaultRole` | `Role.Name` или `Name` | Удалить роль по умолчанию | | `remove-defaultRole` | `Role.Name` или `Name` | Удалить роль по умолчанию |
| `set-defaultRoles` | Имена через `;;` | Заменить список ролей по умолчанию | | `set-defaultRoles` | Имена через `;;` | Заменить список ролей по умолчанию |
@@ -39,6 +39,20 @@
Batch: `"Catalog.Товары ;; Document.Заказ ;; Enum.ВидыОплат"` Batch: `"Catalog.Товары ;; Document.Заказ ;; Enum.ВидыОплат"`
## sort-childObjects
Упорядочивает объекты в `<ChildObjects>` по имени **внутри вида**. Значение — имя вида (`Catalog`, `Role`, …), batch через `;;`. Без значения обрабатываются все виды, какие есть в файле.
```
-Operation sort-childObjects — все виды, кроме перечисленных ниже
-Operation sort-childObjects -Value "Catalog" — только справочники
-Operation sort-childObjects -Value "Catalog ;; Role"
```
Не сортируются, пока вид не назван явно: `CommonAttribute`, `Subsystem`, `CommandGroup`, `Language`.
Вызов без значения дополнительно ставит группы видов в канонический порядок; вызов с явным видом трогает только имена внутри него.
## add-defaultRole / remove-defaultRole / set-defaultRoles ## add-defaultRole / remove-defaultRole / set-defaultRoles
Имя роли: `ПолныеПрава` или `Role.ПолныеПрава` (префикс `Role.` добавляется автоматически). Имя роли: `ПолныеПрава` или `Role.ПолныеПрава` (префикс `Role.` добавляется автоматически).
@@ -1,15 +1,80 @@
# cf-edit v1.19 — Edit 1C configuration root (Configuration.xml) # cf-edit v1.28 — Edit 1C configuration root (Configuration.xml)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath, [Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
[string]$DefinitionFile, [string]$DefinitionFile,
[ValidateSet("modify-property","add-childObject","remove-childObject","add-defaultRole","remove-defaultRole","set-defaultRoles","set-panels","set-home-page")] [ValidateSet("modify-property","add-childObject","remove-childObject","add-defaultRole","remove-defaultRole","set-defaultRoles","set-panels","set-home-page","sort-childObjects")]
[string]$Operation, [string]$Operation,
[string]$Value, [string]$Value,
[switch]$NoValidate [switch]$NoValidate
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
# --- Разбор пользовательского JSON ---
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
# Возврат через -NoEnumerate: без него одноэлементный
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
try {
# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
$parsed = $text | ConvertFrom-Json
} catch {
$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
if ($Inline) {
$got = ($text -replace '\s+', ' ').Trim()
$label = 'got'
if (-not $got) { $got = '(empty)' }
elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
$what = "${what}, ${label}: ${got}"
}
[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
exit 1
}
Write-Output -NoEnumerate $parsed
}
# --- Чтение входного JSON-файла ---
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
# угаданное имя уйдёт в метаданные так же молча.
function Read-JsonInputFile([string]$path) {
# Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот
# же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей
# проверкой срабатывают раньше и сохраняют свой текст.
if (-not (Test-Path -LiteralPath $path)) {
[Console]::Error.WriteLine("[ERROR] File not found: $path")
exit 1
}
if (Test-Path -LiteralPath $path -PathType Container) {
[Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path")
exit 1
}
$bytes = [System.IO.File]::ReadAllBytes($path)
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
}
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
}
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
}
try {
return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
} catch {
$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
exit 1
}
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Mode validation --- # --- Mode validation ---
@@ -212,14 +277,14 @@ foreach ($child in $script:propsEl.ChildNodes) {
} }
Info "Configuration: $($script:objName)" Info "Configuration: $($script:objName)"
# --- Canonical type order for ChildObjects (44 types) --- # --- Canonical type order for ChildObjects (46 types) ---
$script:typeOrder = @( $script:typeOrder = @(
"Language","Subsystem","StyleItem","Style", "Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate", "CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan", "FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference", "XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption", "EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup", "FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document", "Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum", "DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister", "Report","DataProcessor","InformationRegister","AccumulationRegister",
@@ -232,7 +297,7 @@ $script:typeOrder = @(
$script:typeToDir = @{ $script:typeToDir = @{
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles" "Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates" "CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans" "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "PaletteColor"="PaletteColors"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences" "XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions" "EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups" "FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
@@ -311,6 +376,21 @@ function Import-Fragment([string]$xmlString) {
} }
# --- Parse batch value (split by ;;) --- # --- Parse batch value (split by ;;) ---
# Имя вида из пользовательского ввода → каноническое имя или $null.
# Ввод прощающий: регистр не важен, принимается имя каталога выгрузки (Catalogs → Catalog)
# и русское имя вида в единственном и множественном числе.
function Resolve-TypeName([string]$token) {
$key = "$token".Trim()
if (-not $key) { return $null }
foreach ($canon in $script:typeOrder) { if ($canon -eq $key) { return $canon } }
$byDir = $script:dirToType[$key.ToLowerInvariant()]
if ($byDir) { return $byDir }
$ru = $script:ruTypeMap[$key.ToLowerInvariant()]
if ($ru) { return $ru }
return $null
}
function Parse-BatchValue([string]$val) { function Parse-BatchValue([string]$val) {
$items = @() $items = @()
foreach ($part in $val.Split(";;")) { foreach ($part in $val.Split(";;")) {
@@ -376,6 +456,220 @@ function Do-ModifyProperty([string]$batchVal) {
} }
# --- Operation: add-childObject --- # --- Operation: add-childObject ---
# Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
# databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
# иначе корневое поле, иначе end. Значения: end — после последнего объекта того же вида
# (так дописывает Конфигуратор); byName — по имени среди объектов того же вида.
# Файл ищем от рабочего каталога вверх, каталог конфигурации — запасной путь: так же
# его ищут support-guard и группа db-*, а скрипт навыка зовут по абсолютному пути, и cwd
# остаётся рабочим каталогом проекта.
# configSrc считается от каталога .v8-project.json, как задокументировано в
# docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
function Get-NewObjectPosition([string]$cfgDir) {
try {
if (-not $cfgDir) { $cfgDir = "." }
$pj = Find-V8Project (Get-Location).Path
if (-not $pj) { $pj = Find-V8Project ([System.IO.Path]::GetFullPath($cfgDir)) }
if (-not $pj) { return "end" }
$proj = Get-Content -Raw $pj | ConvertFrom-Json
$projDir = [System.IO.Path]::GetDirectoryName($pj)
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
if ($proj.databases) {
foreach ($db in $proj.databases) {
if ($db.configSrc -and $db.newObjectPosition) {
$src = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($projDir, $db.configSrc)).TrimEnd('\', '/')
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
if ("$($db.newObjectPosition)" -eq "byName") { return "byName" }
return "end"
}
}
}
}
if ("$($proj.newObjectPosition)" -eq "byName") { return "byName" }
return "end"
} catch { return "end" }
}
# Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
# CommonAttribute — исключение самого стандарта (#std467): у общих реквизитов-разделителей
# порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
# пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
# порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
# (в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
# без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
# Явно названный вид сортируется в любом случае.
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Test-OrderSensitiveType([string]$typeName) {
return @("CommonAttribute", "Subsystem", "CommandGroup", "Language") -ccontains $typeName
}
# Порядок имён объектов метаданных, как в дереве Конфигуратора.
# Ключ — пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
# букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
# используются — они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
# одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
# Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
function Compare-MetadataNames([string]$a, [string]$b) {
$keys = @("", "")
$names = @($a, $b)
for ($i = 0; $i -lt 2; $i++) {
$sb = New-Object System.Text.StringBuilder
foreach ($ch in $names[$i].ToLowerInvariant().ToCharArray()) {
if ($ch -eq [char]0x0451) { $ch = [char]0x0435 }
if ([char]::IsDigit($ch)) { [void]$sb.Append('1') }
elseif ([char]::IsLetter($ch)) { [void]$sb.Append('2') }
else { [void]$sb.Append('0') }
[void]$sb.Append($ch)
}
$keys[$i] = $sb.ToString()
}
$r = [string]::CompareOrdinal($keys[0], $keys[1])
if ($r -eq 0) { $r = [string]::CompareOrdinal($a, $b) }
if ($r -lt 0) { return -1 }
if ($r -gt 0) { return 1 }
return 0
}
# Сортировка имён компаратором Compare-MetadataNames. В py-порту ту же роль играет
# functools.cmp_to_key — штатный способ отсортировать компаратором; в PS 5.1 его нет,
# поэтому слияние вручную. Порядок обоих портов задаёт один и тот же компаратор.
function Sort-MetadataNames([string[]]$names) {
# Возврат без запятой-обёртки: приёмная сторона всегда пишет @(...), и одноэлементный
# результат остаётся массивом. С `return ,@(...)` @() собрал бы ОДИН объект-массив.
if ($names.Count -le 1) { return $names }
$mid = [int]($names.Count / 2)
$left = @(Sort-MetadataNames $names[0..($mid - 1)])
$right = @(Sort-MetadataNames $names[$mid..($names.Count - 1)])
$out = New-Object System.Collections.ArrayList
$i = 0; $j = 0
while ($i -lt $left.Count -and $j -lt $right.Count) {
if ((Compare-MetadataNames $left[$i] $right[$j]) -le 0) { [void]$out.Add($left[$i]); $i++ }
else { [void]$out.Add($right[$j]); $j++ }
}
while ($i -lt $left.Count) { [void]$out.Add($left[$i]); $i++ }
while ($j -lt $right.Count) { [void]$out.Add($right[$j]); $j++ }
return $out.ToArray()
}
# Упорядочить <ChildObjects>: имена внутри вида, а без аргумента — и группы видов.
# Виды из Test-OrderSensitiveType по имени не сортируются, пока не названы явно.
# Вызов без значения дополнительно ставит группы видов в канонический порядок: платформа
# починила бы его только при загрузке-выгрузке, то есть неканоничный файл даёт диф на
# ровном месте. Переставляем ЗНАЧЕНИЯ узлов, а не сами узлы — отступы и структура файла
# остаются как были, в дифе только перестановка строк.
function Do-SortChildObjects([string]$batchVal) {
if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }
# Ввод прощающий: регистр не важен, принимается и имя каталога (Catalogs → Catalog) —
# в дереве выгрузки виды видны именно во множественном числе.
# Без @(...) на приёме: Parse-BatchValue возвращает ,$items — обёртка, которую @()
# собрал бы как ОДИН объект-массив, и вид не нашёлся бы в $script:typeOrder.
$tokens = @()
if ("$batchVal".Trim()) { $tokens = Parse-BatchValue $batchVal }
$requested = @()
foreach ($token in $tokens) {
$canon = Resolve-TypeName $token
if (-not $canon) { Write-Error "Unknown type '$token'. Valid: $($script:typeOrder -join ', ')"; exit 1 }
$requested += $canon
}
$groups = New-Object System.Collections.Specialized.OrderedDictionary
foreach ($child in $script:childObjsEl.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
$ln = $child.get_LocalName()
if (-not $groups.Contains($ln)) { $groups[$ln] = New-Object System.Collections.ArrayList }
[void]$groups[$ln].Add($child)
}
$targets = if ($requested.Count -gt 0) { $requested } else { @($groups.Keys | Where-Object { -not (Test-OrderSensitiveType $_) }) }
foreach ($typeName in $targets) {
if (-not $groups.Contains($typeName)) { continue }
$els = $groups[$typeName]
if ($els.Count -lt 2) { continue }
$names = @(foreach ($e in $els) { $e.InnerText })
$ordered = @(Sort-MetadataNames $names)
$same = $true
for ($i = 0; $i -lt $names.Count; $i++) { if ($names[$i] -cne $ordered[$i]) { $same = $false; break } }
if ($same) { continue }
for ($i = 0; $i -lt $els.Count; $i++) { $els[$i].InnerText = $ordered[$i] }
$script:modifyCount++
Info "Sorted: $typeName ($($els.Count))"
}
if ($requested.Count -gt 0) { return }
# Без аргумента приводим в порядок и сами группы видов: собранная навыками конфигурация
# может держать их не в каноне, и первая же выгрузка платформы даст диф. Переставляем
# содержимое существующих узлов, а не узлы, поэтому отступы и структура файла не меняются —
# в дифе только перестановка строк. Имя тега у XmlElement неизменяемо, поэтому там, где вид
# меняется, узел заменяется через ReplaceChild: он сохраняет окружающие пробельные узлы.
$elems = @()
foreach ($child in $script:childObjsEl.ChildNodes) {
if ($child.NodeType -eq 'Element') { $elems += $child }
}
$tags = @(); $texts = @()
foreach ($e in $elems) { $tags += $e.get_LocalName(); $texts += $e.InnerText }
$rank = @()
for ($i = 0; $i -lt $tags.Count; $i++) {
$r = $script:typeOrder.IndexOf($tags[$i])
if ($r -lt 0) { $r = $script:typeOrder.Count }
$rank += $r
}
# Порядок стабильный: вторым ключом идёт исходная позиция
$order = @(0..($tags.Count - 1) | Sort-Object @{e={$rank[$_]}}, @{e={$_}})
$same = $true
for ($i = 0; $i -lt $order.Count; $i++) { if ($order[$i] -ne $i) { $same = $false; break } }
if ($same) { return }
for ($i = 0; $i -lt $elems.Count; $i++) {
$srcIdx = $order[$i]
if ($tags[$i] -ceq $tags[$srcIdx]) {
$elems[$i].InnerText = $texts[$srcIdx]
continue
}
$newEl = $script:xmlDoc.CreateElement($tags[$srcIdx], $script:mdNs)
$newEl.InnerText = $texts[$srcIdx]
[void]$script:childObjsEl.ReplaceChild($newEl, $elems[$i])
}
$script:modifyCount++
Info "Reordered type groups: $($elems.Count) entries"
}
# Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
# финальный перенос. $null → файл новый (сохранить текущее поведение).
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Detect-XmlStyle([string]$path) {
if (-not (Test-Path -LiteralPath $path)) { return $null }
$raw = [System.IO.File]::ReadAllBytes($path)
$bom = ($raw.Length -ge 3 -and $raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF)
$body = if ($bom) { [System.Text.Encoding]::UTF8.GetString($raw, 3, $raw.Length - 3) } else { [System.Text.Encoding]::UTF8.GetString($raw) }
$head = if ($body.Length -gt 200) { $body.Substring(0, 200) } else { $body }
$m = [regex]::Match($head, 'encoding="([^"]+)"')
return @{
bom = $bom
crlf = $body.Contains("`r`n")
enc = $(if ($m.Success) { $m.Groups[1].Value } else { "utf-8" })
finalNl = $body.EndsWith("`n")
}
}
# Привести текст XmlWriter к стилю оригинала; для НОВОГО файла ($null) — к канону выгрузки
# Конфигуратора: encoding="UTF-8", CRLF, без перевода строки в конце.
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Finalize-XmlText([string]$text, $style) {
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
$encDecl = $(if ($style) { $style.enc } else { "UTF-8" })
$text = $text.Replace('encoding="utf-8"', 'encoding="' + $encDecl + '"')
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
$text = ($text -replace "`r`n", "`n").TrimEnd("`n")
if ($style -and $style.finalNl) { $text += "`n" }
if (-not $style -or $style.crlf) { $text = $text -replace "`n", "`r`n" }
return $text
}
function Do-AddChildObject([string]$batchVal) { function Do-AddChildObject([string]$batchVal) {
if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 } if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }
@@ -395,6 +689,8 @@ function Do-AddChildObject([string]$batchVal) {
exit 1 exit 1
} }
$typeName = $item.Substring(0, $dotIdx) $typeName = $item.Substring(0, $dotIdx)
$canonType = Resolve-TypeName $typeName
if ($canonType) { $typeName = $canonType }
$objNameVal = $item.Substring($dotIdx + 1) $objNameVal = $item.Substring($dotIdx + 1)
# Check type is valid # Check type is valid
@@ -439,11 +735,11 @@ To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
continue continue
} }
# Find insertion point: after last element of same type, or after last element of preceding type # Место вставки. Вид — по $script:typeOrder; внутри вида — по newObjectPosition.
$byName = (-not (Test-OrderSensitiveType $typeName) -and (Get-NewObjectPosition $script:configDir) -eq "byName")
$insertBefore = $null $insertBefore = $null
$lastSameType = $null $lastSameType = $null
$lastPrecedingType = $null $firstLaterType = $null
$currentTypeIdx = -1
foreach ($child in $script:childObjsEl.ChildNodes) { foreach ($child in $script:childObjsEl.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue } if ($child.NodeType -ne 'Element') { continue }
@@ -451,17 +747,29 @@ To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
if ($childTypeIdx -lt 0) { continue } if ($childTypeIdx -lt 0) { continue }
if ($child.LocalName -eq $typeName) { if ($child.LocalName -eq $typeName) {
# Same type — check alphabetical order # Внутри вида — по newObjectPosition: end (по умолчанию) кладёт после последнего
if ($child.InnerText -gt $objNameVal -and -not $insertBefore) { # объекта того же вида, byName — по имени. Subsystem по имени не упорядочиваем
# Insert before this element (alphabetical) # никогда: порядок подсистем в дереве задаёт порядок разделов в панели.
$lastSameType = $child
if ($byName -and -not $insertBefore -and (Compare-MetadataNames $child.InnerText $objNameVal) -gt 0) {
$insertBefore = $child $insertBefore = $child
} }
$lastSameType = $child } elseif ($childTypeIdx -gt $typeIdx -and -not $firstLaterType) {
} elseif ($childTypeIdx -lt $typeIdx) { $firstLaterType = $child
$lastPrecedingType = $child }
} elseif ($childTypeIdx -gt $typeIdx -and -not $insertBefore) { }
# First element of a later type — insert before it
$insertBefore = $child if (-not $insertBefore) {
# Место не выбрано именем — ставим сразу за последним объектом того же вида,
# то есть перед его следующим соседом. Через $firstLaterType этого не сделать:
# если видов старше в файле нет, запись уехала бы в самый конец блока,
# за пределы своей группы.
if ($lastSameType) {
$next = $lastSameType.NextSibling
while ($next -and $next.NodeType -ne 'Element') { $next = $next.NextSibling }
$insertBefore = $next
} else {
$insertBefore = $firstLaterType
} }
} }
@@ -493,6 +801,8 @@ function Do-RemoveChildObject([string]$batchVal) {
exit 1 exit 1
} }
$typeName = $item.Substring(0, $dotIdx) $typeName = $item.Substring(0, $dotIdx)
$canonType = Resolve-TypeName $typeName
if ($canonType) { $typeName = $canonType }
$objNameVal = $item.Substring($dotIdx + 1) $objNameVal = $item.Substring($dotIdx + 1)
$found = $false $found = $false
@@ -639,10 +949,7 @@ function Do-SetPanels($valArg) {
# Accept string (JSON), PSCustomObject, or hashtable # Accept string (JSON), PSCustomObject, or hashtable
$layout = $valArg $layout = $valArg
if ($layout -is [string]) { if ($layout -is [string]) {
try { $layout = $layout | ConvertFrom-Json } catch { $layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-panels'" "a JSON object with panel layout" -Inline
Write-Error "set-panels value must be valid JSON object, got: $valArg"
exit 1
}
} }
if (-not $layout) { if (-not $layout) {
Write-Error "set-panels value is empty" Write-Error "set-panels value is empty"
@@ -725,6 +1032,29 @@ $script:ruTypeMap = @{
"бот" = "Bot" "бот" = "Bot"
"планобмена" = "ExchangePlan" "планобмена" = "ExchangePlan"
"хранилищенастроек" = "SettingsStorage" "хранилищенастроек" = "SettingsStorage"
# Множественное число: в дереве конфигурации виды подписаны именно так.
"справочники" = "Catalog"
"документы" = "Document"
"перечисления" = "Enum"
"отчёты" = "Report"
"отчеты" = "Report"
"обработки" = "DataProcessor"
"общиеформы" = "CommonForm"
"журналыдокументов" = "DocumentJournal"
"планывидовхарактеристик" = "ChartOfCharacteristicTypes"
"планысчетов" = "ChartOfAccounts"
"планывидоврасчета" = "ChartOfCalculationTypes"
"планывидоврасчёта" = "ChartOfCalculationTypes"
"регистрысведений" = "InformationRegister"
"регистрынакопления" = "AccumulationRegister"
"регистрыбухгалтерии" = "AccountingRegister"
"регистрырасчета" = "CalculationRegister"
"регистрырасчёта" = "CalculationRegister"
"бизнеспроцессы" = "BusinessProcess"
"задачи" = "Task"
"боты" = "Bot"
"планыобмена" = "ExchangePlan"
"хранилищанастроек" = "SettingsStorage"
} }
# plural folder → singular type # plural folder → singular type
$script:dirToType = @{} $script:dirToType = @{}
@@ -826,9 +1156,7 @@ $indent</Item>
function Do-SetHomePage($valArg) { function Do-SetHomePage($valArg) {
$layout = $valArg $layout = $valArg
if ($layout -is [string]) { if ($layout -is [string]) {
try { $layout = $layout | ConvertFrom-Json } catch { $layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-home-page'" "a JSON object with home page layout" -Inline
Write-Error "set-home-page value must be valid JSON object"; exit 1
}
} }
if (-not $layout) { Write-Error "set-home-page value is empty"; exit 1 } if (-not $layout) { Write-Error "set-home-page value is empty"; exit 1 }
@@ -942,8 +1270,8 @@ if ($DefinitionFile) {
if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) { if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) {
$DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile $DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile
} }
$jsonText = Get-Content -Raw -Encoding UTF8 $DefinitionFile $jsonText = Read-JsonInputFile $DefinitionFile
$ops = $jsonText | ConvertFrom-Json $ops = ConvertFrom-JsonInput $jsonText $DefinitionFile
if ($ops -is [System.Array]) { if ($ops -is [System.Array]) {
foreach ($op in $ops) { $operations += $op } foreach ($op in $ops) { $operations += $op }
} else { } else {
@@ -968,11 +1296,16 @@ foreach ($op in $operations) {
"set-defaultRoles" { Do-SetDefaultRoles $opValueStr } "set-defaultRoles" { Do-SetDefaultRoles $opValueStr }
"set-panels" { Do-SetPanels $opValue } "set-panels" { Do-SetPanels $opValue }
"set-home-page" { Do-SetHomePage $opValue } "set-home-page" { Do-SetHomePage $opValue }
"sort-childObjects" { Do-SortChildObjects $opValueStr }
default { Write-Error "Unknown operation: $opName"; exit 1 } default { Write-Error "Unknown operation: $opName"; exit 1 }
} }
} }
# --- Save --- # --- Save ---
# Стиль исходника снимаем ДО записи: правка чужого файла наследует его BOM/EOL/заголовок
# (#44/#46/#47), новый файл получает канон выгрузки. Зеркало _detect_xml_style в py-порту.
$xmlStyle = Detect-XmlStyle $resolvedPath
$settings = New-Object System.Xml.XmlWriterSettings $settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($true) $settings.Encoding = New-Object System.Text.UTF8Encoding($true)
$settings.Indent = $false $settings.Indent = $false
@@ -983,22 +1316,12 @@ $writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
$script:xmlDoc.Save($writer) $script:xmlDoc.Save($writer)
$writer.Flush(); $writer.Close() $writer.Flush(); $writer.Close()
$bytes = $memStream.ToArray() $text = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
$memStream.Close() $memStream.Close()
$text = [System.Text.Encoding]::UTF8.GetString($bytes) $text = Finalize-XmlText $text $xmlStyle
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
$text = [regex]::Replace($text, '(?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 $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
$utf8Bom = New-Object System.Text.UTF8Encoding($true) $writeBom = ($null -eq $xmlStyle) -or $xmlStyle.bom
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom) [System.IO.File]::WriteAllText($resolvedPath, $text, (New-Object System.Text.UTF8Encoding($writeBom)))
Info "Saved: $resolvedPath" Info "Saved: $resolvedPath"
# --- Auto-validate --- # --- Auto-validate ---
@@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cf-edit v1.19 — Edit 1C configuration root (Configuration.xml) # cf-edit v1.28 — Edit 1C configuration root (Configuration.xml)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
import functools
import json import json
import os import os
import re import re
@@ -14,6 +15,68 @@ from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet] # Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное. # регистр не различают, в argparse совпадение точное.
def parse_json_input(text, source, expected=None, inline=False):
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
expected заполняем только для полиморфного входа: у файла подсказка
была бы наполнителем имя файла и текст парсера самодостаточны. inline печатает ещё и то,
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
"""
import json as _pj
import sys as _psys
try:
if not str(text).strip():
raise ValueError("input is empty")
return _pj.loads(text)
except ValueError as exc:
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
if inline:
got = " ".join(str(text).split())
label = "got"
if not got:
got = "(empty)"
elif len(got) > 60:
label = "got (first 60 chars)"
got = got[:60]
what = "%s, %s: %s" % (what, label, got)
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
_psys.exit(1)
def read_json_file(path):
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
BOM объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
"""
import os as _pos
import sys as _psys
if not _pos.path.exists(path):
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
_psys.exit(1)
if _pos.path.isdir(path):
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
_psys.exit(1)
with open(path, "rb") as _fh:
data = _fh.read()
if data[:3] == b"\xef\xbb\xbf":
return data[3:].decode("utf-8")
if data[:2] == b"\xff\xfe":
return data[2:].decode("utf-16-le")
if data[:2] == b"\xfe\xff":
return data[2:].decode("utf-16-be")
try:
return data.decode("utf-8")
except UnicodeDecodeError as exc:
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
% (path, exc), file=_psys.stderr)
_psys.exit(1)
class CIDict(dict): class CIDict(dict):
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные # Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки # реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
@@ -254,14 +317,14 @@ XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
V8_NS = "http://v8.1c.ru/8.1/data/core" V8_NS = "http://v8.1c.ru/8.1/data/core"
XS_NS = "http://www.w3.org/2001/XMLSchema" XS_NS = "http://www.w3.org/2001/XMLSchema"
# Canonical type order for ChildObjects (44 types) # Canonical type order for ChildObjects (46 types)
TYPE_ORDER = [ TYPE_ORDER = [
"Language", "Subsystem", "StyleItem", "Style", "Language", "Subsystem", "StyleItem", "Style",
"CommonPicture", "SessionParameter", "Role", "CommonTemplate", "CommonPicture", "SessionParameter", "Role", "CommonTemplate",
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan", "FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
"XDTOPackage", "WebService", "HTTPService", "WSReference", "XDTOPackage", "WebService", "HTTPService", "WSReference",
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption", "EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup", "FunctionalOptionsParameter", "DefinedType", "Bot", "PaletteColor", "CommonCommand", "CommandGroup",
"Constant", "CommonForm", "Catalog", "Document", "Constant", "CommonForm", "Catalog", "Document",
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum", "DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister", "Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
@@ -274,7 +337,7 @@ TYPE_ORDER = [
TYPE_TO_DIR = { TYPE_TO_DIR = {
"Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles", "Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles",
"CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates", "CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates",
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans", "FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "PaletteColor": "PaletteColors", "PaletteColor": "PaletteColors", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
"XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences", "XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
"EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions", "EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions",
"FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups", "FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups",
@@ -291,6 +354,137 @@ SCALAR_PROPS = ["Name", "Version", "Vendor", "Comment", "NamePrefix", "UpdateCat
REF_PROPS = ["DefaultLanguage"] REF_PROPS = ["DefaultLanguage"]
def get_new_object_position(cfg_dir):
"""Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
иначе корневое поле, иначе end. Значения: end после последнего объекта того же вида
(так дописывает Конфигуратор); byName по имени среди объектов того же вида.
Файл ищем от рабочего каталога вверх, каталог конфигурации запасной путь: так же
его ищут support-guard и группа db-*, а скрипт навыка зовут по абсолютному пути, и cwd
остаётся рабочим каталогом проекта.
configSrc считается от каталога .v8-project.json, как задокументировано в
docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
"""
try:
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(os.path.abspath(cfg_dir or "."))
if not pj:
return "end"
proj = json.loads(open(pj, encoding="utf-8-sig").read())
proj_dir = os.path.dirname(pj)
cfg_full = os.path.normcase(os.path.abspath(cfg_dir or ".")).rstrip("\\/")
for db in proj.get("databases", []):
src = db.get("configSrc")
if src and db.get("newObjectPosition"):
src_full = os.path.normcase(os.path.abspath(os.path.join(proj_dir, src))).rstrip("\\/")
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
return "byName" if str(db["newObjectPosition"]).lower() == "byname" else "end"
if str(proj.get("newObjectPosition") or "").lower() == "byname":
return "byName"
return "end"
except Exception:
return "end"
def is_order_sensitive_type(type_name):
"""Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
CommonAttribute исключение самого стандарта (#std467): у общих реквизитов-разделителей
порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
(в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
Явно названный вид сортируется в любом случае.
Реестр семьи: tests/skills/check-inline-drift.mjs.
"""
return type_name in ("CommonAttribute", "Subsystem", "CommandGroup", "Language")
def compare_metadata_names(a, b):
"""Порядок имён объектов метаданных, как в дереве Конфигуратора.
Ключ пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
используются они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
"""
keys = []
for name in (a, b):
parts = []
for ch in name.lower():
if ch == "ё":
ch = "е"
if ch.isdigit():
parts.append("1" + ch)
elif ch.isalpha():
parts.append("2" + ch)
else:
parts.append("0" + ch)
keys.append("".join(parts))
if keys[0] != keys[1]:
return -1 if keys[0] < keys[1] else 1
if a != b:
return -1 if a < b else 1
return 0
RU_TYPE_MAP = {
"справочник": "Catalog", "документ": "Document", "перечисление": "Enum",
"отчёт": "Report", "отчет": "Report", "обработка": "DataProcessor",
"общаяформа": "CommonForm", "журналдокументов": "DocumentJournal",
"планвидовхарактеристик": "ChartOfCharacteristicTypes",
"плансчетов": "ChartOfAccounts",
"планвидоврасчета": "ChartOfCalculationTypes",
"планвидоврасчёта": "ChartOfCalculationTypes",
"регистрсведений": "InformationRegister",
"регистрнакопления": "AccumulationRegister",
"регистрбухгалтерии": "AccountingRegister",
"регистррасчета": "CalculationRegister",
"регистррасчёта": "CalculationRegister",
"бизнеспроцесс": "BusinessProcess",
"бот": "Bot",
"задача": "Task", "планобмена": "ExchangePlan",
"хранилищенастроек": "SettingsStorage",
# Множественное число: в дереве конфигурации виды подписаны именно так.
"справочники": "Catalog", "документы": "Document", "перечисления": "Enum",
"отчёты": "Report", "отчеты": "Report", "обработки": "DataProcessor",
"общиеформы": "CommonForm", "журналыдокументов": "DocumentJournal",
"планывидовхарактеристик": "ChartOfCharacteristicTypes",
"планысчетов": "ChartOfAccounts",
"планывидоврасчета": "ChartOfCalculationTypes",
"планывидоврасчёта": "ChartOfCalculationTypes",
"регистрысведений": "InformationRegister",
"регистрынакопления": "AccumulationRegister",
"регистрыбухгалтерии": "AccountingRegister",
"регистррасчета": "CalculationRegister", "регистрырасчета": "CalculationRegister",
"регистрырасчёта": "CalculationRegister",
"бизнеспроцессы": "BusinessProcess",
"боты": "Bot",
"задачи": "Task", "планыобмена": "ExchangePlan",
"хранилищанастроек": "SettingsStorage",
}
def resolve_type_name(token):
"""Имя вида из пользовательского ввода → каноническое имя или None.
Ввод прощающий: регистр не важен, принимается имя каталога выгрузки
(Catalogs Catalog) и русское имя вида в единственном и множественном числе.
"""
key = (token or "").strip().lower()
if not key:
return None
for canon in TYPE_ORDER:
if canon.lower() == key:
return canon
for canon, dir_name in TYPE_TO_DIR.items():
if dir_name.lower() == key:
return canon
return RU_TYPE_MAP.get(key)
def localname(el): def localname(el):
return etree.QName(el.tag).localname return etree.QName(el.tag).localname
@@ -436,7 +630,7 @@ def main():
parser = argparse.ArgumentParser(description="Edit 1C configuration root (Configuration.xml)", allow_abbrev=False) parser = argparse.ArgumentParser(description="Edit 1C configuration root (Configuration.xml)", allow_abbrev=False)
parser.add_argument("-ConfigPath", "-Path", required=True) parser.add_argument("-ConfigPath", "-Path", required=True)
parser.add_argument("-DefinitionFile", default=None) parser.add_argument("-DefinitionFile", default=None)
parser.add_argument("-Operation", default=None, choices=["modify-property", "add-childObject", "remove-childObject", "add-defaultRole", "remove-defaultRole", "set-defaultRoles", "set-panels", "set-home-page"]) parser.add_argument("-Operation", default=None, choices=["modify-property", "add-childObject", "remove-childObject", "add-defaultRole", "remove-defaultRole", "set-defaultRoles", "set-panels", "set-home-page", "sort-childObjects"])
parser.add_argument("-Value", default=None) parser.add_argument("-Value", default=None)
parser.add_argument("-NoValidate", action="store_true") parser.add_argument("-NoValidate", action="store_true")
args = ci_parse_args(parser) args = ci_parse_args(parser)
@@ -575,7 +769,7 @@ def main():
if dot_idx < 1: if dot_idx < 1:
print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr) print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr)
sys.exit(1) sys.exit(1)
type_name = item[:dot_idx] type_name = resolve_type_name(item[:dot_idx]) or item[:dot_idx]
obj_name_val = item[dot_idx + 1:] obj_name_val = item[dot_idx + 1:]
if type_name not in TYPE_ORDER: if type_name not in TYPE_ORDER:
@@ -612,8 +806,15 @@ def main():
warn(f"Already exists: {type_name}.{obj_name_val}") warn(f"Already exists: {type_name}.{obj_name_val}")
continue continue
# Find insertion point # Место вставки. Вид — по TYPE_ORDER; внутри вида — по newObjectPosition:
# end (по умолчанию) кладёт после последнего объекта того же вида, byName — по имени.
# Subsystem по имени не упорядочиваем никогда: порядок подсистем в дереве задаёт
# порядок разделов в панели, пока их не перечислили в <SubsystemsOrder>.
by_name = (not is_order_sensitive_type(type_name)
and get_new_object_position(config_dir) == "byName")
insert_before = None insert_before = None
last_same = None
first_later = None
for child in child_objs_el: for child in child_objs_el:
if not isinstance(child.tag, str): if not isinstance(child.tag, str):
continue continue
@@ -623,10 +824,24 @@ def main():
child_type_idx = TYPE_ORDER.index(child_type_name) child_type_idx = TYPE_ORDER.index(child_type_name)
if child_type_name == type_name: if child_type_name == type_name:
if (child.text or "") > obj_name_val and insert_before is None: last_same = child
if (by_name and insert_before is None
and compare_metadata_names(child.text or "", obj_name_val) > 0):
insert_before = child insert_before = child
elif child_type_idx > type_idx and insert_before is None: elif child_type_idx > type_idx and first_later is None:
insert_before = child first_later = child
if insert_before is None:
# Место не выбрано именем — ставим сразу за последним объектом того же вида,
# то есть перед его следующим соседом. Через first_later этого не сделать:
# если видов старше в файле нет, запись уехала бы в самый конец блока,
# за пределы своей группы.
if last_same is not None:
siblings = [c for c in child_objs_el if isinstance(c.tag, str)]
pos = siblings.index(last_same)
insert_before = siblings[pos + 1] if pos + 1 < len(siblings) else None
else:
insert_before = first_later
new_el = etree.Element(f"{{{MD_NS}}}{type_name}") new_el = etree.Element(f"{{{MD_NS}}}{type_name}")
new_el.text = obj_name_val new_el.text = obj_name_val
@@ -639,6 +854,69 @@ def main():
add_count += 1 add_count += 1
info(f"Added: {type_name}.{obj_name_val}") info(f"Added: {type_name}.{obj_name_val}")
def do_sort_child_objects(batch_val):
"""Упорядочить <ChildObjects>: имена внутри вида, а без аргумента — и группы видов.
Виды из is_order_sensitive_type по имени не сортируются, пока не названы явно.
Вызов без значения дополнительно ставит группы видов в канонический порядок: платформа
починила бы его только при загрузке-выгрузке, то есть неканоничный файл даёт диф на
ровном месте. Переставляем ЗНАЧЕНИЯ узлов, а не сами узлы отступы и структура файла
остаются как были, в дифе только перестановка строк.
"""
nonlocal modify_count
if child_objs_el is None:
print("No <ChildObjects> element found", file=sys.stderr)
sys.exit(1)
requested = []
for token in (parse_batch_value(batch_val) if str(batch_val or "").strip() else []):
canon = resolve_type_name(token)
if canon is None:
print(f"Unknown type '{token}'. Valid: {', '.join(TYPE_ORDER)}", file=sys.stderr)
sys.exit(1)
requested.append(canon)
groups = {}
for child in child_objs_el:
if not isinstance(child.tag, str):
continue
groups.setdefault(localname(child), []).append(child)
targets = requested or [t for t in groups if not is_order_sensitive_type(t)]
for type_name in targets:
els = groups.get(type_name, [])
if len(els) < 2:
continue
names = [e.text or "" for e in els]
ordered = sorted(names, key=functools.cmp_to_key(compare_metadata_names))
if names == ordered:
continue
for el, name in zip(els, ordered):
el.text = name
modify_count += 1
info(f"Sorted: {type_name} ({len(els)})")
if requested:
# Вид назван явно — точечная операция: взаимный порядок групп не трогаем.
return
# Без аргумента приводим в порядок и сами группы видов: собранная навыками
# конфигурация может держать их не в каноне, и первая же выгрузка платформы даст
# диф. Переставляем содержимое существующих узлов, а не узлы, поэтому отступы и
# структура файла не меняются — в дифе только перестановка строк.
elems = [c for c in child_objs_el if isinstance(c.tag, str)]
pairs = [(localname(c), c.text or "") for c in elems]
ranked = sorted(range(len(pairs)),
key=lambda i: (TYPE_ORDER.index(pairs[i][0]) if pairs[i][0] in TYPE_ORDER else len(TYPE_ORDER), i))
wanted = [pairs[i] for i in ranked]
if wanted == pairs:
return
for el, (tag, text) in zip(elems, wanted):
el.tag = f'{{{MD_NS}}}{tag}'
el.text = text
modify_count += 1
info(f"Reordered type groups: {len(elems)} entries")
def do_remove_child_object(batch_val): def do_remove_child_object(batch_val):
nonlocal remove_count nonlocal remove_count
if child_objs_el is None: if child_objs_el is None:
@@ -651,7 +929,7 @@ def main():
if dot_idx < 1: if dot_idx < 1:
print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr) print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr)
sys.exit(1) sys.exit(1)
type_name = item[:dot_idx] type_name = resolve_type_name(item[:dot_idx]) or item[:dot_idx]
obj_name_val = item[dot_idx + 1:] obj_name_val = item[dot_idx + 1:]
found = False found = False
@@ -821,11 +1099,8 @@ def main():
nonlocal modify_count nonlocal modify_count
layout = value layout = value
if isinstance(layout, str): if isinstance(layout, str):
try: layout = ci_json(parse_json_input(
layout = ci_json(json.loads(layout)) layout, "-Value for operation 'set-panels'", "a JSON object with panel layout", inline=True))
except json.JSONDecodeError:
print(f"set-panels value must be valid JSON object", file=sys.stderr)
sys.exit(1)
if not isinstance(layout, dict) or not layout: if not isinstance(layout, dict) or not layout:
print("set-panels value must be non-empty object", file=sys.stderr) print("set-panels value must be non-empty object", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -874,24 +1149,6 @@ def main():
info(f"Wrote panel layout: {cai_path}") info(f"Wrote panel layout: {cai_path}")
# --- set-home-page (writes Ext/HomePageWorkArea.xml from scratch) --- # --- set-home-page (writes Ext/HomePageWorkArea.xml from scratch) ---
RU_TYPE_MAP = {
"справочник": "Catalog", "документ": "Document", "перечисление": "Enum",
"отчёт": "Report", "отчет": "Report", "обработка": "DataProcessor",
"общаяформа": "CommonForm", "журналдокументов": "DocumentJournal",
"планвидовхарактеристик": "ChartOfCharacteristicTypes",
"плансчетов": "ChartOfAccounts",
"планвидоврасчета": "ChartOfCalculationTypes",
"планвидоврасчёта": "ChartOfCalculationTypes",
"регистрсведений": "InformationRegister",
"регистрнакопления": "AccumulationRegister",
"регистрбухгалтерии": "AccountingRegister",
"регистррасчета": "CalculationRegister",
"регистррасчёта": "CalculationRegister",
"бизнеспроцесс": "BusinessProcess",
"бот": "Bot",
"задача": "Task", "планобмена": "ExchangePlan",
"хранилищенастроек": "SettingsStorage",
}
DIR_TO_TYPE = {v.lower(): k for k, v in TYPE_TO_DIR.items()} DIR_TO_TYPE = {v.lower(): k for k, v in TYPE_TO_DIR.items()}
UUID_RE = __import__("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}$") UUID_RE = __import__("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}$")
@@ -976,11 +1233,8 @@ def main():
nonlocal modify_count nonlocal modify_count
layout = value layout = value
if isinstance(layout, str): if isinstance(layout, str):
try: layout = ci_json(parse_json_input(
layout = ci_json(json.loads(layout)) layout, "-Value for operation 'set-home-page'", "a JSON object with home page layout", inline=True))
except json.JSONDecodeError:
print("set-home-page value must be valid JSON object", file=sys.stderr)
sys.exit(1)
if not isinstance(layout, dict) or not layout: if not isinstance(layout, dict) or not layout:
print("set-home-page value must be non-empty object", file=sys.stderr) print("set-home-page value must be non-empty object", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -1044,8 +1298,7 @@ def main():
def_file = args.DefinitionFile def_file = args.DefinitionFile
if not os.path.isabs(def_file): if not os.path.isabs(def_file):
def_file = os.path.join(os.getcwd(), def_file) def_file = os.path.join(os.getcwd(), def_file)
with open(def_file, "r", encoding="utf-8-sig") as fh: ops = ci_json(parse_json_input(read_json_file(def_file), def_file))
ops = ci_json(json.loads(fh.read()))
if isinstance(ops, list): if isinstance(ops, list):
operations = ops operations = ops
else: else:
@@ -1075,6 +1328,8 @@ def main():
do_set_panels(op_value) do_set_panels(op_value)
elif op_key == "set-home-page": elif op_key == "set-home-page":
do_set_home_page(op_value) do_set_home_page(op_value)
elif op_key == "sort-childobjects":
do_sort_child_objects(op_value if isinstance(op_value, str) else str(op_value))
else: else:
print(f"Unknown operation: {op_name}", file=sys.stderr) print(f"Unknown operation: {op_name}", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -23,7 +23,7 @@ allowed-tools:
| `OutFile` | Записать результат в файл (UTF-8 BOM) | | `OutFile` | Записать результат в файл (UTF-8 BOM) |
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-info.ps1" -ConfigPath "<путь>" powershell.exe -NoProfile -File ".roo/skills/cf-info/scripts/cf-info.ps1" -ConfigPath "<путь>"
``` ```
## Три режима ## Три режима
@@ -1,7 +1,8 @@
# cf-info v1.5 — Compact summary of 1C configuration root # cf-info v1.8 — Compact summary of 1C configuration root
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath, [Parameter(Mandatory=$true, Position=0)][Alias('Path')][string]$ConfigPath,
[ValidateSet("overview","brief","full")] [ValidateSet("overview","brief","full")]
[string]$Mode = "overview", [string]$Mode = "overview",
[Alias('Name')] [Alias('Name')]
@@ -85,14 +86,14 @@ function Get-PropML([string]$propName) {
return (Get-MLText $n) return (Get-MLText $n)
} }
# --- Type name maps (canonical order, 44 types) --- # --- Type name maps (canonical order, 46 types) ---
$typeOrder = @( $typeOrder = @(
"Language","Subsystem","StyleItem","Style", "Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate", "CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan", "FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference", "XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption", "EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup", "FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document", "Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum", "DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister", "Report","DataProcessor","InformationRegister","AccumulationRegister",
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cf-info v1.5 — Compact summary of 1C configuration root # cf-info v1.8 — Compact summary of 1C configuration root
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -61,11 +61,11 @@ if os.path.isdir(config_path):
if os.path.isfile(candidate): if os.path.isfile(candidate):
config_path = candidate config_path = candidate
else: else:
print(f"[ERROR] No Configuration.xml found in directory: {config_path}", file=sys.stderr) print(f"[ERROR] No Configuration.xml found in directory: {config_path}")
sys.exit(1) sys.exit(1)
if not os.path.isfile(config_path): if not os.path.isfile(config_path):
print(f"[ERROR] File not found: {config_path}", file=sys.stderr) print(f"[ERROR] File not found: {config_path}")
sys.exit(1) sys.exit(1)
# --- Load XML --- # --- Load XML ---
@@ -82,12 +82,12 @@ NS = {
md_root = xml_root # root is MetaDataObject itself md_root = xml_root # root is MetaDataObject itself
if etree.QName(md_root.tag).localname != "MetaDataObject": if etree.QName(md_root.tag).localname != "MetaDataObject":
print("[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)", file=sys.stderr) print("[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)")
sys.exit(1) sys.exit(1)
cfg_node = md_root.find("md:Configuration", NS) cfg_node = md_root.find("md:Configuration", NS)
if cfg_node is None: if cfg_node is None:
print("[ERROR] No <Configuration> element found", file=sys.stderr) print("[ERROR] No <Configuration> element found")
sys.exit(1) sys.exit(1)
version = md_root.get("version", "") version = md_root.get("version", "")
@@ -113,14 +113,14 @@ def get_prop_ml(prop_name):
n = props_node.find(f"md:{prop_name}", NS) n = props_node.find(f"md:{prop_name}", NS)
return get_ml_text(n) return get_ml_text(n)
# --- Type name maps (canonical order, 44 types) --- # --- Type name maps (canonical order, 46 types) ---
type_order = [ type_order = [
"Language", "Subsystem", "StyleItem", "Style", "Language", "Subsystem", "StyleItem", "Style",
"CommonPicture", "SessionParameter", "Role", "CommonTemplate", "CommonPicture", "SessionParameter", "Role", "CommonTemplate",
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan", "FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
"XDTOPackage", "WebService", "HTTPService", "WSReference", "XDTOPackage", "WebService", "HTTPService", "WSReference",
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption", "EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup", "FunctionalOptionsParameter", "DefinedType", "Bot", "PaletteColor", "CommonCommand", "CommandGroup",
"Constant", "CommonForm", "Catalog", "Document", "Constant", "CommonForm", "Catalog", "Document",
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum", "DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister", "Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
@@ -39,7 +39,7 @@ allowed-tools:
не будет. не будет.
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name "МояКонфигурация" powershell.exe -NoProfile -File ".roo/skills/cf-init/scripts/cf-init.ps1" -Name "МояКонфигурация"
``` ```
## Примеры ## Примеры
@@ -1,5 +1,6 @@
# cf-init v1.14 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи) # cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$Name, [string]$Name,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cf-init v1.14 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи) # cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C configuration.""" """Generates minimal XML source files for a 1C configuration."""
import sys, os, argparse, re, uuid import sys, os, argparse, re, uuid
@@ -24,6 +24,6 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty" powershell.exe -NoProfile -File ".roo/skills/cf-validate/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty"
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty/Configuration.xml" powershell.exe -NoProfile -File ".roo/skills/cf-validate/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty/Configuration.xml"
``` ```
@@ -1,7 +1,8 @@
# cf-validate v1.7 — Validate 1C configuration root structure # cf-validate v1.9 — Validate 1C configuration root structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory, Position=0)]
[Alias('Path')] [Alias('Path')]
[string]$ConfigPath, [string]$ConfigPath,
@@ -121,10 +122,10 @@ $validClassIds = @(
$childObjectTypes = @( $childObjectTypes = @(
"Language","Subsystem","StyleItem","Style", "Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate", "CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan", "FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference", "XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption", "EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup", "FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document", "Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum", "DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister", "Report","DataProcessor","InformationRegister","AccumulationRegister",
@@ -139,6 +140,7 @@ $childTypeDirMap = @{
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles" "CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules" "CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
"Bot"="Bots" "Bot"="Bots"
"PaletteColor"="PaletteColors"
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages" "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences" "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs" "EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cf-validate v1.7 — Validate 1C configuration XML structure # cf-validate v1.9 — Validate 1C configuration XML structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages.""" """Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
import sys, os, argparse, re import sys, os, argparse, re
@@ -59,10 +59,10 @@ VALID_CLASS_IDS = [
CHILD_OBJECT_TYPES = [ CHILD_OBJECT_TYPES = [
'Language', 'Subsystem', 'StyleItem', 'Style', 'Language', 'Subsystem', 'StyleItem', 'Style',
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate', 'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan', 'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference', 'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption', 'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup', 'FunctionalOptionsParameter', 'DefinedType', 'Bot', 'PaletteColor', 'CommonCommand', 'CommandGroup',
'Constant', 'CommonForm', 'Catalog', 'Document', 'Constant', 'CommonForm', 'Catalog', 'Document',
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum', 'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister', 'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
@@ -76,7 +76,7 @@ CHILD_TYPE_DIR_MAP = {
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles', 'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles', 'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules', 'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
'Bot': 'Bots', 'Bot': 'Bots', 'PaletteColor': 'PaletteColors',
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages', 'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences', 'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs', 'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
@@ -31,6 +31,7 @@ allowed-tools:
| `ExtensionPath` | Путь к каталогу расширения (обязат.) | | `ExtensionPath` | Путь к каталогу расширения (обязат.) |
| `ConfigPath` | Путь к конфигурации-источнику (обязат.) | | `ConfigPath` | Путь к конфигурации-источнику (обязат.) |
| `Object` | Что заимствовать (обязат.), batch через `;;` | | `Object` | Что заимствовать (обязат.), batch через `;;` |
| `Module` | Создать пустые модули объекта: `ObjectModule`, `ManagerModule`, `RecordSetModule`, `ValueManagerModule` (через запятую) или `None`. У типов с единственным модулем (`CommonModule`, `HTTPService`, `WebService`) он создаётся и без параметра |
| `BorrowMainAttribute` | Заимствовать основной реквизит формы. Без параметра — не заимствует. `Form` — реквизиты, используемые на форме. `All` — все реквизиты объекта. Требует форму в -Object | | `BorrowMainAttribute` | Заимствовать основной реквизит формы. Без параметра — не заимствует. `Form` — реквизиты, используемые на форме. `All` — все реквизиты объекта. Требует форму в -Object |
## Формат -Object ## Формат -Object
@@ -65,12 +66,12 @@ allowed-tools:
2. `/meta-edit` — добавить новый реквизит в объект расширения 2. `/meta-edit` — добавить новый реквизит в объект расширения
3. `/form-edit` — вывести реквизит на заимствованную форму 3. `/form-edit` — вывести реквизит на заимствованную форму
**Защита существующих данных**: если зависимый объект уже заимствован с содержимым (реквизитами, формами) — скрипт не перезаписывает его, а добавляет только недостающее. **Защита существующих данных**: уже заимствованный объект не перезаписывается — добавляется только недостающее. Повторный вызов безопасен: собственные реквизиты расширения, заимствованные подобъекты и код в модулях сохраняются.
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты" powershell.exe -NoProfile -File ".roo/skills/cfe-borrow/scripts/cfe-borrow.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
``` ```
## Примеры ## Примеры
@@ -79,6 +80,12 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -Ex
# Заимствовать один объект # Заимствовать один объект
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты" ... -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.Контрагенты.Form.ФормаЭлемента"
@@ -1,10 +1,12 @@
# cfe-borrow v1.31 — Borrow objects from configuration into extension (CFE) # cfe-borrow v1.36 — Borrow objects from configuration into extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)][string]$ExtensionPath, [Parameter(Mandatory)][string]$ExtensionPath,
[Parameter(Mandatory)][string]$ConfigPath, [Parameter(Mandatory)][string]$ConfigPath,
[Parameter(Mandatory)][string]$Object, [Parameter(Mandatory)][string]$Object,
[string]$BorrowMainAttribute [string]$BorrowMainAttribute,
[string]$Module
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -258,9 +260,35 @@ $childTypeDirMap = @{
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices" "Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices" "XDTOPackage"="XDTOPackages"; "WebService"="WebServices"
"HTTPService"="HTTPServices"; "WSReference"="WSReferences" "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "Bot"="Bots"; "Language"="Languages" "CommonAttribute"="CommonAttributes"; "Style"="Styles"; "Bot"="Bots"; "PaletteColor"="PaletteColors"; "Language"="Languages"
} }
# --- 4a. Модули заимствованных объектов ---
# Порядок внутри значения — порядок выгрузки Конфигуратора: сначала «объектный» модуль
# (ObjectModule / RecordSetModule / ValueManagerModule), затем ManagerModule.
$script:moduleKindsByType = @{
"CommonModule"=@("Module"); "HTTPService"=@("Module"); "WebService"=@("Module")
"Catalog"=@("ObjectModule","ManagerModule"); "Document"=@("ObjectModule","ManagerModule")
"Report"=@("ObjectModule","ManagerModule"); "DataProcessor"=@("ObjectModule","ManagerModule")
"ExchangePlan"=@("ObjectModule","ManagerModule")
"ChartOfCharacteristicTypes"=@("ObjectModule","ManagerModule")
"ChartOfAccounts"=@("ObjectModule","ManagerModule")
"ChartOfCalculationTypes"=@("ObjectModule","ManagerModule")
"BusinessProcess"=@("ObjectModule","ManagerModule"); "Task"=@("ObjectModule","ManagerModule")
"InformationRegister"=@("RecordSetModule","ManagerModule")
"AccumulationRegister"=@("RecordSetModule","ManagerModule")
"AccountingRegister"=@("RecordSetModule","ManagerModule")
"CalculationRegister"=@("RecordSetModule","ManagerModule")
"Sequence"=@("RecordSetModule","ManagerModule")
"Constant"=@("ValueManagerModule","ManagerModule")
"Enum"=@("ManagerModule"); "DocumentJournal"=@("ManagerModule")
"FilterCriterion"=@("ManagerModule")
}
# Типы с ЕДИНСТВЕННЫМ модулем: ради него объект и заимствуют, поэтому файл создаётся молча.
# Отказ — `-Module None`.
$script:autoModuleTypes = @("CommonModule", "HTTPService", "WebService")
$script:moduleKindNames = @("Module", "ObjectModule", "ManagerModule", "RecordSetModule", "ValueManagerModule")
# --- 4b. Russian synonym → English type --- # --- 4b. Russian synonym → English type ---
$synonymMap = @{ $synonymMap = @{
"Справочник"="Catalog"; "Документ"="Document"; "Перечисление"="Enum" "Справочник"="Catalog"; "Документ"="Document"; "Перечисление"="Enum"
@@ -282,14 +310,14 @@ $synonymMap = @{
"HTTPСервис"="HTTPService"; "СервисИнтеграции"="IntegrationService" "HTTPСервис"="HTTPService"; "СервисИнтеграции"="IntegrationService"
} }
# --- 5. Canonical type order (44 types) --- # --- 5. Canonical type order (46 types) ---
$script:typeOrder = @( $script:typeOrder = @(
"Language","Subsystem","StyleItem","Style", "Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate", "CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan", "FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference", "XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption", "EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup", "FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document", "Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum", "DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister", "Report","DataProcessor","InformationRegister","AccumulationRegister",
@@ -591,6 +619,50 @@ if ($BorrowMainAttribute) {
} }
} }
# --- 9c. Validate -Module ---
$script:requestedModules = @()
$script:noModule = $false
if ($Module) {
foreach ($raw in ($Module -split '[,;]')) {
$kind = $raw.Trim()
if (-not $kind) { continue }
# Сравнение РЕГИСТРОНЕЗАВИСИМОЕ явно (-ieq): в py-порте это отдельная ветка, и молчаливое
# расхождение портов на «none» ловится только глазами.
if ($kind -ieq "None") { $script:noModule = $true; continue }
$canon = @($script:moduleKindNames | Where-Object { $_ -ieq $kind })
if ($canon.Count -eq 0) {
Write-Error "Неизвестный вид модуля '$kind'. Допустимо: $($script:moduleKindNames -join ', '), None"
exit 1
}
$script:requestedModules += $canon[0]
}
if ($script:noModule -and $script:requestedModules.Count -gt 0) {
Write-Error "-Module None нельзя сочетать с видами модулей"
exit 1
}
}
# Какие модули создать для объекта. Тип с единственным модулем получает его всегда — уточнять
# там нечего; -Module разбирает только неоднозначные типы. Иначе батч смешанных типов
# (`CommonModule.X ;; Catalog.Y`) не выражался бы одним вызовом.
function Resolve-ModuleKinds {
param([string]$typeName)
if ($script:noModule) { return @() }
$allowed = @($script:moduleKindsByType[$typeName])
if ($allowed.Count -eq 0) { return @() }
if ($script:autoModuleTypes -contains $typeName) { return @($allowed[0]) }
if ($script:requestedModules.Count -eq 0) { return @() }
# Порядок берём из таблицы типа, а не из порядка ключей в -Module.
$selected = @($allowed | Where-Object { $script:requestedModules -contains $_ })
if ($selected.Count -eq 0) {
Warn " Тип $typeName не имеет запрошенных модулей — пропущено. Допустимо: $($allowed -join ', ')"
}
return $selected
}
# --- 10. Helper: read source object XML --- # --- 10. Helper: read source object XML ---
# Имена реквизитов исходного объекта → uuid. Нужны для непрозрачной формы пути в ссылках # Имена реквизитов исходного объекта → uuid. Нужны для непрозрачной формы пути в ссылках
# параметров выбора (см. Rewrite-ChoiceParameterLinks). # параметров выбора (см. Rewrite-ChoiceParameterLinks).
@@ -1275,32 +1347,22 @@ function Register-FormInObject {
} }
# Save object XML # Save object XML
# Стиль исходника снимаем ДО записи: правка чужого файла наследует его BOM/EOL/заголовок
# (#44/#46/#47), новый файл получает канон выгрузки. Зеркало _detect_xml_style в py-порту.
$style2 = Detect-XmlStyle $objFile
$settings2 = New-Object System.Xml.XmlWriterSettings $settings2 = New-Object System.Xml.XmlWriterSettings
$settings2.Encoding = New-Object System.Text.UTF8Encoding($true) $settings2.Encoding = New-Object System.Text.UTF8Encoding($true)
$settings2.Indent = $false $settings2.Indent = $false
$settings2.NewLineHandling = [System.Xml.NewLineHandling]::None $settings2.NewLineHandling = [System.Xml.NewLineHandling]::None
$memStream2 = New-Object System.IO.MemoryStream $memStream2 = New-Object System.IO.MemoryStream
$writer2 = [System.Xml.XmlWriter]::Create($memStream2, $settings2) $writer2 = [System.Xml.XmlWriter]::Create($memStream2, $settings2)
$objDoc.Save($writer2) $objDoc.Save($writer2)
$writer2.Flush(); $writer2.Close() $writer2.Flush(); $writer2.Close()
$text2 = [System.Text.Encoding]::UTF8.GetString($memStream2.ToArray())
$bytes2 = $memStream2.ToArray()
$memStream2.Close() $memStream2.Close()
$text2 = [System.Text.Encoding]::UTF8.GetString($bytes2) $text2 = Finalize-XmlText $text2 $style2
if ($text2.Length -gt 0 -and $text2[0] -eq [char]0xFEFF) { $text2 = $text2.Substring(1) } $writeBom2 = ($null -eq $style2) -or $style2.bom
$text2 = $text2.Replace('encoding="utf-8"', 'encoding="UTF-8"') [System.IO.File]::WriteAllText($objFile, $text2, (New-Object System.Text.UTF8Encoding($writeBom2)))
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
$text2 = [regex]::Replace($text2, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
$utf8Bom2 = New-Object System.Text.UTF8Encoding($true)
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
$targetEol = if ((Test-Path -LiteralPath $objFile) -and ([System.IO.File]::ReadAllText($objFile) -notmatch "`r`n")) { "`n" } else { "`r`n" }
$text2 = ($text2 -replace "`r`n", "`n") -replace "`n", $targetEol
[System.IO.File]::WriteAllText($objFile, $text2, $utf8Bom2)
Info " Registered form in: $objFile" Info " Registered form in: $objFile"
} }
@@ -1313,6 +1375,81 @@ function Test-ObjectBorrowed {
return (Test-Path $objFile) return (Test-Path $objFile)
} }
# --- 10f. Helper: пометка расширенного свойства (<xr:PropertyState>) ---
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке — эмитим, чтобы исходники навыка
# совпадали с эталоном. Имя свойства = базовое имя файла модуля (Module / ObjectModule / …),
# у заимствованной формы — Form. Ставит тот, кто создал файл модуля (или форму).
function Build-PropertyStateXml {
param([string]$propertyName, [string]$indent)
$sb = New-Object System.Text.StringBuilder
$sb.AppendLine("${indent}<xr:PropertyState>") | Out-Null
$sb.AppendLine("${indent}`t<xr:Property>${propertyName}</xr:Property>") | Out-Null
$sb.AppendLine("${indent}`t<xr:State>Extended</xr:State>") | Out-Null
$sb.Append("${indent}</xr:PropertyState>") | Out-Null
return $sb.ToString()
}
function Set-PropertyStateFlag {
param([string]$objFile, [string]$propertyName, [string]$formatVersion)
if ((Get-FormatRank $formatVersion) -lt 219) { return }
if (-not (Test-Path $objFile)) { return }
$enc = New-Object System.Text.UTF8Encoding($true)
$text = [System.IO.File]::ReadAllText($objFile, $enc)
$nl = if ($text -match "`r`n") { "`r`n" } else { "`n" }
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
# но они лежат ниже, внутри <ChildObjects>.
$empty = [regex]::Match($text, '([ \t]*)<InternalInfo\s*/>')
$open = [regex]::Match($text, '(?s)([ \t]*)<InternalInfo>(.*?)</InternalInfo>')
if ($empty.Success -and (-not $open.Success -or $empty.Index -lt $open.Index)) {
$ind = $empty.Groups[1].Value
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
$replacement = "${ind}<InternalInfo>${nl}${block}${nl}${ind}</InternalInfo>"
$text = $text.Remove($empty.Index, $empty.Length).Insert($empty.Index, $replacement)
} elseif ($open.Success) {
if ($open.Groups[2].Value -match "<xr:Property>$([regex]::Escape($propertyName))</xr:Property>") { return }
$ind = $open.Groups[1].Value
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
$closeAt = $open.Index + $open.Length - "</InternalInfo>".Length - $ind.Length
$text = $text.Insert($closeAt, "${block}${nl}")
} else {
return
}
[System.IO.File]::WriteAllText($objFile, $text, $enc)
}
# --- 10g. Helper: пустой модуль заимствованного объекта ---
function New-BorrowedModuleFile {
param([string]$typeName, [string]$objName, [string]$moduleKind)
$dirName = $childTypeDirMap[$typeName]
$objDir = Join-Path (Join-Path $extDir $dirName) $objName
$moduleDir = Join-Path $objDir "Ext"
if (-not (Test-Path $moduleDir)) { New-Item -ItemType Directory -Path $moduleDir -Force | Out-Null }
# NEVER overwrite an existing one: повторное заимствование не должно затирать дописанный код
# (то же правило, что у модуля формы).
$moduleFile = Join-Path $moduleDir "${moduleKind}.bsl"
if (Test-Path $moduleFile) {
Info " Preserved existing ${moduleKind}.bsl"
} else {
$enc = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($moduleFile, "", $enc)
Info " Created: $moduleFile"
}
# Флаг ставим и для уже существовавшего файла: состояние объекта должно отражать факт модуля.
Set-PropertyStateFlag (Join-Path (Join-Path $extDir $dirName) "${objName}.xml") $moduleKind $script:formatVersion
return $moduleFile
}
# --- 11. Helper: generate InternalInfo XML --- # --- 11. Helper: generate InternalInfo XML ---
function Build-InternalInfoXml { function Build-InternalInfoXml {
param([string]$typeName, [string]$objName, [string]$indent) param([string]$typeName, [string]$objName, [string]$indent)
@@ -1738,6 +1875,9 @@ function Merge-AttributesIntoObject {
} }
# Save via text manipulation to avoid namespace issues with InnerXml # Save via text manipulation to avoid namespace issues with InnerXml
# Стиль исходника снимаем ДО записи: правка чужого файла наследует его BOM/EOL/заголовок
# (#44/#46/#47), новый файл получает канон выгрузки. Зеркало _detect_xml_style в py-порту.
$style3 = Detect-XmlStyle $objFile
$settings3 = New-Object System.Xml.XmlWriterSettings $settings3 = New-Object System.Xml.XmlWriterSettings
$settings3.Encoding = New-Object System.Text.UTF8Encoding($true) $settings3.Encoding = New-Object System.Text.UTF8Encoding($true)
$settings3.Indent = $false $settings3.Indent = $false
@@ -1746,28 +1886,15 @@ function Merge-AttributesIntoObject {
$writer3 = [System.Xml.XmlWriter]::Create($memStream3, $settings3) $writer3 = [System.Xml.XmlWriter]::Create($memStream3, $settings3)
$objDoc.Save($writer3) $objDoc.Save($writer3)
$writer3.Flush(); $writer3.Close() $writer3.Flush(); $writer3.Close()
$bytes3 = $memStream3.ToArray() $text3 = [System.Text.Encoding]::UTF8.GetString($memStream3.ToArray())
$memStream3.Close() $memStream3.Close()
$text3 = [System.Text.Encoding]::UTF8.GetString($bytes3)
if ($text3.Length -gt 0 -and $text3[0] -eq [char]0xFEFF) { $text3 = $text3.Substring(1) }
$text3 = $text3.Replace('encoding="utf-8"', 'encoding="UTF-8"')
# Самозакрытый элемент раскрывается текстом, а не пробельным узлом в DOM: тот давал # Самозакрытый элемент раскрывается текстом, а не пробельным узлом в DOM: тот давал
# лишнюю строку с табуляцией перед первым <Attribute> (у Конфигуратора пустых строк нет). # лишнюю строку с табуляцией перед первым <Attribute> (у Конфигуратора пустых строк нет).
# Стоит ДО Finalize-XmlText, чтобы схлопывание пустых тегов накрыло и вставленные реквизиты.
$text3 = Insert-IntoOwnChildObjects $text3 $allAttrXml $text3 = Insert-IntoOwnChildObjects $text3 $allAttrXml
$text3 = Finalize-XmlText $text3 $style3
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри $writeBom3 = ($null -eq $style3) -or $style3.bom
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется), [System.IO.File]::WriteAllText($objFile, $text3, (New-Object System.Text.UTF8Encoding($writeBom3)))
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
# Стоит ПОСЛЕ вставки реквизитов, чтобы накрыть и их.
$text3 = [regex]::Replace($text3, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
$utf8Bom3 = New-Object System.Text.UTF8Encoding($true)
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
$targetEol = if ((Test-Path -LiteralPath $objFile) -and ([System.IO.File]::ReadAllText($objFile) -notmatch "`r`n")) { "`n" } else { "`r`n" }
$text3 = ($text3 -replace "`r`n", "`n") -replace "`n", $targetEol
[System.IO.File]::WriteAllText($objFile, $text3, $utf8Bom3)
Info " Merged $added attribute(s) into: $objFile" Info " Merged $added attribute(s) into: $objFile"
} }
} }
@@ -2083,6 +2210,127 @@ function Build-BorrowedObjectXml {
} }
# --- 13. Helper: add object to extension ChildObjects --- # --- 13. Helper: add object to extension ChildObjects ---
# Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
# финальный перенос. $null → файл новый (сохранить текущее поведение).
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Detect-XmlStyle([string]$path) {
if (-not (Test-Path -LiteralPath $path)) { return $null }
$raw = [System.IO.File]::ReadAllBytes($path)
$bom = ($raw.Length -ge 3 -and $raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF)
$body = if ($bom) { [System.Text.Encoding]::UTF8.GetString($raw, 3, $raw.Length - 3) } else { [System.Text.Encoding]::UTF8.GetString($raw) }
$head = if ($body.Length -gt 200) { $body.Substring(0, 200) } else { $body }
$m = [regex]::Match($head, 'encoding="([^"]+)"')
return @{
bom = $bom
crlf = $body.Contains("`r`n")
enc = $(if ($m.Success) { $m.Groups[1].Value } else { "utf-8" })
finalNl = $body.EndsWith("`n")
}
}
# Привести текст XmlWriter к стилю оригинала; для НОВОГО файла ($null) — к канону выгрузки
# Конфигуратора: encoding="UTF-8", CRLF, без перевода строки в конце.
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Finalize-XmlText([string]$text, $style) {
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
$encDecl = $(if ($style) { $style.enc } else { "UTF-8" })
$text = $text.Replace('encoding="utf-8"', 'encoding="' + $encDecl + '"')
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
$text = ($text -replace "`r`n", "`n").TrimEnd("`n")
if ($style -and $style.finalNl) { $text += "`n" }
if (-not $style -or $style.crlf) { $text = $text -replace "`n", "`r`n" }
return $text
}
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
}
# Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
# databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
# иначе корневое поле, иначе end. Значения: end — после последнего объекта того же вида
# (так дописывает Конфигуратор); byName — по имени среди объектов того же вида.
# Файл ищем от рабочего каталога вверх, каталог конфигурации — запасной путь: так же
# его ищут support-guard и группа db-*, а скрипт навыка зовут по абсолютному пути, и cwd
# остаётся рабочим каталогом проекта.
# configSrc считается от каталога .v8-project.json, как задокументировано в
# docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
function Get-NewObjectPosition([string]$cfgDir) {
try {
if (-not $cfgDir) { $cfgDir = "." }
$pj = Find-V8Project (Get-Location).Path
if (-not $pj) { $pj = Find-V8Project ([System.IO.Path]::GetFullPath($cfgDir)) }
if (-not $pj) { return "end" }
$proj = Get-Content -Raw $pj | ConvertFrom-Json
$projDir = [System.IO.Path]::GetDirectoryName($pj)
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
if ($proj.databases) {
foreach ($db in $proj.databases) {
if ($db.configSrc -and $db.newObjectPosition) {
$src = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($projDir, $db.configSrc)).TrimEnd('\', '/')
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
if ("$($db.newObjectPosition)" -eq "byName") { return "byName" }
return "end"
}
}
}
}
if ("$($proj.newObjectPosition)" -eq "byName") { return "byName" }
return "end"
} catch { return "end" }
}
# Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
# CommonAttribute — исключение самого стандарта (#std467): у общих реквизитов-разделителей
# порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
# пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
# порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
# (в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
# без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
# Явно названный вид сортируется в любом случае.
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Test-OrderSensitiveType([string]$typeName) {
return @("CommonAttribute", "Subsystem", "CommandGroup", "Language") -ccontains $typeName
}
# Порядок имён объектов метаданных, как в дереве Конфигуратора.
# Ключ — пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
# букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
# используются — они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
# одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
# Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
function Compare-MetadataNames([string]$a, [string]$b) {
$keys = @("", "")
$names = @($a, $b)
for ($i = 0; $i -lt 2; $i++) {
$sb = New-Object System.Text.StringBuilder
foreach ($ch in $names[$i].ToLowerInvariant().ToCharArray()) {
if ($ch -eq [char]0x0451) { $ch = [char]0x0435 }
if ([char]::IsDigit($ch)) { [void]$sb.Append('1') }
elseif ([char]::IsLetter($ch)) { [void]$sb.Append('2') }
else { [void]$sb.Append('0') }
[void]$sb.Append($ch)
}
$keys[$i] = $sb.ToString()
}
$r = [string]::CompareOrdinal($keys[0], $keys[1])
if ($r -eq 0) { $r = [string]::CompareOrdinal($a, $b) }
if ($r -lt 0) { return -1 }
if ($r -gt 0) { return 1 }
return 0
}
function Add-ToChildObjects { function Add-ToChildObjects {
param([string]$typeName, [string]$objName) param([string]$typeName, [string]$objName)
@@ -2108,7 +2356,12 @@ function Add-ToChildObjects {
} }
} }
# Find insertion point: after last element of same type, or before first element of later type # Место вставки. Вид — по $script:typeOrder; внутри вида — по newObjectPosition: end
# (по умолчанию) кладёт после последнего объекта того же вида, byName — по имени. Так же
# заимствует Конфигуратор: в боевых выгрузках расширений ChildObjects не отсортирован.
# Subsystem по имени не упорядочиваем никогда: порядок подсистем в дереве задаёт порядок
# разделов в панели.
$byName = (-not (Test-OrderSensitiveType $typeName) -and (Get-NewObjectPosition $extDir) -eq "byName")
$insertBefore = $null $insertBefore = $null
$lastSameType = $null $lastSameType = $null
@@ -2118,8 +2371,7 @@ function Add-ToChildObjects {
if ($childTypeIdx -lt 0) { continue } if ($childTypeIdx -lt 0) { continue }
if ($child.LocalName -eq $typeName) { if ($child.LocalName -eq $typeName) {
# Same type -- check alphabetical order if ($byName -and -not $insertBefore -and (Compare-MetadataNames $child.InnerText $objName) -gt 0) {
if ($child.InnerText -gt $objName -and -not $insertBefore) {
$insertBefore = $child $insertBefore = $child
} }
$lastSameType = $child $lastSameType = $child
@@ -2204,6 +2456,9 @@ foreach ($item in $items) {
$hasBMA = [bool]$BorrowMainAttribute $hasBMA = [bool]$BorrowMainAttribute
$formFiles = Borrow-Form $typeName $objName $formName -BorrowMainAttr:$hasBMA $formFiles = Borrow-Form $typeName $objName $formName -BorrowMainAttr:$hasBMA
$script:borrowedFiles += $formFiles $script:borrowedFiles += $formFiles
# Замер на 8.3.26: платформа помечает форму расширенной сразу при заимствовании,
# даже если элементы не менялись. Флаг живёт в метаданных формы, не у владельца.
Set-PropertyStateFlag $formFiles[0] "Form" $script:formatVersion
$borrowedCount++ $borrowedCount++
# Borrow main attribute if requested # Borrow main attribute if requested
@@ -2212,26 +2467,37 @@ foreach ($item in $items) {
} }
} else { } else {
# --- Object borrowing (existing logic) --- # --- Object borrowing (existing logic) ---
Info "Borrowing ${typeName}.${objName}..."
$src = Read-SourceObject $typeName $objName
Info " Source UUID: $($src.Uuid)"
$borrowedXml = Build-BorrowedObjectXml $typeName $objName $src.Uuid $src.Properties
$targetDir = Join-Path $extDir $dirName $targetDir = Join-Path $extDir $dirName
if (-not (Test-Path $targetDir)) {
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
}
$targetFile = Join-Path $targetDir "${objName}.xml" $targetFile = Join-Path $targetDir "${objName}.xml"
$enc = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($targetFile, $borrowedXml, $enc) # Уже заимствованный объект НЕ переписываем: в его XML лежат собственные реквизиты
Info " Created: $targetFile" # расширения, заимствованные подобъекты и состояния, которые из источника не выводятся.
# Повторный вызов — законный способ доделать модуль (-Module), а не переиздать заготовку.
if (Test-ObjectBorrowed $typeName $objName) {
Info "Already borrowed: ${typeName}.${objName} — XML сохранён без изменений"
} else {
Info "Borrowing ${typeName}.${objName}..."
$src = Read-SourceObject $typeName $objName
Info " Source UUID: $($src.Uuid)"
$borrowedXml = Build-BorrowedObjectXml $typeName $objName $src.Uuid $src.Properties
if (-not (Test-Path $targetDir)) {
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
}
$enc = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($targetFile, $borrowedXml, $enc)
Info " Created: $targetFile"
}
Add-ToChildObjects $typeName $objName Add-ToChildObjects $typeName $objName
$script:borrowedFiles += $targetFile $script:borrowedFiles += $targetFile
foreach ($kind in (Resolve-ModuleKinds $typeName)) {
$script:borrowedFiles += (New-BorrowedModuleFile $typeName $objName $kind)
}
$borrowedCount++ $borrowedCount++
} }
} }
@@ -2278,32 +2544,22 @@ while ($true) {
} }
# --- 15. Save modified Configuration.xml --- # --- 15. Save modified Configuration.xml ---
# Стиль исходника снимаем ДО записи: правка чужого файла наследует его BOM/EOL/заголовок
# (#44/#46/#47), новый файл получает канон выгрузки. Зеркало _detect_xml_style в py-порту.
$style = Detect-XmlStyle $extResolvedPath
$settings = New-Object System.Xml.XmlWriterSettings $settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($true) $settings.Encoding = New-Object System.Text.UTF8Encoding($true)
$settings.Indent = $false $settings.Indent = $false
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None $settings.NewLineHandling = [System.Xml.NewLineHandling]::None
$memStream = New-Object System.IO.MemoryStream $memStream = New-Object System.IO.MemoryStream
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings) $writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
$script:xmlDoc.Save($writer) $script:xmlDoc.Save($writer)
$writer.Flush(); $writer.Close() $writer.Flush(); $writer.Close()
$text = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
$bytes = $memStream.ToArray()
$memStream.Close() $memStream.Close()
$text = [System.Text.Encoding]::UTF8.GetString($bytes) $text = Finalize-XmlText $text $style
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) } $writeBom = ($null -eq $style) -or $style.bom
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"') [System.IO.File]::WriteAllText($extResolvedPath, $text, (New-Object System.Text.UTF8Encoding($writeBom)))
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
$targetEol = if ((Test-Path -LiteralPath $extResolvedPath) -and ([System.IO.File]::ReadAllText($extResolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
[System.IO.File]::WriteAllText($extResolvedPath, $text, $utf8Bom)
Info "Saved: $extResolvedPath" Info "Saved: $extResolvedPath"
# --- 16. Summary --- # --- 16. Summary ---
@@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cfe-borrow v1.31 — Borrow objects from configuration into extension (CFE) # cfe-borrow v1.36 — Borrow objects from configuration into extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
import json
import os import os
import re import re
import sys import sys
@@ -202,6 +203,97 @@ def decode_numeric_entities(s):
return s return s
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 get_new_object_position(cfg_dir):
"""Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
иначе корневое поле, иначе end. Значения: end после последнего объекта того же вида
(так дописывает Конфигуратор); byName по имени среди объектов того же вида.
Файл ищем от рабочего каталога вверх, каталог конфигурации запасной путь: так же
его ищут support-guard и группа db-*, а скрипт навыка зовут по абсолютному пути, и cwd
остаётся рабочим каталогом проекта.
configSrc считается от каталога .v8-project.json, как задокументировано в
docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
"""
try:
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(os.path.abspath(cfg_dir or "."))
if not pj:
return "end"
proj = json.loads(open(pj, encoding="utf-8-sig").read())
proj_dir = os.path.dirname(pj)
cfg_full = os.path.normcase(os.path.abspath(cfg_dir or ".")).rstrip("\\/")
for db in proj.get("databases", []):
src = db.get("configSrc")
if src and db.get("newObjectPosition"):
src_full = os.path.normcase(os.path.abspath(os.path.join(proj_dir, src))).rstrip("\\/")
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
return "byName" if str(db["newObjectPosition"]).lower() == "byname" else "end"
if str(proj.get("newObjectPosition") or "").lower() == "byname":
return "byName"
return "end"
except Exception:
return "end"
def is_order_sensitive_type(type_name):
"""Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
CommonAttribute исключение самого стандарта (#std467): у общих реквизитов-разделителей
порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
(в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
Явно названный вид сортируется в любом случае.
Реестр семьи: tests/skills/check-inline-drift.mjs.
"""
return type_name in ("CommonAttribute", "Subsystem", "CommandGroup", "Language")
def compare_metadata_names(a, b):
"""Порядок имён объектов метаданных, как в дереве Конфигуратора.
Ключ пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
используются они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
"""
keys = []
for name in (a, b):
parts = []
for ch in name.lower():
if ch == "ё":
ch = "е"
if ch.isdigit():
parts.append("1" + ch)
elif ch.isalpha():
parts.append("2" + ch)
else:
parts.append("0" + ch)
keys.append("".join(parts))
if keys[0] != keys[1]:
return -1 if keys[0] < keys[1] else 1
if a != b:
return -1 if a < b else 1
return 0
def localname(el): def localname(el):
return etree.QName(el.tag).localname return etree.QName(el.tag).localname
@@ -237,9 +329,35 @@ CHILD_TYPE_DIR_MAP = {
"XDTOPackage": "XDTOPackages", "WebService": "WebServices", "XDTOPackage": "XDTOPackages", "WebService": "WebServices",
"HTTPService": "HTTPServices", "WSReference": "WSReferences", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
"CommonAttribute": "CommonAttributes", "Style": "Styles", "CommonAttribute": "CommonAttributes", "Style": "Styles",
"Bot": "Bots", "Language": "Languages", "Bot": "Bots", "PaletteColor": "PaletteColors", "PaletteColor": "PaletteColors", "Language": "Languages",
} }
# --- Модули заимствованных объектов ---
# Порядок внутри значения — порядок выгрузки Конфигуратора: сначала «объектный» модуль
# (ObjectModule / RecordSetModule / ValueManagerModule), затем ManagerModule.
MODULE_KINDS_BY_TYPE = {
"CommonModule": ["Module"], "HTTPService": ["Module"], "WebService": ["Module"],
"Catalog": ["ObjectModule", "ManagerModule"], "Document": ["ObjectModule", "ManagerModule"],
"Report": ["ObjectModule", "ManagerModule"], "DataProcessor": ["ObjectModule", "ManagerModule"],
"ExchangePlan": ["ObjectModule", "ManagerModule"],
"ChartOfCharacteristicTypes": ["ObjectModule", "ManagerModule"],
"ChartOfAccounts": ["ObjectModule", "ManagerModule"],
"ChartOfCalculationTypes": ["ObjectModule", "ManagerModule"],
"BusinessProcess": ["ObjectModule", "ManagerModule"], "Task": ["ObjectModule", "ManagerModule"],
"InformationRegister": ["RecordSetModule", "ManagerModule"],
"AccumulationRegister": ["RecordSetModule", "ManagerModule"],
"AccountingRegister": ["RecordSetModule", "ManagerModule"],
"CalculationRegister": ["RecordSetModule", "ManagerModule"],
"Sequence": ["RecordSetModule", "ManagerModule"],
"Constant": ["ValueManagerModule", "ManagerModule"],
"Enum": ["ManagerModule"], "DocumentJournal": ["ManagerModule"],
"FilterCriterion": ["ManagerModule"],
}
# Типы с ЕДИНСТВЕННЫМ модулем: ради него объект и заимствуют, поэтому файл создаётся молча.
# Отказ — `-Module None`.
AUTO_MODULE_TYPES = ["CommonModule", "HTTPService", "WebService"]
MODULE_KIND_NAMES = ["Module", "ObjectModule", "ManagerModule", "RecordSetModule", "ValueManagerModule"]
SYNONYM_MAP = { SYNONYM_MAP = {
"\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a": "Catalog", "\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a": "Catalog",
"\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442": "Document", "\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442": "Document",
@@ -281,10 +399,10 @@ SYNONYM_MAP = {
TYPE_ORDER = [ TYPE_ORDER = [
"Language", "Subsystem", "StyleItem", "Style", "Language", "Subsystem", "StyleItem", "Style",
"CommonPicture", "SessionParameter", "Role", "CommonTemplate", "CommonPicture", "SessionParameter", "Role", "CommonTemplate",
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan", "FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
"XDTOPackage", "WebService", "HTTPService", "WSReference", "XDTOPackage", "WebService", "HTTPService", "WSReference",
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption", "EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup", "FunctionalOptionsParameter", "DefinedType", "Bot", "PaletteColor", "CommonCommand", "CommandGroup",
"Constant", "CommonForm", "Catalog", "Document", "Constant", "CommonForm", "Catalog", "Document",
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum", "DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister", "Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
@@ -516,6 +634,55 @@ def format_rank(ver):
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0 return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
# Копии этих функций есть в cfe-patch-method (навыки автономны); держать их одинаковыми — сознательно.
def build_property_state_xml(property_name, indent):
return "\n".join([
f"{indent}<xr:PropertyState>",
f"{indent}\t<xr:Property>{property_name}</xr:Property>",
f"{indent}\t<xr:State>Extended</xr:State>",
f"{indent}</xr:PropertyState>",
])
def set_property_state_flag(obj_file, property_name, format_version):
if format_rank(format_version) < 219:
return
if not os.path.isfile(obj_file):
return
with open(obj_file, "r", encoding="utf-8-sig", newline="") as fh:
text = fh.read()
nl = "\r\n" if "\r\n" in text else "\n"
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
# но они лежат ниже, внутри <ChildObjects>.
empty = re.search(r"([ \t]*)<InternalInfo\s*/>", text)
opened = re.search(r"([ \t]*)<InternalInfo>(.*?)</InternalInfo>", text, re.S)
if empty and (not opened or empty.start() < opened.start()):
ind = empty.group(1)
block = build_property_state_xml(property_name, ind + "\t")
replacement = f"{ind}<InternalInfo>{nl}{block}{nl}{ind}</InternalInfo>"
text = text[:empty.start()] + replacement + text[empty.end():]
elif opened:
if re.search(rf"<xr:Property>{re.escape(property_name)}</xr:Property>", opened.group(2)):
return
ind = opened.group(1)
block = build_property_state_xml(property_name, ind + "\t")
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
close_at = opened.end() - len("</InternalInfo>") - len(ind)
text = text[:close_at] + block + nl + text[close_at:]
else:
return
with open(obj_file, "w", encoding="utf-8-sig", newline="") as fh:
fh.write(text)
def apply_pal_ns(format_version): def apply_pal_ns(format_version):
"""2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления. """2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту, Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
@@ -655,6 +822,7 @@ def main():
parser.add_argument("-ConfigPath", required=True) parser.add_argument("-ConfigPath", required=True)
parser.add_argument("-Object", required=True) parser.add_argument("-Object", required=True)
parser.add_argument("-BorrowMainAttribute", nargs="?", const="Form", default=None) parser.add_argument("-BorrowMainAttribute", nargs="?", const="Form", default=None)
parser.add_argument("-Module", default=None)
args = ci_parse_args(parser) args = ci_parse_args(parser)
# --- 1. Resolve paths --- # --- 1. Resolve paths ---
@@ -860,6 +1028,25 @@ def main():
sys.exit(1) sys.exit(1)
return src_uuid return src_uuid
# --- Пустой модуль заимствованного объекта ---
def new_borrowed_module_file(type_name, obj_name, module_kind):
dir_name = CHILD_TYPE_DIR_MAP[type_name]
module_dir = os.path.join(ext_dir, dir_name, obj_name, "Ext")
os.makedirs(module_dir, exist_ok=True)
# NEVER overwrite an existing one: повторное заимствование не должно затирать дописанный
# код (то же правило, что у модуля формы).
module_file = os.path.join(module_dir, f"{module_kind}.bsl")
if os.path.isfile(module_file):
info(f" Preserved existing {module_kind}.bsl")
else:
write_utf8_bom(module_file, "")
info(f" Created: {module_file}")
# Флаг ставим и для уже существовавшего файла: состояние объекта должно отражать факт модуля.
set_property_state_flag(os.path.join(ext_dir, dir_name, f"{obj_name}.xml"), module_kind, format_version)
return module_file
def build_internal_info_xml(type_name, obj_name, indent): def build_internal_info_xml(type_name, obj_name, indent):
types = GENERATED_TYPES.get(type_name) types = GENERATED_TYPES.get(type_name)
if not types: if not types:
@@ -940,6 +1127,13 @@ def main():
warn(f"Already in ChildObjects: {type_name}.{obj_name}") warn(f"Already in ChildObjects: {type_name}.{obj_name}")
return return
# Место вставки. Вид — по TYPE_ORDER; внутри вида — по newObjectPosition:
# end (по умолчанию) кладёт после последнего объекта того же вида, byName — по имени.
# Так же, как заимствует Конфигуратор: в боевых выгрузках расширений ChildObjects
# не отсортирован. Subsystem по имени не упорядочиваем никогда: порядок подсистем
# в дереве задаёт порядок разделов в панели.
by_name = (not is_order_sensitive_type(type_name)
and get_new_object_position(ext_dir) == "byName")
insert_before = None insert_before = None
for child in child_objs_el: for child in child_objs_el:
if not isinstance(child.tag, str): if not isinstance(child.tag, str):
@@ -950,7 +1144,8 @@ def main():
child_type_idx = TYPE_ORDER.index(child_type_name) child_type_idx = TYPE_ORDER.index(child_type_name)
if child_type_name == type_name: if child_type_name == type_name:
if (child.text or "") > obj_name and insert_before is None: if (by_name and insert_before is None
and compare_metadata_names(child.text or "", obj_name) > 0):
insert_before = child insert_before = child
elif child_type_idx > type_idx and insert_before is None: elif child_type_idx > type_idx and insert_before is None:
insert_before = child insert_before = child
@@ -2019,6 +2214,47 @@ def main():
print("-BorrowMainAttribute requires a form in -Object (e.g. 'Catalog.X.Form.Y')", file=sys.stderr) print("-BorrowMainAttribute requires a form in -Object (e.g. 'Catalog.X.Form.Y')", file=sys.stderr)
sys.exit(1) sys.exit(1)
# --- 9c. Validate -Module ---
requested_modules = []
no_module = False
if args.Module:
for raw in re.split(r"[,;]", args.Module):
kind = raw.strip()
if not kind:
continue
# Сравнение РЕГИСТРОНЕЗАВИСИМОЕ явно: в ps1-порте `-ieq`, и молчаливое расхождение
# портов на «none» ловится только глазами.
if kind.lower() == "none":
no_module = True
continue
canon = [k for k in MODULE_KIND_NAMES if k.lower() == kind.lower()]
if not canon:
print(f"Неизвестный вид модуля '{kind}'. Допустимо: {', '.join(MODULE_KIND_NAMES)}, None", file=sys.stderr)
sys.exit(1)
requested_modules.append(canon[0])
if no_module and requested_modules:
print("-Module None нельзя сочетать с видами модулей", file=sys.stderr)
sys.exit(1)
# Какие модули создать для объекта. Тип с единственным модулем получает его всегда — уточнять
# там нечего; -Module разбирает только неоднозначные типы. Иначе батч смешанных типов
# (`CommonModule.X ;; Catalog.Y`) не выражался бы одним вызовом.
def resolve_module_kinds(type_name):
if no_module:
return []
allowed = MODULE_KINDS_BY_TYPE.get(type_name, [])
if not allowed:
return []
if type_name in AUTO_MODULE_TYPES:
return [allowed[0]]
if not requested_modules:
return []
# Порядок берём из таблицы типа, а не из порядка ключей в -Module.
selected = [k for k in allowed if k in requested_modules]
if not selected:
warn(f" Тип {type_name} не имеет запрошенных модулей — пропущено. Допустимо: {', '.join(allowed)}")
return selected
# --- 10. Process each item --- # --- 10. Process each item ---
borrowed_count = 0 borrowed_count = 0
@@ -2070,6 +2306,9 @@ def main():
has_bma = borrow_main_attribute_mode is not None has_bma = borrow_main_attribute_mode is not None
form_files = borrow_form(type_name, obj_name, form_name, borrow_main_attr=has_bma) form_files = borrow_form(type_name, obj_name, form_name, borrow_main_attr=has_bma)
borrowed_files.extend(form_files) borrowed_files.extend(form_files)
# Замер на 8.3.26: платформа помечает форму расширенной сразу при заимствовании,
# даже если элементы не менялись. Флаг живёт в метаданных формы, не у владельца.
set_property_state_flag(form_files[0], "Form", format_version)
borrowed_count += 1 borrowed_count += 1
# Borrow main attribute if requested # Borrow main attribute if requested
@@ -2077,23 +2316,32 @@ def main():
borrow_main_attribute(type_name, obj_name, form_name, borrow_main_attribute_mode) borrow_main_attribute(type_name, obj_name, form_name, borrow_main_attribute_mode)
else: else:
# --- Object borrowing --- # --- Object borrowing ---
info(f"Borrowing {type_name}.{obj_name}...")
src = read_source_object(type_name, obj_name)
info(f" Source UUID: {src['Uuid']}")
borrowed_xml = build_borrowed_object_xml(type_name, obj_name, src["Uuid"], src["Properties"])
target_dir = os.path.join(ext_dir, dir_name) target_dir = os.path.join(ext_dir, dir_name)
os.makedirs(target_dir, exist_ok=True)
target_file = os.path.join(target_dir, f"{obj_name}.xml") target_file = os.path.join(target_dir, f"{obj_name}.xml")
write_xml_file(target_file, borrowed_xml)
info(f" Created: {target_file}") # Уже заимствованный объект НЕ переписываем: в его XML лежат собственные реквизиты
# расширения, заимствованные подобъекты и состояния, которые из источника не
# выводятся. Повторный вызов — законный способ доделать модуль (-Module), а не
# переиздать заготовку.
if test_object_borrowed(type_name, obj_name):
info(f"Already borrowed: {type_name}.{obj_name} — XML сохранён без изменений")
else:
info(f"Borrowing {type_name}.{obj_name}...")
src = read_source_object(type_name, obj_name)
info(f" Source UUID: {src['Uuid']}")
borrowed_xml = build_borrowed_object_xml(type_name, obj_name, src["Uuid"], src["Properties"])
os.makedirs(target_dir, exist_ok=True)
write_xml_file(target_file, borrowed_xml)
info(f" Created: {target_file}")
add_to_child_objects(type_name, obj_name) add_to_child_objects(type_name, obj_name)
borrowed_files.append(target_file) borrowed_files.append(target_file)
for kind in resolve_module_kinds(type_name):
borrowed_files.append(new_borrowed_module_file(type_name, obj_name, kind))
borrowed_count += 1 borrowed_count += 1
# --- Владельцы заимствованных справочников --- # --- Владельцы заимствованных справочников ---
@@ -23,7 +23,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A powershell.exe -NoProfile -File ".roo/skills/cfe-diff/scripts/cfe-diff.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
``` ```
## Mode A — обзор расширения ## Mode A — обзор расширения
@@ -1,7 +1,8 @@
# cfe-diff v1.2 — Analyze and compare 1C configuration extension (CFE) (+тип Bot; cfe-diff/cfe-borrow: недостающие типы) # cfe-diff v1.4 — Analyze and compare 1C configuration extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory, Position=0)]
[string]$ExtensionPath, [string]$ExtensionPath,
[Parameter(Mandatory)] [Parameter(Mandatory)]
@@ -52,6 +53,7 @@ $childTypeDirMap = @{
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "XDTOPackage"="XDTOPackages" "CommonAttribute"="CommonAttributes"; "Style"="Styles"; "XDTOPackage"="XDTOPackages"
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences" "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"Bot"="Bots" "Bot"="Bots"
"PaletteColor"="PaletteColors"
} }
# --- Parse extension Configuration.xml --- # --- Parse extension Configuration.xml ---
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cfe-diff v1.2 — Analyze and compare 1C configuration extension (CFE) (+тип Bot; cfe-diff/cfe-borrow: недостающие типы) # cfe-diff v1.4 — Analyze and compare 1C configuration extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -88,6 +88,7 @@ CHILD_TYPE_DIR_MAP = {
"HTTPService": "HTTPServices", "HTTPService": "HTTPServices",
"WSReference": "WSReferences", "WSReference": "WSReferences",
"Bot": "Bots", "Bot": "Bots",
"PaletteColor": "PaletteColors",
} }
@@ -44,7 +44,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение" -OutputDir "src\cfe\МоёРасширение" -ConfigPath "src\cf" powershell.exe -NoProfile -File ".roo/skills/cfe-init/scripts/cfe-init.ps1" -Name "МоёРасширение" -OutputDir "src\cfe\МоёРасширение" -ConfigPath "src\cf"
``` ```
## Примеры ## Примеры
@@ -1,5 +1,6 @@
# cfe-init v1.10 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи) # 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 # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$Name, [string]$Name,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cfe-init v1.10 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи) # 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 # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C configuration extension.""" """Generates minimal XML source files for a 1C configuration extension."""
import sys, os, re, argparse, uuid import sys, os, re, argparse, uuid
@@ -88,7 +88,7 @@ allowed-tools:
Правила: Правила:
- Маркеры — на **отдельной строке с 0-й колонки** (без отступа), даже внутри отступов и текста запроса (`|…`). - Маркеры — на **отдельной строке с 0-й колонки** (без отступа), даже внутри отступов и текста запроса (`|…`).
- **Незамеченные (unmarked) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`). - **Незамеченные (unmarked) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Дословно — включая комментарии, регистр и пробелы внутри строки (`Х = Х + 1``Х=Х+1`); свободны только отступ и пустые строки. Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`).
- Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай. - Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай.
## Актуализация ## Актуализация
@@ -110,7 +110,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before powershell.exe -NoProfile -File ".roo/skills/cfe-patch-method/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
``` ```
## Примеры ## Примеры
@@ -1,5 +1,6 @@
# cfe-patch-method v2.7 — Source-aware method interceptor for 1C extension (CFE) (+прощающий ввод: имя каталога наравне с именем типа) # cfe-patch-method v2.11 — Source-aware method interceptor for 1C extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$ExtensionPath, [string]$ExtensionPath,
@@ -361,6 +362,22 @@ function Get-Normalized {
return (($line -replace '\s+', ' ').Trim()) return (($line -replace '\s+', ' ').Trim())
} }
# Control comparison key, as the platform compares a &ИзменениеИКонтроль copy with the original:
# each line trimmed, blank lines dropped, everything else byte-for-byte and case-sensitive
# (inner spaces, comments and letter case are significant). Measured on 8.3.24 and 8.3.27.
function Get-ControlKey {
param($lines)
return (@($lines | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) -join "`n")
}
# Parameter count of a signature params text. The platform compares only the number of
# parameters of a &ИзменениеИКонтроль copy (names and default values it ignores).
function Get-ParamCount {
param([string]$paramsText)
if ([string]::IsNullOrWhiteSpace($paramsText)) { return 0 }
return @(Split-TopLevel $paramsText | Where-Object { $_.Trim() -ne '' }).Count
}
# Reconstruct v1 body and edit ops from a marked body # Reconstruct v1 body and edit ops from a marked body
function Parse-MarkedBody { function Parse-MarkedBody {
param($bodyLines) param($bodyLines)
@@ -650,7 +667,14 @@ function Invoke-Resync {
$v1norm = @($v1 | ForEach-Object { Get-Normalized $_ }) $v1norm = @($v1 | ForEach-Object { Get-Normalized $_ })
$v2norm = @($v2 | ForEach-Object { Get-Normalized $_ }) $v2norm = @($v2 | ForEach-Object { Get-Normalized $_ })
if (($v1norm -join "`n") -eq ($v2norm -join "`n")) { # Signature: the platform rejects the interceptor when the parameter COUNT differs, so a
# body-only comparison would miss a vendor-added parameter. Names/defaults it ignores.
$extParamCount = Get-ParamCount $sig.ParamsText
$srcParamCount = Get-ParamCount $method.ParamsText
$paramsDrift = ($extParamCount -ne $srcParamCount)
$paramsReason = if ($paramsDrift) { "список параметров: в оригинале $srcParamCount, в перехватчике $extParamCount" } else { '' }
if (-not $paramsDrift -and [string]::Equals((Get-ControlKey $v1), (Get-ControlKey $v2), 'Ordinal')) {
return @{ Id = $methodId; Status = 'АКТУАЛЕН'; ExtBsl = $extBsl } return @{ Id = $methodId; Status = 'АКТУАЛЕН'; ExtBsl = $extBsl }
} }
@@ -700,7 +724,9 @@ function Invoke-Resync {
if ($ReportOnly) { if ($ReportOnly) {
$st = if ($disputed.Count -gt 0) { 'КОНФЛИКТ' } elseif ($transferred.Count -eq 0 -and $absorbed.Count -gt 0) { 'ПЕРЕНЕСЕНО В ОСНОВНУЮ' } else { 'ДРЕЙФ' } $st = if ($disputed.Count -gt 0) { 'КОНФЛИКТ' } elseif ($transferred.Count -eq 0 -and $absorbed.Count -gt 0) { 'ПЕРЕНЕСЕНО В ОСНОВНУЮ' } else { 'ДРЕЙФ' }
if ($paramsDrift -and $st -eq 'ПЕРЕНЕСЕНО В ОСНОВНУЮ') { $st = 'ДРЕЙФ' }
$rsn = if ($disputed.Count -gt 0) { Get-ResyncConflictReason $disputed } elseif ($st -eq 'ПЕРЕНЕСЕНО В ОСНОВНУЮ') { 'все правки уже в основной конфигурации' } else { '' } $rsn = if ($disputed.Count -gt 0) { Get-ResyncConflictReason $disputed } elseif ($st -eq 'ПЕРЕНЕСЕНО В ОСНОВНУЮ') { 'все правки уже в основной конфигурации' } else { '' }
if ($paramsDrift) { $rsn = if ($rsn) { "$paramsReason; $rsn" } else { $paramsReason } }
return @{ Id = $methodId; Status = $st; ExtBsl = $extBsl; Transferred = $transferred.Count; Absorbed = $absorbed.Count; Disputed = $disputed.Count; Reason = $rsn; AbsorbedNotes = $absorbedNotes } return @{ Id = $methodId; Status = $st; ExtBsl = $extBsl; Transferred = $transferred.Count; Absorbed = $absorbed.Count; Disputed = $disputed.Count; Reason = $rsn; AbsorbedNotes = $absorbedNotes }
} }
@@ -788,6 +814,100 @@ if (Test-Path $ExtensionPath -PathType Leaf) { $ExtensionPath = Split-Path $Exte
$cfgFile = Join-Path $ExtensionPath "Configuration.xml" $cfgFile = Join-Path $ExtensionPath "Configuration.xml"
if (-not (Test-Path $cfgFile)) { Write-Error "Configuration.xml не найден в расширении: $ExtensionPath"; exit 1 } if (-not (Test-Path $cfgFile)) { Write-Error "Configuration.xml не найден в расширении: $ExtensionPath"; exit 1 }
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
# Копии этих функций есть в cfe-borrow (навыки автономны); держать их одинаковыми — сознательно.
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
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"
}
function Build-PropertyStateXml {
param([string]$propertyName, [string]$indent)
$sb = New-Object System.Text.StringBuilder
$sb.AppendLine("${indent}<xr:PropertyState>") | Out-Null
$sb.AppendLine("${indent}`t<xr:Property>${propertyName}</xr:Property>") | Out-Null
$sb.AppendLine("${indent}`t<xr:State>Extended</xr:State>") | Out-Null
$sb.Append("${indent}</xr:PropertyState>") | Out-Null
return $sb.ToString()
}
function Set-PropertyStateFlag {
param([string]$objFile, [string]$propertyName, [string]$formatVersion)
if ((Get-FormatRank $formatVersion) -lt 219) { return }
if (-not (Test-Path $objFile)) { return }
$enc = New-Object System.Text.UTF8Encoding($true)
$text = [System.IO.File]::ReadAllText($objFile, $enc)
$nl = if ($text -match "`r`n") { "`r`n" } else { "`n" }
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
# но они лежат ниже, внутри <ChildObjects>.
$empty = [regex]::Match($text, '([ \t]*)<InternalInfo\s*/>')
$open = [regex]::Match($text, '(?s)([ \t]*)<InternalInfo>(.*?)</InternalInfo>')
if ($empty.Success -and (-not $open.Success -or $empty.Index -lt $open.Index)) {
$ind = $empty.Groups[1].Value
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
$replacement = "${ind}<InternalInfo>${nl}${block}${nl}${ind}</InternalInfo>"
$text = $text.Remove($empty.Index, $empty.Length).Insert($empty.Index, $replacement)
} elseif ($open.Success) {
if ($open.Groups[2].Value -match "<xr:Property>$([regex]::Escape($propertyName))</xr:Property>") { return }
$ind = $open.Groups[1].Value
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
$closeAt = $open.Index + $open.Length - "</InternalInfo>".Length - $ind.Length
$text = $text.Insert($closeAt, "${block}${nl}")
} else {
return
}
[System.IO.File]::WriteAllText($objFile, $text, $enc)
}
# Модуль формы сюда не попадает: у формы флаг называется Form и ставится при заимствовании,
# а не при появлении модуля (замер на 8.3.26 — пустой модуль формы платформа не выгружает).
function Get-ModuleFlagTarget {
param([string[]]$relParts, [string]$extRoot)
if ($relParts.Count -ne 4 -or $relParts[2] -ne "Ext") { return $null }
$prop = [System.IO.Path]::GetFileNameWithoutExtension($relParts[3])
return @{
File = (Join-Path (Join-Path $extRoot $relParts[0]) "$($relParts[1]).xml")
Property = $prop
}
}
# --- Read NamePrefix --- # --- Read NamePrefix ---
$cfgDoc = New-Object System.Xml.XmlDocument $cfgDoc = New-Object System.Xml.XmlDocument
$cfgDoc.PreserveWhitespace = $false $cfgDoc.PreserveWhitespace = $false
@@ -1084,6 +1204,12 @@ if ($reuseRegionIdx -ge 0) {
} }
} }
# Модуль в расширении есть — отражаем это в метаданных объекта (формат ≥ 2.19).
$flagTarget = Get-ModuleFlagTarget $relParts $ExtensionPath
if ($flagTarget) {
Set-PropertyStateFlag $flagTarget.File $flagTarget.Property (Detect-FormatVersion $ExtensionPath)
}
Write-Host "[OK] Перехватчик &$decoratorRu(`"$MethodName`") — $placement" Write-Host "[OK] Перехватчик &$decoratorRu(`"$MethodName`") — $placement"
Write-Host " Файл: $extBsl" Write-Host " Файл: $extBsl"
Write-Host " Процедура: $interceptorName($($method.ParamsText -replace '\s+', ' '))" Write-Host " Процедура: $interceptorName($($method.ParamsText -replace '\s+', ' '))"
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cfe-patch-method v2.7 — Source-aware method interceptor for 1C extension (CFE) (+прощающий ввод: имя каталога наравне с именем типа) # cfe-patch-method v2.11 — Source-aware method interceptor for 1C extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -75,6 +75,99 @@ CONTEXT_RE = re.compile(
) )
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
# Копии этих функций есть в cfe-borrow (навыки автономны); держать их одинаковыми — сознательно.
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 build_property_state_xml(property_name, indent):
return "\n".join([
f"{indent}<xr:PropertyState>",
f"{indent}\t<xr:Property>{property_name}</xr:Property>",
f"{indent}\t<xr:State>Extended</xr:State>",
f"{indent}</xr:PropertyState>",
])
def set_property_state_flag(obj_file, property_name, format_version):
if format_rank(format_version) < 219:
return
if not os.path.isfile(obj_file):
return
with open(obj_file, "r", encoding="utf-8-sig", newline="") as fh:
text = fh.read()
nl = "\r\n" if "\r\n" in text else "\n"
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
# но они лежат ниже, внутри <ChildObjects>.
empty = re.search(r"([ \t]*)<InternalInfo\s*/>", text)
opened = re.search(r"([ \t]*)<InternalInfo>(.*?)</InternalInfo>", text, re.S)
if empty and (not opened or empty.start() < opened.start()):
ind = empty.group(1)
block = build_property_state_xml(property_name, ind + "\t")
replacement = f"{ind}<InternalInfo>{nl}{block}{nl}{ind}</InternalInfo>"
text = text[:empty.start()] + replacement + text[empty.end():]
elif opened:
if re.search(rf"<xr:Property>{re.escape(property_name)}</xr:Property>", opened.group(2)):
return
ind = opened.group(1)
block = build_property_state_xml(property_name, ind + "\t")
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
close_at = opened.end() - len("</InternalInfo>") - len(ind)
text = text[:close_at] + block + nl + text[close_at:]
else:
return
with open(obj_file, "w", encoding="utf-8-sig", newline="") as fh:
fh.write(text)
# Модуль формы сюда не попадает: у формы флаг называется Form и ставится при заимствовании,
# а не при появлении модуля (замер на 8.3.26 — пустой модуль формы платформа не выгружает).
def get_module_flag_target(rel_parts, ext_root):
if len(rel_parts) != 4 or rel_parts[2] != "Ext":
return None
prop = os.path.splitext(rel_parts[3])[0]
return {
"file": os.path.join(ext_root, rel_parts[0], f"{rel_parts[1]}.xml"),
"property": prop,
}
def get_module_rel_path(module_path): def get_module_rel_path(module_path):
parts = module_path.split(".") parts = module_path.split(".")
if len(parts) < 2: if len(parts) < 2:
@@ -398,6 +491,21 @@ def normalize(line):
return re.sub(r'\s+', ' ', line).strip() return re.sub(r'\s+', ' ', line).strip()
# Control comparison key, as the platform compares a &ИзменениеИКонтроль copy with the original:
# each line trimmed, blank lines dropped, everything else byte-for-byte and case-sensitive
# (inner spaces, comments and letter case are significant). Measured on 8.3.24 and 8.3.27.
def control_key(lines):
return "\n".join([k for k in (x.strip() for x in lines) if k != ""])
# Parameter count of a signature params text. The platform compares only the number of
# parameters of a &ИзменениеИКонтроль copy (names and default values it ignores).
def param_count(params_text):
if not params_text or not params_text.strip():
return 0
return len([p for p in split_top_level(params_text) if p.strip()])
def parse_marked_body(body_lines): def parse_marked_body(body_lines):
v1 = [] v1 = []
ops = [] ops = []
@@ -841,6 +949,12 @@ def main():
place_new(ext_bsl, ext_lines, ext_exists, method["chain"], core) place_new(ext_bsl, ext_lines, ext_exists, method["chain"], core)
# Модуль в расширении есть — отражаем это в метаданных объекта (формат ≥ 2.19).
flag_target = get_module_flag_target(rel_parts, extension_path)
if flag_target:
set_property_state_flag(flag_target["file"], flag_target["property"],
detect_format_version(extension_path))
# emit summary # emit summary
placement = place_new.placement placement = place_new.placement
print('[OK] Перехватчик &%s("%s") — %s' % (decorator_ru, method_name, placement)) print('[OK] Перехватчик &%s("%s") — %s' % (decorator_ru, method_name, placement))
@@ -1030,7 +1144,7 @@ def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder,
sig = read_signature(ext_lines, sig_line_idx) sig = read_signature(ext_lines, sig_line_idx)
if not sig: if not sig:
return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не разобрать сигнатуру"} return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не разобрать сигнатуру"}
_params, sig_end = sig ext_params_text, sig_end = sig
is_func = bool(re.match(r'^\s*(?:Асинх\s+)?Функция\b', ext_lines[sig_line_idx], re.IGNORECASE)) is_func = bool(re.match(r'^\s*(?:Асинх\s+)?Функция\b', ext_lines[sig_line_idx], re.IGNORECASE))
end_re = re.compile(r'^\s*КонецФункции\b' if is_func else r'^\s*КонецПроцедуры\b', re.IGNORECASE) end_re = re.compile(r'^\s*КонецФункции\b' if is_func else r'^\s*КонецПроцедуры\b', re.IGNORECASE)
block_end = -1 block_end = -1
@@ -1047,7 +1161,15 @@ def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder,
v1norm = [normalize(x) for x in v1] v1norm = [normalize(x) for x in v1]
v2norm = [normalize(x) for x in v2] v2norm = [normalize(x) for x in v2]
if "\n".join(v1norm) == "\n".join(v2norm): # Signature: the platform rejects the interceptor when the parameter COUNT differs, so a
# body-only comparison would miss a vendor-added parameter. Names/defaults it ignores.
ext_param_count = param_count(ext_params_text)
src_param_count = param_count(method["params_text"])
params_drift = ext_param_count != src_param_count
params_reason = ("список параметров: в оригинале %d, в перехватчике %d"
% (src_param_count, ext_param_count)) if params_drift else ""
if not params_drift and control_key(v1) == control_key(v2):
return {"id": method_id, "status": "АКТУАЛЕН", "ext_bsl": ext_bsl} return {"id": method_id, "status": "АКТУАЛЕН", "ext_bsl": ext_bsl}
insert_top = []; insert_after = {}; del_start = set(); del_end = set(); disputed = []; transferred = 0; absorbed = 0; absorbed_notes = [] insert_top = []; insert_after = {}; del_start = set(); del_end = set(); disputed = []; transferred = 0; absorbed = 0; absorbed_notes = []
@@ -1109,7 +1231,11 @@ def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder,
st = "ПЕРЕНЕСЕНО В ОСНОВНУЮ" st = "ПЕРЕНЕСЕНО В ОСНОВНУЮ"
else: else:
st = "ДРЕЙФ" st = "ДРЕЙФ"
if params_drift and st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ":
st = "ДРЕЙФ"
rsn = conflict_reason(disputed) if disputed else ("все правки уже в основной конфигурации" if st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ" else "") rsn = conflict_reason(disputed) if disputed else ("все правки уже в основной конфигурации" if st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ" else "")
if params_drift:
rsn = ("%s; %s" % (params_reason, rsn)) if rsn else params_reason
return {"id": method_id, "status": st, "ext_bsl": ext_bsl, "transferred": transferred, return {"id": method_id, "status": st, "ext_bsl": ext_bsl, "transferred": transferred,
"absorbed": absorbed, "disputed": len(disputed), "reason": rsn, "absorbed_notes": absorbed_notes} "absorbed": absorbed, "disputed": len(disputed), "reason": rsn, "absorbed_notes": absorbed_notes}
@@ -10,7 +10,7 @@ allowed-tools:
# /cfe-validate — валидация расширения конфигурации (CFE) # /cfe-validate — валидация расширения конфигурации (CFE)
Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты. Аналог `/cf-validate`, но для расширений. Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты, права ролей. Аналог `/cf-validate`, но для расширений.
## Параметры ## Параметры
@@ -34,7 +34,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" powershell.exe -NoProfile -File ".roo/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname"
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname\Configuration.xml" powershell.exe -NoProfile -File ".roo/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname\Configuration.xml"
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf" powershell.exe -NoProfile -File ".roo/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf"
``` ```
@@ -1,7 +1,8 @@
# cfe-validate v1.10 — Validate 1C configuration extension structure (CFE) (полнота GeneratedType, ТЧ из AdditionalColumns, сверка путей с -ConfigPath) # cfe-validate v1.15 — Validate 1C configuration extension structure (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory, Position=0)]
[Alias('Path')] [Alias('Path')]
[string]$ExtensionPath, [string]$ExtensionPath,
@@ -107,7 +108,28 @@ function Get-FormatRank([string]$ver) {
} }
# --- Reference tables --- # --- 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}$' # Модули заимствованных объектов: тип → виды модулей. Имя свойства в <xr:PropertyState>
# совпадает с базовым именем файла модуля. Копия таблицы есть в cfe-borrow (навыки автономны).
$moduleKindsByType = @{
"CommonModule"=@("Module"); "HTTPService"=@("Module"); "WebService"=@("Module")
"Catalog"=@("ObjectModule","ManagerModule"); "Document"=@("ObjectModule","ManagerModule")
"Report"=@("ObjectModule","ManagerModule"); "DataProcessor"=@("ObjectModule","ManagerModule")
"ExchangePlan"=@("ObjectModule","ManagerModule")
"ChartOfCharacteristicTypes"=@("ObjectModule","ManagerModule")
"ChartOfAccounts"=@("ObjectModule","ManagerModule")
"ChartOfCalculationTypes"=@("ObjectModule","ManagerModule")
"BusinessProcess"=@("ObjectModule","ManagerModule"); "Task"=@("ObjectModule","ManagerModule")
"InformationRegister"=@("RecordSetModule","ManagerModule")
"AccumulationRegister"=@("RecordSetModule","ManagerModule")
"AccountingRegister"=@("RecordSetModule","ManagerModule")
"CalculationRegister"=@("RecordSetModule","ManagerModule")
"Sequence"=@("RecordSetModule","ManagerModule")
"Constant"=@("ValueManagerModule","ManagerModule")
"Enum"=@("ManagerModule"); "DocumentJournal"=@("ManagerModule")
"FilterCriterion"=@("ManagerModule")
}
$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_]*$' $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 # 7 fixed ClassIds for Configuration
@@ -121,14 +143,14 @@ $validClassIds = @(
"fb282519-d103-4dd3-bc12-cb271d631dfc" "fb282519-d103-4dd3-bc12-cb271d631dfc"
) )
# 44 types in canonical order # 46 types in canonical order
$childObjectTypes = @( $childObjectTypes = @(
"Language","Subsystem","StyleItem","Style", "Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate", "CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan", "FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference", "XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption", "EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup", "FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document", "Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum", "DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister", "Report","DataProcessor","InformationRegister","AccumulationRegister",
@@ -139,7 +161,7 @@ $childObjectTypes = @(
# Type -> directory mapping # Type -> directory mapping
$childTypeDirMap = @{ $childTypeDirMap = @{
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"; "Bot"="Bots" "Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"; "Bot"="Bots"; "PaletteColor"="PaletteColors"
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles" "CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules" "CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages" "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
@@ -1169,8 +1191,113 @@ if (-not $script:stopped -and $script:borrowedFormsWithTree.Count -gt 0) {
} }
} }
# --- Check 15: основные роли расширения не дают прав на заимствованные объекты ---
# Платформа: «Назначение прав доступа на заимствованные объекты основными ролями в
# расширениях недопустимо». Роль вне <DefaultRoles> так делать вправе — проверяем только
# основные. Ловится статически, а по симптому (отказ загрузки) причина не читается.
$defaultRoleNodes = @($cfgNode.SelectNodes("md:Properties/md:DefaultRoles/xr:Item", $ns))
if ($defaultRoleNodes.Count -gt 0) {
$adoptedCache = @{}
function Test-ObjectAdopted {
param([string]$typeName, [string]$objName)
$key = "$typeName.$objName"
if ($adoptedCache.ContainsKey($key)) { return $adoptedCache[$key] }
$adoptedCache[$key] = $false
if ($childTypeDirMap.ContainsKey($typeName)) {
$objPath = Join-Path (Join-Path $configDir $childTypeDirMap[$typeName]) "$objName.xml"
if (Test-Path $objPath) {
try {
$objDoc = New-Object System.Xml.XmlDocument
$objDoc.Load($objPath)
$objNs = New-Object System.Xml.XmlNamespaceManager($objDoc.NameTable)
$objNs.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$ob = $objDoc.SelectSingleNode("/md:MetaDataObject/md:$typeName/md:Properties/md:ObjectBelonging", $objNs)
if ($ob -and $ob.InnerText -eq "Adopted") { $adoptedCache[$key] = $true }
} catch {}
}
}
return $adoptedCache[$key]
}
$check15Ok = $true
$check15Count = 0
foreach ($rn in $defaultRoleNodes) {
$roleRef = $rn.InnerText
if ($roleRef -notmatch '^Role\.(.+)$') { continue }
$defRoleName = $Matches[1]
$rightsPath = Join-Path (Join-Path (Join-Path $configDir "Roles") $defRoleName) "Ext\Rights.xml"
if (-not (Test-Path $rightsPath)) { continue }
try {
$rDoc = New-Object System.Xml.XmlDocument
$rDoc.Load($rightsPath)
} catch {
continue
}
$rNs = New-Object System.Xml.XmlNamespaceManager($rDoc.NameTable)
$rNs.AddNamespace("r", "http://v8.1c.ru/8.2/roles")
foreach ($nameNode in $rDoc.SelectNodes("/r:Rights/r:object/r:name", $rNs)) {
$fullName = $nameNode.InnerText
$segs = $fullName.Split(".")
# Configuration.* — права самого расширения, не объект; заимствования там нет.
if ($segs.Count -lt 2 -or $segs[0] -eq "Configuration") { continue }
$check15Count++
if (Test-ObjectAdopted $segs[0] $segs[1]) {
Report-Error ("15. Роль '$defRoleName' входит в DefaultRoles и даёт права на заимствованный $($segs[0]).$($segs[1]) " +
"($fullName): платформа это запрещает. Вынесите такие права в отдельную роль вне DefaultRoles.")
$check15Ok = $false
}
}
}
if ($check15Ok -and $check15Count -gt 0) {
Report-OK "15. Основные роли: прав на заимствованные объекты нет ($check15Count checked)"
}
}
if ($script:stopped) { & $finalize; exit 1 } if ($script:stopped) { & $finalize; exit 1 }
# --- Check 16: модуль заимствованного объекта и пометка расширенного свойства ---
# Свойство <xr:PropertyState> появилось в формате 2.19 (8.3.26); ниже платформа его молча
# выбрасывает, поэтому там проверять нечего. С 2.19 состояние обязано соответствовать факту:
# есть файл модуля — есть пометка, и наоборот. Перекос платформа принимает (проверено на стенде),
# но выгрузка Конфигуратора так не выглядит — отсюда предупреждение, а не ошибка.
if ($versionRank -ge 219 -and $childObjNode) {
$stateIssues = @()
$stateChecked = 0
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
$typeName = $child.LocalName
if (-not $moduleKindsByType.ContainsKey($typeName)) { continue }
if (-not $childTypeDirMap.ContainsKey($typeName)) { continue }
$stateObjName = $child.InnerText.Trim()
if (-not $stateObjName) { continue }
$typeDir = Join-Path $configDir $childTypeDirMap[$typeName]
$objFile = Join-Path $typeDir "$stateObjName.xml"
if (-not (Test-Path $objFile)) { continue }
$objText = [System.IO.File]::ReadAllText($objFile, [System.Text.Encoding]::UTF8)
if ($objText -notmatch '<ObjectBelonging>Adopted</ObjectBelonging>') { continue }
foreach ($kind in $moduleKindsByType[$typeName]) {
$stateChecked++
$hasFile = Test-Path (Join-Path (Join-Path (Join-Path $typeDir $stateObjName) "Ext") "$kind.bsl")
$hasFlag = $objText -match "<xr:Property>$kind</xr:Property>"
if ($hasFile -and -not $hasFlag) {
$stateIssues += "$typeName.$stateObjName — есть $kind.bsl, но нет <xr:PropertyState> для $kind"
} elseif ($hasFlag -and -not $hasFile) {
$stateIssues += "$typeName.$stateObjName — есть <xr:PropertyState> для $kind, но нет $kind.bsl"
}
}
}
if ($stateChecked -gt 0) {
if ($stateIssues.Count -eq 0) {
Report-OK "16. Модули заимствованных объектов: пометки расширенных свойств согласованы ($stateChecked)"
} else {
foreach ($issue in $stateIssues) { Report-Warn "16. $issue" }
}
}
}
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here --- # --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
$extRootDir = Split-Path $resolvedPath -Parent $extRootDir = Split-Path $resolvedPath -Parent
$ctrlCount = 0 $ctrlCount = 0
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cfe-validate v1.10 — Validate 1C configuration extension XML structure (CFE) (полнота GeneratedType, ТЧ из AdditionalColumns, сверка путей с -ConfigPath) # cfe-validate v1.15 — Validate 1C configuration extension XML structure (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects.""" """Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects."""
import sys, os, argparse, re import sys, os, argparse, re
@@ -55,14 +55,14 @@ VALID_CLASS_IDS = [
'fb282519-d103-4dd3-bc12-cb271d631dfc', 'fb282519-d103-4dd3-bc12-cb271d631dfc',
] ]
# 44 types in canonical order # 46 types in canonical order
CHILD_OBJECT_TYPES = [ CHILD_OBJECT_TYPES = [
'Language', 'Subsystem', 'StyleItem', 'Style', 'Language', 'Subsystem', 'StyleItem', 'Style',
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate', 'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan', 'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference', 'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption', 'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup', 'FunctionalOptionsParameter', 'DefinedType', 'Bot', 'PaletteColor', 'CommonCommand', 'CommandGroup',
'Constant', 'CommonForm', 'Catalog', 'Document', 'Constant', 'CommonForm', 'Catalog', 'Document',
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum', 'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister', 'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
@@ -71,12 +71,33 @@ CHILD_OBJECT_TYPES = [
'BusinessProcess', 'Task', 'IntegrationService', 'BusinessProcess', 'Task', 'IntegrationService',
] ]
# Модули заимствованных объектов: тип → виды модулей. Имя свойства в <xr:PropertyState>
# совпадает с базовым именем файла модуля. Копия таблицы есть в cfe-borrow (навыки автономны).
MODULE_KINDS_BY_TYPE = {
"CommonModule": ["Module"], "HTTPService": ["Module"], "WebService": ["Module"],
"Catalog": ["ObjectModule", "ManagerModule"], "Document": ["ObjectModule", "ManagerModule"],
"Report": ["ObjectModule", "ManagerModule"], "DataProcessor": ["ObjectModule", "ManagerModule"],
"ExchangePlan": ["ObjectModule", "ManagerModule"],
"ChartOfCharacteristicTypes": ["ObjectModule", "ManagerModule"],
"ChartOfAccounts": ["ObjectModule", "ManagerModule"],
"ChartOfCalculationTypes": ["ObjectModule", "ManagerModule"],
"BusinessProcess": ["ObjectModule", "ManagerModule"], "Task": ["ObjectModule", "ManagerModule"],
"InformationRegister": ["RecordSetModule", "ManagerModule"],
"AccumulationRegister": ["RecordSetModule", "ManagerModule"],
"AccountingRegister": ["RecordSetModule", "ManagerModule"],
"CalculationRegister": ["RecordSetModule", "ManagerModule"],
"Sequence": ["RecordSetModule", "ManagerModule"],
"Constant": ["ValueManagerModule", "ManagerModule"],
"Enum": ["ManagerModule"], "DocumentJournal": ["ManagerModule"],
"FilterCriterion": ["ManagerModule"],
}
# Type -> directory mapping # Type -> directory mapping
CHILD_TYPE_DIR_MAP = { CHILD_TYPE_DIR_MAP = {
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles', 'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles', 'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules', 'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
'Bot': 'Bots', 'Bot': 'Bots', 'PaletteColor': 'PaletteColors',
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages', 'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences', 'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs', 'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
@@ -1141,10 +1162,108 @@ def main():
if check14_ok: if check14_ok:
r.ok(f'14. Object paths vs source config: {path_check_count} checked') r.ok(f'14. Object paths vs source config: {path_check_count} checked')
# --- Check 15: основные роли расширения не дают прав на заимствованные объекты ---
# Платформа: «Назначение прав доступа на заимствованные объекты основными ролями в
# расширениях недопустимо». Роль вне <DefaultRoles> так делать вправе — проверяем только
# основные. Ловится статически, а по симптому (отказ загрузки) причина не читается.
default_role_nodes = cfg_node.findall('md:Properties/md:DefaultRoles/xr:Item', NS)
if default_role_nodes:
adopted_cache = {}
def is_object_adopted(type_name, obj_name):
key = f"{type_name}.{obj_name}"
if key in adopted_cache:
return adopted_cache[key]
adopted_cache[key] = False
dir_name = CHILD_TYPE_DIR_MAP.get(type_name)
if dir_name:
obj_path = os.path.join(config_dir, dir_name, obj_name + '.xml')
if os.path.isfile(obj_path):
try:
obj_root = etree.parse(obj_path).getroot()
ob = obj_root.find(f'md:{type_name}/md:Properties/md:ObjectBelonging', NS)
if ob is not None and (ob.text or '') == 'Adopted':
adopted_cache[key] = True
except Exception:
pass
return adopted_cache[key]
check15_ok = True
check15_count = 0
roles_ns = {'r': 'http://v8.1c.ru/8.2/roles'}
for rn in default_role_nodes:
m = re.match(r'^Role\.(.+)$', rn.text or '')
if not m:
continue
def_role_name = m.group(1)
rights_path = os.path.join(config_dir, 'Roles', def_role_name, 'Ext', 'Rights.xml')
if not os.path.isfile(rights_path):
continue
try:
rights_root = etree.parse(rights_path).getroot()
except Exception:
continue
for name_node in rights_root.findall('r:object/r:name', roles_ns):
full_name = name_node.text or ''
segs = full_name.split('.')
# Configuration.* — права самого расширения, не объект; заимствования там нет.
if len(segs) < 2 or segs[0] == 'Configuration':
continue
check15_count += 1
if is_object_adopted(segs[0], segs[1]):
r.error(f"15. Роль '{def_role_name}' входит в DefaultRoles и даёт права на заимствованный "
f"{segs[0]}.{segs[1]} ({full_name}): платформа это запрещает. "
"Вынесите такие права в отдельную роль вне DefaultRoles.")
check15_ok = False
if check15_ok and check15_count > 0:
r.ok(f'15. Основные роли: прав на заимствованные объекты нет ({check15_count} checked)')
if r.stopped: if r.stopped:
r.finalize(out_file) r.finalize(out_file)
sys.exit(1) sys.exit(1)
# --- Check 16: модуль заимствованного объекта и пометка расширенного свойства ---
# Свойство <xr:PropertyState> появилось в формате 2.19 (8.3.26); ниже платформа его молча
# выбрасывает, поэтому там проверять нечего. С 2.19 состояние обязано соответствовать факту:
# есть файл модуля — есть пометка, и наоборот. Перекос платформа принимает (проверено на
# стенде), но выгрузка Конфигуратора так не выглядит — отсюда предупреждение, а не ошибка.
if version_rank >= 219 and child_obj_node is not None:
state_issues = []
state_checked = 0
for child in child_obj_node:
if not isinstance(child.tag, str):
continue
type_name = etree.QName(child.tag).localname
if type_name not in MODULE_KINDS_BY_TYPE or type_name not in CHILD_TYPE_DIR_MAP:
continue
obj_name_val = (child.text or '').strip()
if not obj_name_val:
continue
type_dir = os.path.join(config_dir, CHILD_TYPE_DIR_MAP[type_name])
obj_file = os.path.join(type_dir, f'{obj_name_val}.xml')
if not os.path.isfile(obj_file):
continue
with open(obj_file, 'r', encoding='utf-8-sig') as f:
obj_text = f.read()
if '<ObjectBelonging>Adopted</ObjectBelonging>' not in obj_text:
continue
for kind in MODULE_KINDS_BY_TYPE[type_name]:
state_checked += 1
has_file = os.path.isfile(os.path.join(type_dir, obj_name_val, 'Ext', f'{kind}.bsl'))
has_flag = f'<xr:Property>{kind}</xr:Property>' in obj_text
if has_file and not has_flag:
state_issues.append(f'{type_name}.{obj_name_val} — есть {kind}.bsl, но нет <xr:PropertyState> для {kind}')
elif has_flag and not has_file:
state_issues.append(f'{type_name}.{obj_name_val} — есть <xr:PropertyState> для {kind}, но нет {kind}.bsl')
if state_checked > 0:
if not state_issues:
r.ok(f'16. Модули заимствованных объектов: пометки расширенных свойств согласованы ({state_checked})')
else:
for issue in state_issues:
r.warn(f'16. {issue}')
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here --- # --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
ctrl_count = 0 ctrl_count = 0
for dp, _dn, files in os.walk(config_dir): for dp, _dn, files in os.walk(config_dir):
+119
View File
@@ -0,0 +1,119 @@
---
name: db-cfe-admin
description: Управление расширениями конфигурации в информационной базе 1С. Используй когда нужно узнать какие расширения подключены к базе, выполнить проверку применимости или синтаксическую проверку, изменить безопасный режим или активность, удалить расширение из базы
argument-hint: <list|check|set-properties|delete> [database] [-Name <Имя>]
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-cfe-admin — Управление расширениями конфигурации
Расширения **на стороне базы**: состав и свойства подключения, проверки, удаление.
Про исходники расширения — другие навыки, см. «Смежное».
## Usage
```
/db-cfe-admin list [database]
/db-cfe-admin check [database] [-Name Расш1]
/db-cfe-admin set-properties [database] -Name Расш1 -SafeMode off
/db-cfe-admin delete [database] -Name Расш1
```
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Если `v8path` не задан — скрипт сам попытается определить платформу.
## Команда
```powershell
powershell.exe -NoProfile -File ".roo/skills/db-cfe-admin/scripts/db-cfe-admin.ps1" -Command <команда> <параметры>
```
### Общие параметры
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-Command <команда>` | да | `list` / `check` / `set-properties` / `delete` |
| `-V8Path <путь>` | нет | Каталог bin платформы или полный путь к `1cv8.exe` / `ibcmd.exe` |
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Пользователь базы |
| `-Password <пароль>` | нет | Пароль пользователя |
| `-Name <имя>` | усл. | Расширение. Обязателен для `set-properties`; в `delete` — вместо `-All`. Без него `list` и `check` работают по всем расширениям |
| `-All` | усл. | Только для `delete`: удалить все расширения. Вместо `-Name`, а не вместе с ним |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы `1cv8.exe` через запятую |
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
### Параметры `check`
| Параметр | Описание |
|----------|----------|
| `-Checks <список>` | `apply` — применимость расширения, `modules` — синтаксическая проверка, `config` — проверки конфигурации (целостность, ссылки, неиспользуемые процедуры и обработчики). Через запятую, по умолчанию `apply,modules` |
| `-Context <список>` | Контексты синтаксической проверки: `ThinClient`, `WebClient`, `Server`, `ExternalConnection`, `ThickClientManagedApplication`, `ThickClientOrdinaryApplication`, `MobileClient` и др. Через запятую, по умолчанию `ThinClient,Server` |
Применимость и синтаксис проверяют разное и друг друга не заменяют. Проверяется только
расширение: ошибки самой конфигурации сюда не попадают.
### Свойства `set-properties`
| Параметр | Описание |
|----------|----------|
| `-SafeMode <on/off>` | Безопасный режим |
| `-Active <on/off>` | Активность расширения |
| `-UnsafeActionProtection <on/off>` | Защита от опасных действий |
| `-UsedInDistributedInfobase <on/off>` | Использование в распределённой ИБ |
| `-Scope <область>` | `infobase` / `data-separation` |
| `-SecurityProfile <имя>` | Профиль безопасности |
Передавай только то, что меняешь: не указанное свойство остаётся как было. Расширение, впервые
попавшее в базу загрузкой, создаётся с включённым безопасным режимом, а в нём расширение модуля не
применяется.
Свойствами управляет `ibcmd` — он есть не в каждой установке платформы и работает с файловой базой;
остальные команды работают всегда.
## Смежное
| Задача | Навык |
|--------|-------|
| Создать расширение, заимствовать объекты, перехватить метод | `/cfe-init`, `/cfe-borrow`, `/cfe-patch-method`, `/cfe-validate` |
| Загрузить исходники расширения в базу | `/db-load-xml -Extension` (из коммита Git — `/db-load-git`) |
| Загрузить готовый `.cfe` | `/db-load-cf -Extension` |
| Выгрузить расширение из базы | `/db-dump-xml -Extension`, `/db-dump-cf -Extension` |
| Обновить конфигурацию базы после загрузки | `/db-update -Extension` |
| Проверить дрейф контролируемых методов по исходникам | `/cfe-patch-method -Check` |
## Примеры
```powershell
# Что подключено к базе
... -Command list -InfoBasePath "C:\Bases\MyDB"
# Проверить расширение
... -Command check -InfoBasePath "C:\Bases\MyDB" -Name "Расш1"
# Синтаксическая проверка в контексте веб-клиента
... -Command check -InfoBasePath "C:\Bases\MyDB" -Checks modules -Context WebClient,Server
# Снять безопасный режим
... -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" -SafeMode off
# Отключить, не удаляя
... -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" -Active off
# Убрать расширение из базы
... -Command delete -InfoBasePath "C:\Bases\MyDB" -Name "Расш1"
```
@@ -0,0 +1,992 @@
# db-cfe-admin v1.0 — Configuration extensions in a 1C infobase: list, check, properties, delete
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<#
.SYNOPSIS
Расширения конфигурации в информационной базе 1С
.DESCRIPTION
list — что за расширения в базе и в каком они состоянии
check — применимость и синтаксический контроль
set-properties — активность, безопасный режим, защита от опасных действий и прочие свойства
delete — удаление расширения из базы
.PARAMETER Command
list | check | set-properties | delete
.EXAMPLE
.\db-cfe-admin.ps1 -Command list -InfoBasePath "C:\Bases\MyDB"
.EXAMPLE
.\db-cfe-admin.ps1 -Command check -InfoBasePath "C:\Bases\MyDB" -Name "МоёРасширение"
.EXAMPLE
.\db-cfe-admin.ps1 -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "МоёРасширение" -SafeMode "-"
#>
[CmdletBinding(PositionalBinding=$false)]
param(
# Не Mandatory: обязательный параметр PowerShell запрашивает интерактивно, а в пакетном
# запуске это зависание. Пустое значение проверяем сами.
[Parameter(Mandatory=$false)]
[string]$Command,
[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]$Name,
[Parameter(Mandatory=$false)]
[switch]$All,
[Parameter(Mandatory=$false)]
[string]$Checks,
[Parameter(Mandatory=$false)]
[string]$Context,
# Тристабильные флаги: on включить, off выключить, не указан — не трогать.
# Значение "-" через powershell.exe -File парсер съедает молча (проверено), поэтому
# каноническая форма словесная; "+"/"-" принимаются, но в инструкции не значатся.
[Parameter(Mandatory=$false)]
[ValidateSet("on", "off", "yes", "no", "+", "-")]
[string]$SafeMode,
[Parameter(Mandatory=$false)]
[ValidateSet("on", "off", "yes", "no", "+", "-")]
[string]$Active,
[Parameter(Mandatory=$false)]
[ValidateSet("on", "off", "yes", "no", "+", "-")]
[string]$UnsafeActionProtection,
[Parameter(Mandatory=$false)]
[ValidateSet("on", "off", "yes", "no", "+", "-")]
[string]$UsedInDistributedInfobase,
[Parameter(Mandatory=$false)]
[ValidateSet("infobase", "data-separation")]
[string]$Scope,
[Parameter(Mandatory=$false)]
[string]$SecurityProfile,
[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
# Общий блок группы db-*: реквизиты хранилища, дополнительные аргументы, запуск платформы.
# Копии держит одинаковыми tests/skills/check-inline-drift.mjs — правку вносить в навык-эталон.
$Extension = $Name
# --- Реквизиты хранилища из .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 ---"
}
# --- Утилиты платформы: нужны обе, выбор по команде ---
# -V8Path указывает на каталог bin либо на любой из двух исполняемых файлов; второй берётся соседом.
$binDir = Split-Path $V8Path -Parent
$exeLeaf = Split-Path $V8Path -Leaf
# Расширение файла сохраняем: на Windows это .exe, на *nix его нет, в тестах — .cmd/.sh.
$exeSuffix = [System.IO.Path]::GetExtension($V8Path)
if ($exeLeaf -match '^ibcmd') {
$ibcmdExe = $V8Path
$v8Exe = Join-Path $binDir ("1cv8" + $exeSuffix)
} else {
$v8Exe = $V8Path
$ibcmdExe = Join-Path $binDir ("ibcmd" + $exeSuffix)
}
$hasV8 = Test-Path $v8Exe
$hasIbcmd = Test-Path $ibcmdExe
# --- Разбор и проверка команды ---
$knownCommands = @('list', 'check', 'set-properties', 'delete')
$cmd = if ($Command) { $Command.Trim().ToLower() } else { '' }
if (-not $cmd) {
Write-Host "Error: specify a command: $($knownCommands -join ' | ')" -ForegroundColor Red
exit 1
}
if ($knownCommands -notcontains $cmd) {
Write-Host "Error: unknown command '$Command' (expected: $($knownCommands -join ' | '))" -ForegroundColor Red
exit 1
}
# Пустое имя платформа трактует разрушительно: /DeleteCfg -Extension "" удаляет первое расширение
# из списка и рапортует успех. Поэтому пустое значение не доходит до платформы ни в одной команде.
if ($PSBoundParameters.ContainsKey('Name') -and [string]::IsNullOrWhiteSpace($Name)) {
Write-Host "Error: -Name is empty; omit it to address all extensions, or pass a name" -ForegroundColor Red
exit 1
}
$hasName = -not [string]::IsNullOrWhiteSpace($Name)
if ($hasName) { $Name = $Name.Trim() }
if ($All -and $cmd -ne 'delete') {
Write-Host "Error: -All applies to delete only (list and check address all extensions when -Name is omitted)" -ForegroundColor Red
exit 1
}
if ($cmd -eq 'delete') {
if ($hasName -and $All) {
Write-Host "Error: -Name and -All are mutually exclusive - pass one or the other" -ForegroundColor Red
exit 1
}
if (-not $hasName -and -not $All) {
Write-Host "Error: specify -Name <extension> or -All (an omitted name never means all)" -ForegroundColor Red
exit 1
}
}
if ($cmd -eq 'set-properties' -and -not $hasName) {
Write-Host "Error: set-properties needs -Name <extension>" -ForegroundColor Red
exit 1
}
# --- Проверки (-Checks) и контексты (-Context) ---
$knownChecks = @('apply', 'modules', 'config')
$checkList = @()
if ($Checks) {
$checkList = @($Checks -split ',' | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ })
foreach ($c in $checkList) {
if ($knownChecks -notcontains $c) {
Write-Host "Error: unknown check '$c' (expected: $($knownChecks -join ', '))" -ForegroundColor Red
exit 1
}
}
}
if ($checkList.Count -eq 0) { $checkList = @('apply', 'modules') }
$knownContexts = @('ThinClient', 'WebClient', 'MobileClient', 'MobileClientStandalone', 'MobileAppClient',
'Server', 'MobileAppServer', 'ExternalConnection', 'ExternalConnectionServer',
'ThickClientManagedApplication', 'ThickClientServerManagedApplication',
'ThickClientOrdinaryApplication', 'ThickClientServerOrdinaryApplication')
$contextList = @()
if ($Context) {
foreach ($c in @($Context -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
$match = $knownContexts | Where-Object { $_.Equals($c, [System.StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1
if (-not $match) {
Write-Host "Error: unknown context '$c' (expected: $($knownContexts -join ', '))" -ForegroundColor Red
exit 1
}
$contextList += $match
}
}
if ($Context -and $checkList.Count -gt 0 -and $checkList -notcontains 'modules') {
Write-Host "Error: -Context applies to the syntax check - add 'modules' to -Checks" -ForegroundColor Red
exit 1
}
if ($contextList.Count -eq 0) { $contextList = @('ThinClient', 'Server') }
# --- Свойства для set-properties ---
$script:propRu = @{
'safe-mode' = 'безопасный режим'
'active' = 'активно'
'unsafe-action-protection' = 'защита от опасных действий'
'used-in-distributed-infobase' = 'используется в РИБ'
'scope' = 'область действия'
'security-profile-name' = 'профиль безопасности'
'purpose' = 'назначение'
'version' = 'версия'
}
function Get-PropRu {
param([string]$Key)
if ($script:propRu.ContainsKey($Key)) { return $script:propRu[$Key] }
return $Key
}
function Convert-FlagValue {
param([string]$Value)
if (@('on', 'yes', '+') -contains $Value.ToLower()) { return 'yes' }
return 'no'
}
$propFlags = [ordered]@{}
if ($SafeMode) { $propFlags['safe-mode'] = (Convert-FlagValue $SafeMode) }
if ($Active) { $propFlags['active'] = (Convert-FlagValue $Active) }
if ($UnsafeActionProtection) { $propFlags['unsafe-action-protection'] = (Convert-FlagValue $UnsafeActionProtection) }
if ($UsedInDistributedInfobase) { $propFlags['used-in-distributed-infobase'] = (Convert-FlagValue $UsedInDistributedInfobase) }
if ($Scope) { $propFlags['scope'] = $Scope }
if ($PSBoundParameters.ContainsKey('SecurityProfile')) { $propFlags['security-profile-name'] = $SecurityProfile }
if ($cmd -eq 'set-properties' -and $propFlags.Count -eq 0) {
Write-Host "Error: set-properties needs at least one property (-SafeMode, -Active, -UnsafeActionProtection, -UsedInDistributedInfobase, -Scope, -SecurityProfile)" -ForegroundColor Red
exit 1
}
# --- Соединение ---
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
exit 1
}
# --- Дополнительные аргументы: у каждой утилиты свои ---
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
$v8Extra = @(Resolve-ExtraArgs '1cv8' $AdditionalV8Arguments @() $argHints)
$ibExtra = @(Resolve-ExtraArgs 'ibcmd' @() $AdditionalIbcmdArguments $argHints)
if ($AdditionalIbcmdArguments.Count -gt 0 -and @('check', 'delete') -contains $cmd) {
Write-Host "Error: -AdditionalIbcmdArguments does not apply to '$cmd' - it runs the Designer only" -ForegroundColor Red
exit 1
}
$script:repoSettings = Resolve-RepositorySettings
$baseLabel = if ($InfoBasePath) { $InfoBasePath } else { "$InfoBaseServer/$InfoBaseRef" }
# --- Запуск Конфигуратора: соединение, реквизиты хранилища и /Out навык держит сам ---
function Invoke-Designer {
param([string[]]$OpArgs)
if (-not $hasV8) {
Write-Host "Error: 1C executable not found at $v8Exe" -ForegroundColor Red
exit 1
}
$tempDir = Join-Path $env:TEMP "db_cfe_admin_$(Get-Random)"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
$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 += Get-RepositoryArgs $script:repoSettings
$arguments += $OpArgs
$outFile = Join-Path $tempDir "out.txt"
$arguments += "/Out", "`"$outFile`""
$arguments += "/DisableStartupDialogs"
$arguments += $v8Extra
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments '1cv8') -join ' ') @($Password, $UserName, $script:repoSettings.Password))"
$res = Invoke-PlatformProcess $v8Exe $arguments -PreQuoted
$log = ''
if (Test-Path $outFile) {
$raw = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($raw) { $log = $raw.Trim() }
}
return @{
ExitCode = $res.ExitCode
Log = $log
Output = $res.Output
}
} finally {
if (Test-Path $tempDir) { Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue }
}
}
function Invoke-Ibcmd {
param([string[]]$OpArgs)
$arguments = @($OpArgs)
$arguments += "--db-path=$InfoBasePath"
if ($UserName) { $arguments += "--user=$UserName" }
if ($Password) { $arguments += "--password=$Password" }
$arguments += $ibExtra
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments 'ibcmd') -join ' ') @($Password, $UserName))"
$res = Invoke-PlatformProcess $ibcmdExe $arguments
return @{ ExitCode = $res.ExitCode; Output = $res.Output }
}
function Write-PlatformFailure {
# Единый разбор неуспеха: что запускали, чем ответила платформа.
param($Result, [string]$What)
Write-Host "Error: $What (code: $($Result.ExitCode))$(Get-ExitAnnotation $Result.ExitCode)" -ForegroundColor Red
if ($Result.Log) {
Write-Host "--- Log ---"
Write-Host $Result.Log
Write-Host "--- End ---"
}
Write-PlatformOutput $Result.Output
}
# --- Свойства расширений: только ibcmd, и только для файловой базы ---
function Get-PropertiesUnavailableReason {
if (-not $InfoBasePath) { return "свойства читает ibcmd, а он подключается к файловой базе (--db-path)" }
if (-not $hasIbcmd) { return "рядом с 1cv8 нет ibcmd ($ibcmdExe) - эта установка платформы его не содержит" }
return $null
}
function ConvertFrom-IbcmdRecords {
# Вывод ibcmd: строки «ключ : значение», записи разделены пустой строкой.
param([string]$Text)
$records = @()
$cur = [ordered]@{}
foreach ($line in ($Text -split "`r?`n")) {
if ([string]::IsNullOrWhiteSpace($line)) {
if ($cur.Count -gt 0) { $records += ,$cur; $cur = [ordered]@{} }
continue
}
$idx = $line.IndexOf(':')
if ($idx -lt 0) { continue }
$key = $line.Substring(0, $idx).Trim()
$val = $line.Substring($idx + 1).Trim().Trim('"')
if ($key) { $cur[$key] = $val }
}
if ($cur.Count -gt 0) { $records += ,$cur }
return $records
}
function Get-ExtensionProperties {
# Хеш «имя расширения» -> запись свойств. Пустой, если ibcmd недоступен.
if (Get-PropertiesUnavailableReason) { return @{} }
$r = Invoke-Ibcmd @('infobase', 'config', 'extension', 'list')
if ($r.ExitCode -ne 0) { return @{} }
$map = @{}
foreach ($rec in (ConvertFrom-IbcmdRecords $r.Output)) {
if ($rec['name']) { $map[[string]$rec['name']] = $rec }
}
return $map
}
function Get-ExtensionNames {
# Имена расширений базы - Конфигуратором, чтобы работало и без ibcmd, и на серверной базе.
$r = Invoke-Designer @('/DumpDBCfgList', '-AllExtensions')
if ($r.ExitCode -ne 0) {
Write-PlatformFailure $r "cannot list extensions"
exit 1
}
return @($r.Log -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
# --- Человекочитаемые значения свойств ---
$script:flagRu = @{ 'yes' = 'да'; 'no' = 'нет' }
$script:scopeRu = @{ 'infobase' = 'Информационная база'; 'data-separation' = 'Область данных' }
$script:purposeRu = @{ 'customization' = 'Адаптация'; 'add-on' = 'Дополнение'; 'patch' = 'Исправление' }
function Format-PropValue {
param([string]$Key, $Value)
if ($null -eq $Value -or $Value -eq '') { return '' }
$v = [string]$Value
if ($Key -eq 'scope' -and $script:scopeRu.ContainsKey($v)) { return $script:scopeRu[$v] }
if ($Key -eq 'purpose' -and $script:purposeRu.ContainsKey($v)) { return $script:purposeRu[$v] }
if ($script:flagRu.ContainsKey($v)) { return $script:flagRu[$v] }
return $v
}
function Get-PropCell {
# Значение свойства для таблицы: «—», когда свойства вообще не читались.
param($Record, [string]$Key)
if (-not $Record) { return '—' }
if ($Record[$Key]) { return (Format-PropValue $Key $Record[$Key]) }
return ''
}
function Write-Table {
param([string[]]$Headers, $Rows)
$widths = @()
for ($i = 0; $i -lt $Headers.Count; $i++) {
$w = $Headers[$i].Length
foreach ($row in $Rows) { if (([string]$row[$i]).Length -gt $w) { $w = ([string]$row[$i]).Length } }
$widths += $w
}
$line = ' '
for ($i = 0; $i -lt $Headers.Count; $i++) { $line += $Headers[$i].PadRight($widths[$i] + 2) }
Write-Host $line.TrimEnd()
foreach ($row in $Rows) {
$l = ' '
for ($i = 0; $i -lt $Headers.Count; $i++) { $l += ([string]$row[$i]).PadRight($widths[$i] + 2) }
Write-Host $l.TrimEnd()
}
}
# ============================================================================
# Команды
# ============================================================================
if ($cmd -eq 'list') {
$names = @(Get-ExtensionNames)
if ($hasName) {
$names = @($names | Where-Object { $_.Equals($Name, [System.StringComparison]::OrdinalIgnoreCase) })
if ($names.Count -eq 0) {
Write-Host "[РАСШИРЕНИЯ] $baseLabel (0)"
Write-Host " расширение '$Name' в базе не найдено"
exit 1
}
}
Write-Host "[РАСШИРЕНИЯ] $baseLabel ($($names.Count))"
if ($names.Count -eq 0) {
Write-Host " расширений нет"
exit 0
}
$props = Get-ExtensionProperties
$reason = Get-PropertiesUnavailableReason
$rows = @()
foreach ($n in $names) {
$rec = $props[$n]
$rows += ,@($n,
(Get-PropCell $rec 'purpose'),
(Get-PropCell $rec 'active'),
(Get-PropCell $rec 'safe-mode'),
(Get-PropCell $rec 'unsafe-action-protection'),
(Get-PropCell $rec 'used-in-distributed-infobase'),
(Get-PropCell $rec 'scope'))
}
Write-Table @('Имя', 'Назначение', 'Активно', 'Безопасный режим', 'Защита', 'РИБ', 'Область') $rows
if ($reason) { Write-Host " свойства недоступны: $reason" }
exit 0
}
if ($cmd -eq 'check') {
# Список расширений заранее не запрашиваем: платформа сама отвечает «расширение не найдено»,
# а лишний запуск конфигуратора стоит дороже, чем разница в формулировке.
$target = if ($hasName) { $Name } else { $null }
if ($target) {
Write-Host "[ПРОВЕРКА] $baseLabel · $target"
} else {
Write-Host "[ПРОВЕРКА] $baseLabel · все расширения"
}
$failed = 0
$done = 0
$rows = @()
if ($checkList -contains 'apply') {
$opArgs = @('/CheckCanApplyConfigurationExtensions')
if ($target) { $opArgs += '-Extension', "`"$target`"" }
$r = Invoke-Designer $opArgs
$done++
$logLines = @($r.Log -split "`r?`n" | Where-Object { $_.Trim() -ne '' })
if ($r.ExitCode -eq 0) {
$rows += ,@{ Label = 'применимость'; Status = 'ОК'; Note = ''; Lines = @() }
} elseif ($r.ExitCode -eq 1) {
$failed++
$rows += ,@{ Label = 'применимость'; Status = 'ОШИБКА'; Note = ''; Lines = $logLines }
} else {
$failed++
$rows += ,@{ Label = 'применимость'; Status = 'СБОЙ'; Note = "код $($r.ExitCode)$(Get-ExitAnnotation $r.ExitCode)"; Lines = $logLines }
}
}
# modules и config - одна и та же команда платформы с разным набором флагов, поэтому при
# запросе обеих делается ОДИН запуск. Без флагов контекста платформа рапортует «ошибок не
# обнаружено» на заведомо сломанном модуле - набор всегда явный.
$wantModules = $checkList -contains 'modules'
$wantConfig = $checkList -contains 'config'
if ($wantModules -or $wantConfig) {
$opArgs = @('/CheckConfig')
if ($wantModules) { foreach ($c in $contextList) { $opArgs += "-$c" } }
if ($wantConfig) {
$opArgs += '-ConfigLogIntegrity', '-IncorrectReferences', '-UnreferenceProcedures', '-HandlersExistence', '-EmptyHandlers'
}
if ($target) { $opArgs += '-Extension', "`"$target`"" } else { $opArgs += '-AllExtensions' }
$r = Invoke-Designer $opArgs
$label = if ($wantModules -and $wantConfig) { 'модули и конфигурация' } elseif ($wantModules) { 'модули' } else { 'конфигурация' }
$done++
$logLines = @($r.Log -split "`r?`n" | Where-Object { $_.Trim() -ne '' })
if ($r.ExitCode -eq 0) {
$note = if ($wantModules) { "($($contextList -join ', '))" } else { '' }
$rows += ,@{ Label = $label; Status = 'ОК'; Note = $note; Lines = @() }
} elseif ($r.ExitCode -eq 1 -or $r.ExitCode -eq 101) {
$failed++
$rows += ,@{ Label = $label; Status = 'ОШИБКА'; Note = ''; Lines = $logLines }
} else {
$failed++
$rows += ,@{ Label = $label; Status = 'СБОЙ'; Note = "код $($r.ExitCode)$(Get-ExitAnnotation $r.ExitCode)"; Lines = $logLines }
}
}
# Сообщения платформы печатаются построчно под своей проверкой: они называют расширение и
# место ошибки, и при нескольких расширениях склейка в одну строку нечитаема.
$w = 0
foreach ($row in $rows) { if ($row.Label.Length -gt $w) { $w = $row.Label.Length } }
foreach ($row in $rows) {
$l = ' ' + $row.Label.PadRight($w + 2) + $row.Status.PadRight(9)
if ($row.Note) { $l += $row.Note }
Write-Host $l.TrimEnd()
foreach ($line in $row.Lines) { Write-Host (" " + $line.Trim()) }
}
if ($failed -gt 0) {
Write-Host "Итог: провалено $failed из $done"
exit 1
}
Write-Host "Итог: пройдено $done из $done"
exit 0
}
if ($cmd -eq 'set-properties') {
$reason = Get-PropertiesUnavailableReason
if ($reason) {
Write-Host "Error: cannot set properties - $reason" -ForegroundColor Red
exit 1
}
$before = Get-ExtensionProperties
if (-not $before.ContainsKey($Name)) {
Write-Host "Error: extension '$Name' not found in the infobase" -ForegroundColor Red
exit 1
}
$opArgs = @('infobase', 'config', 'extension', 'update', "--name=$Name")
foreach ($k in $propFlags.Keys) { $opArgs += "--$k=$($propFlags[$k])" }
$r = Invoke-Ibcmd $opArgs
if ($r.ExitCode -ne 0) {
Write-Host "Error: cannot set properties (code: $($r.ExitCode))$(Get-ExitAnnotation $r.ExitCode)" -ForegroundColor Red
Write-PlatformOutput $r.Output
exit 1
}
# Постусловие: состояние перечитывается, а не берётся из кода возврата.
$after = Get-ExtensionProperties
if (-not $after.ContainsKey($Name)) {
Write-Host "Error: extension '$Name' disappeared after the update" -ForegroundColor Red
exit 1
}
Write-Host "[СВОЙСТВА] $baseLabel · $Name"
$changed = 0
$stale = @()
foreach ($k in $propFlags.Keys) {
$was = Format-PropValue $k $before[$Name][$k]
$now = Format-PropValue $k $after[$Name][$k]
$want = Format-PropValue $k $propFlags[$k]
if ($was -ne $now) {
Write-Host (' ' + (Get-PropRu $k).PadRight(30) + "$was$now")
$changed++
} elseif ($now -ne $want) {
$stale += "$(Get-PropRu $k): просили '$want', в базе осталось '$now'"
}
}
if ($stale.Count -gt 0) {
foreach ($s in $stale) { Write-Host " $s" -ForegroundColor Yellow }
Write-Host "Итог: изменено $changed, не применено $($stale.Count)"
exit 1
}
if ($changed -eq 0) { Write-Host " свойства уже в этом состоянии" }
Write-Host "Итог: изменено $changed"
exit 0
}
if ($cmd -eq 'delete') {
$names = @(Get-ExtensionNames)
if ($names.Count -eq 0) {
Write-Host "[УДАЛЕНИЕ] $baseLabel"
Write-Host " расширений нет - удалять нечего"
exit 0
}
$targets = @()
if ($hasName) {
$match = @($names | Where-Object { $_.Equals($Name, [System.StringComparison]::OrdinalIgnoreCase) })
if ($match.Count -eq 0) {
Write-Host "Error: extension '$Name' not found in the infobase" -ForegroundColor Red
exit 1
}
$targets = $match
} else {
$targets = $names
}
Write-Host "[УДАЛЕНИЕ] $baseLabel (будет удалено: $($targets.Count))"
foreach ($t in $targets) {
# Имя непустое по построению: пустое отбито разбором параметров, список получен от платформы.
$r = Invoke-Designer @('/DeleteCfg', '-Extension', "`"$t`"")
if ($r.ExitCode -ne 0) {
Write-PlatformFailure $r "cannot delete extension '$t'"
exit 1
}
Write-Host " удалено: $t"
}
# Постусловие: список перечитывается - код возврата платформы сам по себе ничего не доказывает.
$rest = @(Get-ExtensionNames)
foreach ($t in $targets) {
if ($rest | Where-Object { $_.Equals($t, [System.StringComparison]::OrdinalIgnoreCase) }) {
Write-Host "Error: platform reported success, but '$t' is still in the infobase" -ForegroundColor Red
exit 1
}
}
Write-Host "Итог: удалено $($targets.Count), осталось $($rest.Count)"
exit 0
}
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-create/scripts/db-create.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -59,14 +59,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <п
```powershell ```powershell
# Создать файловую базу # Создать файловую базу
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" powershell.exe -NoProfile -File ".roo/skills/db-create/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB"
# Создать серверную базу # Создать серверную базу
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" powershell.exe -NoProfile -File ".roo/skills/db-create/scripts/db-create.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test"
# Создать из шаблона CF # Создать из шаблона CF
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" powershell.exe -NoProfile -File ".roo/skills/db-create/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf"
# Создать и добавить в список баз # Создать и добавить в список баз
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -AddToList -ListName "Новая база" powershell.exe -NoProfile -File ".roo/skills/db-create/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -AddToList -ListName "Новая база"
``` ```
@@ -1,4 +1,4 @@
# db-create v1.11 — Create 1C information base # db-create v1.14 — Create 1C information base
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -46,7 +46,7 @@
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база" .\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-create v1.11 — Create 1C information base # db-create v1.14 — Create 1C information base
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -120,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -292,7 +288,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -313,7 +309,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -332,11 +328,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -401,15 +417,15 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate template --- # --- Validate template ---
if args.UseTemplate and not os.path.exists(args.UseTemplate): if args.UseTemplate and not os.path.exists(args.UseTemplate):
print(f"Error: template file not found: {args.UseTemplate}", file=sys.stderr) print(f"Error: template file not found: {args.UseTemplate}")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
@@ -436,10 +452,9 @@ def main():
print( print(
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} " f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
"— information base was not created", "— information base was not created",
file=sys.stderr,
) )
else: else:
print(f"Error creating information base (code: {exit_code})", file=sys.stderr) print(f"Error creating information base (code: {exit_code})")
print_platform_output(result) print_platform_output(result)
sys.exit(exit_code) sys.exit(exit_code)
@@ -496,10 +511,9 @@ def main():
print( print(
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} " f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
"— information base was not created", "— information base was not created",
file=sys.stderr,
) )
else: else:
print(f"Error creating information base (code: {exit_code})", file=sys.stderr) print(f"Error creating information base (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -35,7 +35,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-dump-cf/scripts/db-dump-cf.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -60,11 +60,11 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <п
```powershell ```powershell
# Выгрузка конфигурации (файловая база) # Выгрузка конфигурации (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\config.cf" powershell.exe -NoProfile -File ".roo/skills/db-dump-cf/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\config.cf"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "config.cf" powershell.exe -NoProfile -File ".roo/skills/db-dump-cf/scripts/db-dump-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "config.cf"
# Выгрузка расширения # Выгрузка расширения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "ext.cfe" -Extension "МоёРасширение" powershell.exe -NoProfile -File ".roo/skills/db-dump-cf/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "ext.cfe" -Extension "МоёРасширение"
``` ```
@@ -1,4 +1,4 @@
# db-dump-cf v1.13 — Dump 1C configuration to CF file # db-dump-cf v1.16 — Dump 1C configuration to CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -49,7 +49,7 @@
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение" .\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-dump-cf v1.13 — Dump 1C configuration to CF file # db-dump-cf v1.16 — Dump 1C configuration to CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -120,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -352,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -422,10 +438,10 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Ensure output directory exists --- # --- Ensure output directory exists ---
@@ -436,7 +452,7 @@ def main():
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.AllExtensions: if args.AllExtensions:
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)", file=sys.stderr) print("Error: ibcmd config save does not support -AllExtensions (use -Extension)")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"] arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
if args.Extension: if args.Extension:
@@ -459,9 +475,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Configuration dumped successfully to: {args.OutputFile}") print(f"Configuration dumped successfully to: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
else: else:
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr) print(f"Error dumping configuration (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Temp dir --- # --- Temp dir ---
@@ -509,9 +525,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Configuration dumped successfully to: {args.OutputFile}") print(f"Configuration dumped successfully to: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
else: else:
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr) print(f"Error dumping configuration (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -38,7 +38,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-dump-dt/scripts/db-dump-dt.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -61,10 +61,10 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <п
```powershell ```powershell
# Выгрузка ИБ (файловая база) # Выгрузка ИБ (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt" powershell.exe -NoProfile -File ".roo/skills/db-dump-dt/scripts/db-dump-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt" powershell.exe -NoProfile -File ".roo/skills/db-dump-dt/scripts/db-dump-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt"
``` ```
## Связанные навыки ## Связанные навыки
@@ -1,4 +1,4 @@
# db-dump-dt v1.12 — Dump 1C information base to DT file # db-dump-dt v1.15 — Dump 1C information base to DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -39,7 +39,7 @@
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt" .\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-dump-dt v1.12 — Dump 1C information base to DT file # db-dump-dt v1.15 — Dump 1C information base to DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -120,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -352,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -420,10 +436,10 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Ensure output directory exists --- # --- Ensure output directory exists ---
@@ -452,9 +468,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Information base dumped successfully to: {args.OutputFile}") print(f"Information base dumped successfully to: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
else: else:
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr) print(f"Error dumping information base (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Temp dir --- # --- Temp dir ---
@@ -496,9 +512,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Information base dumped successfully to: {args.OutputFile}") print(f"Information base dumped successfully to: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
else: else:
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr) print(f"Error dumping information base (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -33,11 +33,12 @@ allowed-tools:
Если файла нет — предложи `/db-list add`. Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`. Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
Если в записи базы указан `configSrc` — используй как каталог выгрузки по умолчанию. Если в записи базы указан `configSrc` — используй как каталог выгрузки по умолчанию.
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-dump-xml/scripts/db-dump-xml.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -76,17 +77,17 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <
```powershell ```powershell
# Полная выгрузка (файловая база) # Полная выгрузка (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/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 -File ".roo/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 -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Changes powershell.exe -NoProfile -File ".roo/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Changes
# Частичная выгрузка # Частичная выгрузка
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ" powershell.exe -NoProfile -File ".roo/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 -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -ConfigDir "C:\WS\cfsrc" -Mode Full powershell.exe -NoProfile -File ".roo/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 -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение" powershell.exe -NoProfile -File ".roo/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение"
``` ```
@@ -1,4 +1,4 @@
# db-dump-xml v1.15 — Dump 1C configuration to XML files # db-dump-xml v1.21 — Dump 1C configuration to XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -61,7 +61,7 @@
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ" .\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -85,8 +85,10 @@ param(
[string]$ConfigDir, [string]$ConfigDir,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[ValidateSet("Full", "Changes", "Partial", "UpdateInfo")] # Пустое значение = режим не задан. Прежнее умолчание Changes подставляется ниже, после
[string]$Mode = "Changes", # того как станет видно, перечислены ли объекты.
[ValidateSet("", "Full", "Changes", "Partial", "UpdateInfo")]
[string]$Mode = "",
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$Objects, [string]$Objects,
@@ -101,6 +103,18 @@ param(
[ValidateSet("Hierarchical", "Plain")] [ValidateSet("Hierarchical", "Plain")]
[string]$Format = "Hierarchical", [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)] [Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(), [string[]]$AdditionalV8Arguments = @(),
@@ -111,6 +125,90 @@ param(
$OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::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 { function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex). # Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets) param([string]$Text, [string[]]$Secrets)
@@ -132,7 +230,7 @@ $script:IbcmdOwnedKeys = @(
'--import', '--export', '--apply', '--force', '--create-database', '--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password' '--user', '--password'
) )
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP') $script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd') $script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch { function Test-ArgKeyMatch {
@@ -415,8 +513,33 @@ if ($engine -eq "ibcmd") {
} }
# --- Validate Partial mode --- # --- 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) { if ($Mode -eq "Partial" -and -not $Objects) {
Write-Host "Error: -Objects required for Partial mode" -ForegroundColor Red Write-Host "Error: -Objects or -ObjectsFile required for Partial mode" -ForegroundColor Red
exit 1 exit 1
} }
@@ -486,6 +609,11 @@ try {
if ($UserName) { $arguments += "/N`"$UserName`"" } if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" } if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/DumpConfigToFiles", "`"$ConfigDir`"" $arguments += "/DumpConfigToFiles", "`"$ConfigDir`""
$arguments += "-Format", $Format $arguments += "-Format", $Format
@@ -530,7 +658,7 @@ try {
$arguments += $extraArgs $arguments += $extraArgs
# --- Execute --- # --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode $exitCode = $__v8.ExitCode
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-dump-xml v1.15 — Dump 1C configuration to XML files # db-dump-xml v1.21 — Dump 1C configuration to XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -72,10 +72,95 @@ IBCMD_OWNED_KEYS = [
"--import", "--export", "--apply", "--force", "--create-database", "--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password", "--user", "--password",
] ]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"] V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] 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): def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character """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 is not a letter catches glued /N"user" and --password=x, while keeping
@@ -120,7 +205,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +212,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +279,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +326,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +364,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +381,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +400,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -352,7 +453,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -388,14 +489,18 @@ def main():
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server") parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name") parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password") 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("-ConfigDir", required=True, help="Directory for configuration dump")
parser.add_argument( parser.add_argument(
"-Mode", "-Mode",
default="Changes", default="",
choices=["Full", "Changes", "Partial", "UpdateInfo"], choices=["", "Full", "Changes", "Partial", "UpdateInfo"],
help="Dump mode (default: Changes)", help="Dump mode (default: Changes)",
) )
parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)") 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("-Extension", default="", help="Extension name to dump")
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions") parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
parser.add_argument( parser.add_argument(
@@ -436,15 +541,40 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate Partial mode --- # --- 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: if args.Mode == "Partial" and not args.Objects:
print("Error: -Objects required for Partial mode", file=sys.stderr) print("Error: -Objects or -ObjectsFile required for Partial mode")
sys.exit(1) sys.exit(1)
# --- Create output dir if needed --- # --- Create output dir if needed ---
@@ -455,12 +585,12 @@ def main():
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) --- # --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.Format == "Plain": if args.Format == "Plain":
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1) sys.exit(1)
if args.AllExtensions: if args.AllExtensions:
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"] arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
elif args.Mode == "UpdateInfo": elif args.Mode == "UpdateInfo":
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8", file=sys.stderr) print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8")
sys.exit(1) sys.exit(1)
elif args.Mode == "Partial": elif args.Mode == "Partial":
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()] obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
@@ -490,9 +620,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Configuration exported successfully to: {args.ConfigDir}") print(f"Configuration exported successfully to: {args.ConfigDir}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr) print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported")
else: else:
print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr) print(f"Error exporting configuration (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Temp dir --- # --- Temp dir ---
@@ -513,6 +643,11 @@ def main():
if args.Password: if args.Password:
arguments.append(f'/P"{args.Password}"') arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"'] arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
arguments += ["-Format", args.Format] arguments += ["-Format", args.Format]
@@ -551,7 +686,7 @@ def main():
arguments.extend(quote_if_needed(a) for a in extra_args) arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute --- # --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments) result = run_v8(v8path, arguments)
exit_code = result.returncode exit_code = result.returncode
@@ -564,9 +699,9 @@ def main():
print("Dump completed successfully") print("Dump completed successfully")
print(f"Configuration dumped to: {args.ConfigDir}") print(f"Configuration dumped to: {args.ConfigDir}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped")
else: else:
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr) print(f"Error dumping configuration (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -40,7 +40,19 @@ allowed-tools:
"password": "", "password": "",
"aliases": ["dev", "разработка"], "aliases": ["dev", "разработка"],
"branches": ["dev", "develop", "feature/*"], "branches": ["dev", "develop", "feature/*"],
"configSrc": "C:\\WS\\myapp\\cfsrc" "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", "id": "test",
@@ -64,6 +76,7 @@ allowed-tools:
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение | | `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
| `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` | | `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` |
| `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) | | `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) |
| `newObjectPosition` | `"end"` / `"byName"` | Куда попадает новый объект в составе конфигурации: в конец своего вида (по умолчанию) или на место по имени. Переопределяется в `databases[]` |
| `databases` | array | Массив баз данных | | `databases` | array | Массив баз данных |
| `default` | string | id базы по умолчанию | | `default` | string | id базы по умолчанию |
@@ -82,6 +95,35 @@ allowed-tools:
| `aliases` | string[] | нет | Альтернативные имена для быстрого доступа | | `aliases` | string[] | нет | Альтернативные имена для быстрого доступа |
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`), привязанные к этой базе | | `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`), привязанные к этой базе |
| `configSrc` | string | нет | Каталог XML-выгрузки конфигурации | | `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.
> Обслуживается сервером хранилища. Если он недоступен, платформа отвечает «Соединение с
> хранилищем конфигурации не установлено» — тем же сообщением, что и при отсутствии реквизитов.
## Алгоритм разрешения базы данных ## Алгоритм разрешения базы данных
@@ -128,6 +170,7 @@ test Тестовая server srv01/MyApp_Test
- path (для file) или server + ref (для server) - path (для file) или server + ref (для server)
- user, password (необязательно) - user, password (необязательно)
- aliases, branches (необязательно) - aliases, branches (необязательно)
- если база под хранилищем конфигурации — `repository`: путь, пользователь, пароль
Добавь в массив `databases`. Если это первая база — установи как `default`. Добавь в массив `databases`. Если это первая база — установи как `default`.
@@ -159,3 +202,10 @@ test Тестовая server srv01/MyApp_Test
``` ```
> **Важно**: между `/N` и именем пробела нет. Между `/P` и паролем пробела нет. Если пароль пустой — опусти `/P` целиком. > **Важно**: между `/N` и именем пробела нет. Между `/P` и паролем пробела нет. Если пароль пустой — опусти `/P` целиком.
**Хранилище конфигурации** (если у базы задан `repository`) — скрипты навыков подставляют
сами, сопоставляя параметры соединения с записью реестра:
```
/ConfigurationRepositoryF"<path>" /ConfigurationRepositoryN"<user>" /ConfigurationRepositoryP"<password>"
```
@@ -36,7 +36,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-load-cf/scripts/db-load-cf.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -65,11 +65,11 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <п
```powershell ```powershell
# Файловая база # Файловая база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\config.cf" powershell.exe -NoProfile -File ".roo/skills/db-load-cf/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\config.cf"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "config.cf" powershell.exe -NoProfile -File ".roo/skills/db-load-cf/scripts/db-load-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "config.cf"
# Загрузка расширения # Загрузка расширения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "ext.cfe" -Extension "МоёРасширение" powershell.exe -NoProfile -File ".roo/skills/db-load-cf/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "ext.cfe" -Extension "МоёРасширение"
``` ```
@@ -1,4 +1,4 @@
# db-load-cf v1.14 — Load 1C configuration from CF file # db-load-cf v1.17 — Load 1C configuration from CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -49,7 +49,7 @@
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение" .\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-load-cf v1.14 — Load 1C configuration from CF file # db-load-cf v1.17 — Load 1C configuration from CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -120,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -352,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -440,21 +456,21 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate input file --- # --- Validate input file ---
if not os.path.isfile(args.InputFile): if not os.path.isfile(args.InputFile):
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr) print(f"Error: input file not found: {args.InputFile}")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.AllExtensions: if args.AllExtensions:
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)", file=sys.stderr) print("Error: ibcmd config load does not support -AllExtensions (use -Extension)")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"] arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
if args.Extension: if args.Extension:
@@ -473,7 +489,7 @@ def main():
if result.returncode == 0: if result.returncode == 0:
print(f"Configuration loaded successfully from: {args.InputFile}") print(f"Configuration loaded successfully from: {args.InputFile}")
else: else:
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
# --- Temp dir --- # --- Temp dir ---
@@ -517,7 +533,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Configuration loaded successfully from: {args.InputFile}") print(f"Configuration loaded successfully from: {args.InputFile}")
else: else:
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -52,7 +52,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-load-dt/scripts/db-load-dt.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -82,10 +82,10 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <п
```powershell ```powershell
# Файловая база # Файловая база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt" powershell.exe -NoProfile -File ".roo/skills/db-load-dt/scripts/db-load-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt"
# Серверная база с ускорением загрузки # Серверная база с ускорением загрузки
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4 powershell.exe -NoProfile -File ".roo/skills/db-load-dt/scripts/db-load-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4
``` ```
## Связанные навыки ## Связанные навыки
@@ -1,4 +1,4 @@
# db-load-dt v1.13 — Load 1C information base from DT file # db-load-dt v1.16 — Load 1C information base from DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -46,7 +46,7 @@
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt" .\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-load-dt v1.13 — Load 1C information base from DT file # db-load-dt v1.16 — Load 1C information base from DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -120,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -352,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -440,15 +456,15 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate input file --- # --- Validate input file ---
if not os.path.isfile(args.InputFile): if not os.path.isfile(args.InputFile):
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr) print(f"Error: input file not found: {args.InputFile}")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
@@ -470,7 +486,7 @@ def main():
if result.returncode == 0: if result.returncode == 0:
print(f"Information base restored successfully from: {args.InputFile}") print(f"Information base restored successfully from: {args.InputFile}")
else: else:
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
# --- Temp dir --- # --- Temp dir ---
@@ -512,7 +528,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Information base restored successfully from: {args.InputFile}") print(f"Information base restored successfully from: {args.InputFile}")
else: else:
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -38,7 +38,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-load-git/scripts/db-load-git.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -72,8 +72,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <
```powershell ```powershell
# Все незафиксированные изменения # Все незафиксированные изменения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/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 -File ".roo/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 -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\WS\cfsrc" -Source Commit -CommitRange "HEAD~3..HEAD" powershell.exe -NoProfile -File ".roo/skills/db-load-git/scripts/db-load-git.ps1" -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\WS\cfsrc" -Source Commit -CommitRange "HEAD~3..HEAD"
``` ```
@@ -1,4 +1,4 @@
# db-load-git v1.20 — Load Git changes into 1C database # db-load-git v1.26 — Load Git changes into 1C database
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -64,7 +64,7 @@
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun .\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -116,6 +116,15 @@ param(
# но в логе есть отбраковка. # но в логе есть отбраковка.
[switch]$StrictLog, [switch]$StrictLog,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(), [string[]]$AdditionalV8Arguments = @(),
@@ -126,6 +135,115 @@ param(
$OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::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 { function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex). # Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets) param([string]$Text, [string[]]$Secrets)
@@ -147,7 +265,7 @@ $script:IbcmdOwnedKeys = @(
'--import', '--export', '--apply', '--force', '--create-database', '--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password' '--user', '--password'
) )
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP') $script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd') $script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch { function Test-ArgKeyMatch {
@@ -668,6 +786,11 @@ try {
if ($UserName) { $arguments += "/N`"$UserName`"" } if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" } if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`"" $arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
$arguments += "-listFile", "`"$listFile`"" $arguments += "-listFile", "`"$listFile`""
$arguments += "-Format", $Format $arguments += "-Format", $Format
@@ -695,7 +818,7 @@ try {
# --- Execute --- # --- Execute ---
Write-Host "" Write-Host ""
Write-Host "Executing partial configuration load..." Write-Host "Executing partial configuration load..."
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode $exitCode = $__v8.ExitCode
@@ -718,6 +841,7 @@ try {
} }
} }
Write-PlatformOutput $__v8.Output Write-PlatformOutput $__v8.Output
Write-RepositoryHints $logContent
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы # Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку # разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-load-git v1.20 — Load Git changes into 1C database # db-load-git v1.26 — Load Git changes into 1C database
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -72,10 +72,116 @@ IBCMD_OWNED_KEYS = [
"--import", "--export", "--apply", "--force", "--create-database", "--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password", "--user", "--password",
] ]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"] V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] 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): def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character """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 is not a letter catches glued /N"user" and --password=x, while keeping
@@ -120,7 +226,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +233,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +300,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +347,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +385,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +402,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +421,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -384,7 +506,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -460,6 +582,9 @@ def main():
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server") parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name") parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password") 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("-ConfigDir", required=True, help="Directory with XML configuration (git repo)")
parser.add_argument( parser.add_argument(
"-Source", "-Source",
@@ -506,10 +631,10 @@ def main():
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Resolve additional arguments for the selected engine --- # --- Resolve additional arguments for the selected engine ---
@@ -526,19 +651,19 @@ def main():
# --- Validate config dir --- # --- Validate config dir ---
if not os.path.exists(args.ConfigDir): if not os.path.exists(args.ConfigDir):
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr) print(f"Error: config directory not found: {args.ConfigDir}")
sys.exit(1) sys.exit(1)
# --- Validate Commit mode --- # --- Validate Commit mode ---
if args.Source == "Commit" and not args.CommitRange: if args.Source == "Commit" and not args.CommitRange:
print("Error: -CommitRange required for Source=Commit", file=sys.stderr) print("Error: -CommitRange required for Source=Commit")
sys.exit(1) sys.exit(1)
# --- Check git --- # --- Check git ---
try: try:
subprocess.run(["git", "--version"], capture_output=True, text=True, check=True) subprocess.run(["git", "--version"], capture_output=True, text=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: git not found in PATH", file=sys.stderr) print("Error: git not found in PATH")
sys.exit(1) sys.exit(1)
# --- Get changed files from Git --- # --- Get changed files from Git ---
@@ -617,10 +742,10 @@ def main():
config_files.append(rel_path) config_files.append(rel_path)
if support_skipped: if support_skipped:
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):", file=sys.stderr) print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):")
for sf in support_skipped: for sf in support_skipped:
print(f" - {sf}", file=sys.stderr) print(f" - {sf}")
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).", file=sys.stderr) print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).")
if len(config_files) == 0: if len(config_files) == 0:
print("No configuration files found in changes") print("No configuration files found in changes")
@@ -644,10 +769,10 @@ def main():
if engine == "ibcmd": if engine == "ibcmd":
# --- ibcmd branch (file infobase only; import specific files) --- # --- ibcmd branch (file infobase only; import specific files) ---
if args.Format == "Plain": if args.Format == "Plain":
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1) sys.exit(1)
if args.AllExtensions: if args.AllExtensions:
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)", file=sys.stderr) print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "import", "files"] + config_files arguments = ["infobase", "config", "import", "files"] + config_files
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"] arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
@@ -664,7 +789,7 @@ def main():
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName)) result = run_ibcmd([v8path] + arguments, bool(args.UserName))
if result.returncode != 0: if result.returncode != 0:
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
print(f"Changes loaded successfully ({len(config_files)} files)") print(f"Changes loaded successfully ({len(config_files)} files)")
exit_code = 0 exit_code = 0
@@ -682,7 +807,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Database configuration updated successfully") print("Database configuration updated successfully")
else: else:
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
print_platform_output(ar) print_platform_output(ar)
sys.exit(exit_code) sys.exit(exit_code)
@@ -704,6 +829,11 @@ def main():
if args.Password: if args.Password:
arguments.append(f'/P"{args.Password}"') arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"'] arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
arguments += ["-listFile", f'"{list_file}"'] arguments += ["-listFile", f'"{list_file}"']
arguments += ["-Format", args.Format] arguments += ["-Format", args.Format]
@@ -729,7 +859,7 @@ def main():
# --- Execute --- # --- Execute ---
print("") print("")
print("Executing partial configuration load...") print("Executing partial configuration load...")
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments) result = run_v8(v8path, arguments)
exit_code = result.returncode exit_code = result.returncode
@@ -739,7 +869,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Load completed successfully") print("Load completed successfully")
else: else:
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
log_content = "" log_content = ""
if os.path.isfile(out_file): if os.path.isfile(out_file):
@@ -754,6 +884,7 @@ def main():
pass pass
print_platform_output(result) print_platform_output(result)
write_repository_hints(log_content)
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы # Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку # разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
@@ -34,11 +34,12 @@ allowed-tools:
Если файла нет — предложи `/db-list add`. Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`. Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
Если в записи базы указан `configSrc` — используй как каталог загрузки по умолчанию. Если в записи базы указан `configSrc` — используй как каталог загрузки по умолчанию.
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-load-xml/scripts/db-load-xml.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -90,14 +91,14 @@ Documents/Заказ/Forms/ФормаДокумента.xml
```powershell ```powershell
# Полная загрузка # Полная загрузка
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/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 -File ".roo/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 -File "${CLAUDE_SKILL_DIR}/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 -File ".roo/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 -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение" powershell.exe -NoProfile -File ".roo/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 -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full -UpdateDB powershell.exe -NoProfile -File ".roo/skills/db-load-xml/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full -UpdateDB
``` ```
@@ -1,4 +1,4 @@
# db-load-xml v1.21 — Load 1C configuration from XML files # db-load-xml v1.28 — Load 1C configuration from XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -61,7 +61,7 @@
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl" .\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -85,8 +85,10 @@ param(
[string]$ConfigDir, [string]$ConfigDir,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[ValidateSet("Full", "Partial")] # Пустое значение = режим не задан. Прежнее умолчание Full подставляется ниже, после того
[string]$Mode = "Full", # как станет видно, перечислены ли файлы.
[ValidateSet("", "Full", "Partial")]
[string]$Mode = "",
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$Files, [string]$Files,
@@ -110,6 +112,15 @@ param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[switch]$StrictLog, [switch]$StrictLog,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(), [string[]]$AdditionalV8Arguments = @(),
@@ -120,6 +131,115 @@ param(
$OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::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 { function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex). # Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets) param([string]$Text, [string[]]$Secrets)
@@ -158,7 +278,7 @@ $script:IbcmdOwnedKeys = @(
'--import', '--export', '--apply', '--force', '--create-database', '--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password' '--user', '--password'
) )
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP') $script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd') $script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch { function Test-ArgKeyMatch {
@@ -475,6 +595,16 @@ if (-not (Test-Path $ConfigDir)) {
exit 1 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 --- # --- Validate Partial mode ---
if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) { if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) {
Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red
@@ -494,7 +624,7 @@ try {
} }
if ($AllExtensions) { if ($AllExtensions) {
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath") $arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
} elseif ($Mode -eq "Partial" -or $Files -or $ListFile) { } elseif ($Mode -eq "Partial") {
# partial: import specific files (relative to ConfigDir) # partial: import specific files (relative to ConfigDir)
$fileList = @() $fileList = @()
if ($ListFile) { if ($ListFile) {
@@ -567,6 +697,11 @@ try {
if ($UserName) { $arguments += "/N`"$UserName`"" } if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" } if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`"" $arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
if ($Mode -eq "Full") { if ($Mode -eq "Full") {
@@ -631,7 +766,7 @@ try {
$arguments += $extraArgs $arguments += $extraArgs
# --- Execute --- # --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode $exitCode = $__v8.ExitCode
@@ -660,6 +795,7 @@ try {
Write-Host "--- End ---" Write-Host "--- End ---"
} }
Write-PlatformOutput $__v8.Output Write-PlatformOutput $__v8.Output
Write-RepositoryHints $logContent
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы # Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку # разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-load-xml v1.21 — Load 1C configuration from XML files # db-load-xml v1.28 — Load 1C configuration from XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -72,10 +72,116 @@ IBCMD_OWNED_KEYS = [
"--import", "--export", "--apply", "--force", "--create-database", "--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password", "--user", "--password",
] ]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"] V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] 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): def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character """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 is not a letter catches glued /N"user" and --password=x, while keeping
@@ -120,7 +226,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +233,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +300,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +347,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +385,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +402,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +421,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -384,7 +506,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -438,11 +560,14 @@ def main():
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server") parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name") parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password") 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("-ConfigDir", required=True, help="Directory with XML configuration sources")
parser.add_argument( parser.add_argument(
"-Mode", "-Mode",
default="Full", default="",
choices=["Full", "Partial"], choices=["", "Full", "Partial"],
help="Load mode (default: Full)", help="Load mode (default: Full)",
) )
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)") parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
@@ -495,34 +620,42 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate config dir --- # --- Validate config dir ---
if not os.path.exists(args.ConfigDir): if not os.path.exists(args.ConfigDir):
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr) print(f"Error: config directory not found: {args.ConfigDir}")
sys.exit(1) sys.exit(1)
# --- Validate Partial mode --- # --- 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: if args.Mode == "Partial" and not args.Files and not args.ListFile:
print("Error: -Files or -ListFile required for Partial mode", file=sys.stderr) print("Error: -Files or -ListFile required for Partial mode")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only; hierarchical full-directory import) --- # --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.Format == "Plain": if args.Format == "Plain":
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1) sys.exit(1)
if args.AllExtensions: if args.AllExtensions:
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"] arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
elif args.Mode == "Partial" or args.Files or args.ListFile: elif args.Mode == "Partial":
# partial: import specific files (relative to ConfigDir) # partial: import specific files (relative to ConfigDir)
if args.ListFile: if args.ListFile:
if not os.path.isfile(args.ListFile): if not os.path.isfile(args.ListFile):
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr) print(f"Error: list file not found: {args.ListFile}")
sys.exit(1) sys.exit(1)
with open(args.ListFile, encoding="utf-8-sig") as f: with open(args.ListFile, encoding="utf-8-sig") as f:
file_list = [ln.strip() for ln in f if ln.strip()] file_list = [ln.strip() for ln in f if ln.strip()]
@@ -531,7 +664,7 @@ def main():
else: else:
file_list = [] file_list = []
if not file_list: if not file_list:
print("Error: -Files or -ListFile required for partial import", file=sys.stderr) print("Error: -Files or -ListFile required for partial import")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "import", "files"] + file_list arguments = ["infobase", "config", "import", "files"] + file_list
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"] arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
@@ -553,7 +686,7 @@ def main():
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName)) result = run_ibcmd([v8path] + arguments, bool(args.UserName))
if result.returncode != 0: if result.returncode != 0:
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
print(f"Configuration loaded successfully from: {args.ConfigDir}") print(f"Configuration loaded successfully from: {args.ConfigDir}")
exit_code = 0 exit_code = 0
@@ -571,7 +704,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Database configuration updated successfully") print("Database configuration updated successfully")
else: else:
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
print_platform_output(ar) print_platform_output(ar)
sys.exit(exit_code) sys.exit(exit_code)
@@ -593,6 +726,11 @@ def main():
if args.Password: if args.Password:
arguments.append(f'/P"{args.Password}"') arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"'] arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
if args.Mode == "Full": if args.Mode == "Full":
@@ -603,7 +741,7 @@ def main():
# Build list file # Build list file
if args.ListFile: if args.ListFile:
if not os.path.isfile(args.ListFile): if not os.path.isfile(args.ListFile):
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr) print(f"Error: list file not found: {args.ListFile}")
sys.exit(1) sys.exit(1)
with open(args.ListFile, encoding="utf-8-sig") as f: with open(args.ListFile, encoding="utf-8-sig") as f:
raw_list = [ln.strip() for ln in f if ln.strip()] raw_list = [ln.strip() for ln in f if ln.strip()]
@@ -615,12 +753,12 @@ def main():
support_files = [x for x in raw_list if support_re.search(x)] 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)] file_list = [x for x in raw_list if not support_re.search(x)]
if support_files: if support_files:
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):", file=sys.stderr) print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):")
for sf in support_files: for sf in support_files:
print(f" - {sf}", file=sys.stderr) print(f" - {sf}")
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.", file=sys.stderr) print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.")
if not file_list: if not file_list:
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.", file=sys.stderr) print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.")
sys.exit(1) sys.exit(1)
generated_list_file = os.path.join(temp_dir, "load_list.txt") generated_list_file = os.path.join(temp_dir, "load_list.txt")
with open(generated_list_file, "w", encoding="utf-8-sig") as f: with open(generated_list_file, "w", encoding="utf-8-sig") as f:
@@ -652,7 +790,7 @@ def main():
arguments.extend(quote_if_needed(a) for a in extra_args) arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute --- # --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments) result = run_v8(v8path, arguments)
exit_code = result.returncode exit_code = result.returncode
@@ -677,7 +815,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Load completed successfully") print("Load completed successfully")
else: else:
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
if log_content: if log_content:
print("--- Log ---") print("--- Log ---")
@@ -685,6 +823,7 @@ def main():
print("--- End ---") print("--- End ---")
print_platform_output(result) print_platform_output(result)
write_repository_hints(log_content)
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы # Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку # разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем. # про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
+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 -File ".roo/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 -File ".roo/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -WithChildren
# Захватить корень — он нужен, чтобы добавить или удалить объект конфигурации
powershell.exe -NoProfile -File ".roo/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация"
# Поместить новый объект: он уже существует, поэтому называется вместе с корнем
powershell.exe -NoProfile -File ".roo/skills/db-repo/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация,Справочник.Склады" -Comment "Добавлен справочник Склады"
# Поместить с комментарием, оставив захват
powershell.exe -NoProfile -File ".roo/skills/db-repo/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -Comment "Добавлен реквизит Артикул" -KeepLocked
# Получить изменения из хранилища
powershell.exe -NoProfile -File ".roo/skills/db-repo/scripts/db-repo.ps1" -Command update -InfoBasePath "C:\Bases\MyDB"
# Серверная база, расширение
powershell.exe -NoProfile -File ".roo/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` описывают хранилище-**источник**. Удалённые пользователи
не копируются; существующие не перезаписываются.
+39
View File
@@ -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 "<Имя>"` указывай вместе с путём именно к нему, а не
к хранилищу основной конфигурации.
+31
View File
@@ -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`) выгружается последняя версия.
+31
View File
@@ -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
@@ -36,7 +36,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-run/scripts/db-run.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -64,14 +64,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <пар
```powershell ```powershell
# Простой запуск # Простой запуск
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" powershell.exe -NoProfile -File ".roo/skills/db-run/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin"
# Запуск с обработкой # Запуск с обработкой
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Execute "C:\epf\МояОбработка.epf" powershell.exe -NoProfile -File ".roo/skills/db-run/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Execute "C:\epf\МояОбработка.epf"
# Открыть по навигационной ссылке # Открыть по навигационной ссылке
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -URL "e1cib/data/Справочник.Номенклатура" powershell.exe -NoProfile -File ".roo/skills/db-run/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -URL "e1cib/data/Справочник.Номенклатура"
# Серверная база с параметром запуска # Серверная база с параметром запуска
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -CParam "ЗапуститьОбновление" powershell.exe -NoProfile -File ".roo/skills/db-run/scripts/db-run.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -CParam "ЗапуститьОбновление"
``` ```
@@ -1,4 +1,4 @@
# db-run v1.8 — Launch 1C:Enterprise # db-run v1.10 — Launch 1C:Enterprise
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -52,7 +52,7 @@
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -CParam "ЗапуститьОбновление" .\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -CParam "ЗапуститьОбновление"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-run v1.8 — Launch 1C:Enterprise # db-run v1.10 — Launch 1C:Enterprise
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -117,7 +117,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -125,7 +124,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -193,14 +191,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -225,7 +221,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -260,14 +256,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -327,7 +323,7 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Build arguments --- # --- Build arguments ---
@@ -377,7 +373,7 @@ def main():
time.sleep(0.2) time.sleep(0.2)
rc = proc.poll() rc = proc.poll()
if rc is not None: if rc is not None:
print(f"Error: 1C:Enterprise exited immediately (code: {rc})", file=sys.stderr) print(f"Error: 1C:Enterprise exited immediately (code: {rc})")
sys.exit(rc if rc and rc > 0 else 1) sys.exit(rc if rc and rc > 0 else 1)
print(f"PID: {proc.pid}") print(f"PID: {proc.pid}")
print("1C:Enterprise launched") print("1C:Enterprise launched")
@@ -11,14 +11,16 @@ allowed-tools:
# /db-update — Обновление конфигурации БД # /db-update — Обновление конфигурации БД
Применяет изменения основной конфигурации к конфигурации базы данных (`/UpdateDBCfg`). Обязательный шаг после `/db-load-cf`, `/db-load-xml`, `/db-load-git`. Применяет изменения основной конфигурации к конфигурации базы данных (`/UpdateDBCfg`)
отдельным шагом после загрузки. У `/db-load-xml` и `/db-load-git` то же самое делает
ключ `-UpdateDB`.
## Usage ## Usage
``` ```
/db-update [database] /db-update [database]
/db-update dev /db-update dev
/db-update dev -Dynamic+ /db-update dev -Dynamic on
``` ```
## Параметры подключения ## Параметры подключения
@@ -35,7 +37,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/db-update/scripts/db-update.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -50,7 +52,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
| `-Password <пароль>` | нет | Пароль | | `-Password <пароль>` | нет | Пароль |
| `-Extension <имя>` | нет | Обновить расширение | | `-Extension <имя>` | нет | Обновить расширение |
| `-AllExtensions` | нет | Обновить все расширения | | `-AllExtensions` | нет | Обновить все расширения |
| `-Dynamic <+/->` | нет | `+` — динамическое обновление, `-` — отключить | | `-Dynamic <on/off>` | нет | `on` — динамическое обновление, без монопольного доступа к базе; `off` — отключить |
| `-Server` | нет | Обновление на стороне сервера | | `-Server` | нет | Обновление на стороне сервера |
| `-WarningsAsErrors` | нет | Предупреждения считать ошибками | | `-WarningsAsErrors` | нет | Предупреждения считать ошибками |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` | | `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
@@ -68,21 +70,15 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
| `-BackgroundSuspend` | Приостановить | | `-BackgroundSuspend` | Приостановить |
| `-BackgroundResume` | Возобновить | | `-BackgroundResume` | Возобновить |
## Предупреждения
- Если обновление **не динамическое** — потребуется **монопольный доступ** к базе (все пользователи должны выйти)
- Для серверных баз рекомендуется `-Dynamic+` для обновления без остановки
- Если структура данных существенно изменилась (удаление реквизитов, изменение типов) — динамическое обновление может быть невозможно
## Примеры ## Примеры
```powershell ```powershell
# Обычное обновление (файловая база) # Обычное обновление (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" powershell.exe -NoProfile -File ".roo/skills/db-update/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin"
# Динамическое обновление (серверная база) # Динамическое обновление
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -Dynamic "+" powershell.exe -NoProfile -File ".roo/skills/db-update/scripts/db-update.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -Dynamic on
# Обновление расширения # Обновление расширения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Extension "МоёРасширение" powershell.exe -NoProfile -File ".roo/skills/db-update/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Extension "МоёРасширение"
``` ```
@@ -1,4 +1,4 @@
# db-update v1.15 — Update 1C database configuration # db-update v1.20 — Update 1C database configuration
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -34,7 +34,7 @@
Обновить все расширения Обновить все расширения
.PARAMETER Dynamic .PARAMETER Dynamic
Динамическое обновление: "+" включить, "-" отключить Динамическое обновление: on включить, off отключить
.PARAMETER Server .PARAMETER Server
Обновление на стороне сервера Обновление на стороне сервера
@@ -55,7 +55,7 @@
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB" -Dynamic "+" -Extension "МоёРасширение" .\db-update.ps1 -InfoBasePath "C:\Bases\MyDB" -Dynamic "+" -Extension "МоёРасширение"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -81,8 +81,10 @@ param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[switch]$AllExtensions, [switch]$AllExtensions,
# on/off, а не +/-: значение "-" через powershell.exe -File парсер не связывает и молча
# выходит с кодом 2, без единого сообщения. "+"/"-" принимаются, но в инструкции не значатся.
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[ValidateSet("+", "-")] [ValidateSet("on", "off", "yes", "no", "+", "-")]
[string]$Dynamic, [string]$Dynamic,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
@@ -97,6 +99,15 @@ param(
# но в логе есть отбраковка. # но в логе есть отбраковка.
[switch]$StrictLog, [switch]$StrictLog,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(), [string[]]$AdditionalV8Arguments = @(),
@@ -104,9 +115,95 @@ param(
[string[]]$AdditionalIbcmdArguments = @() [string[]]$AdditionalIbcmdArguments = @()
) )
if ($Dynamic) { $Dynamic = if (@('on', 'yes', '+') -contains $Dynamic.ToLower()) { '+' } else { '-' } }
$OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::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 { function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex). # Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets) param([string]$Text, [string[]]$Secrets)
@@ -145,7 +242,7 @@ $script:IbcmdOwnedKeys = @(
'--import', '--export', '--apply', '--force', '--create-database', '--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password' '--user', '--password'
) )
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP') $script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd') $script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch { function Test-ArgKeyMatch {
@@ -499,6 +596,11 @@ try {
if ($UserName) { $arguments += "/N`"$UserName`"" } if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" } if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/UpdateDBCfg" $arguments += "/UpdateDBCfg"
# --- Options --- # --- Options ---
@@ -526,7 +628,7 @@ try {
$arguments += $extraArgs $arguments += $extraArgs
# --- Execute --- # --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode $exitCode = $__v8.ExitCode
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-update v1.15 — Update 1C database configuration # db-update v1.20 — Update 1C database configuration
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -72,10 +72,95 @@ IBCMD_OWNED_KEYS = [
"--import", "--export", "--apply", "--force", "--create-database", "--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password", "--user", "--password",
] ]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"] V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] 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): def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character """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 is not a letter catches glued /N"user" and --password=x, while keeping
@@ -120,7 +205,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +212,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +279,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +326,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +364,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +381,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +400,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -384,7 +485,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -438,9 +539,14 @@ def main():
parser.add_argument("-InfoBaseRef", default="") parser.add_argument("-InfoBaseRef", default="")
parser.add_argument("-UserName", default="") parser.add_argument("-UserName", default="")
parser.add_argument("-Password", 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("-Extension", default="")
parser.add_argument("-AllExtensions", action="store_true") parser.add_argument("-AllExtensions", action="store_true")
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"]) # on/off, а не +/-: значение "-" через powershell.exe -File парсер PS не связывает и молча
# выходит с кодом 2. "+"/"-" принимаются, но в инструкции не значатся.
parser.add_argument("-Dynamic", default="", choices=["", "on", "off", "yes", "no", "+", "-"])
parser.add_argument("-Server", action="store_true") parser.add_argument("-Server", action="store_true")
parser.add_argument("-WarningsAsErrors", action="store_true") parser.add_argument("-WarningsAsErrors", action="store_true")
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md # Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
@@ -455,6 +561,9 @@ def main():
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = ci_parse_args(parser, argv) args = ci_parse_args(parser, argv)
if args.Dynamic:
args.Dynamic = "+" if args.Dynamic.lower() in ("on", "yes", "+") else "-"
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
assert_infobase_exists(args.InfoBasePath) assert_infobase_exists(args.InfoBasePath)
@@ -478,16 +587,16 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.AllExtensions: if args.AllExtensions:
print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)", file=sys.stderr) print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"] arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
if args.Dynamic == "+": if args.Dynamic == "+":
@@ -509,7 +618,7 @@ def main():
if result.returncode == 0: if result.returncode == 0:
print("Database configuration updated successfully") print("Database configuration updated successfully")
else: else:
print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
# --- Temp dir --- # --- Temp dir ---
@@ -530,6 +639,11 @@ def main():
if args.Password: if args.Password:
arguments.append(f'/P"{args.Password}"') arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments.append("/UpdateDBCfg") arguments.append("/UpdateDBCfg")
# --- Options --- # --- Options ---
@@ -553,7 +667,7 @@ def main():
arguments.extend(quote_if_needed(a) for a in extra_args) arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute --- # --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments) result = run_v8(v8path, arguments)
exit_code = result.returncode exit_code = result.returncode
@@ -561,7 +675,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Database configuration updated successfully") print("Database configuration updated successfully")
else: else:
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
log_content = "" log_content = ""
if os.path.isfile(out_file): if os.path.isfile(out_file):
@@ -40,7 +40,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/epf-build/scripts/epf-build.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -64,8 +64,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <п
```powershell ```powershell
# Сборка обработки (файловая база) # Сборка обработки (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf" powershell.exe -NoProfile -File ".roo/skills/epf-build/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf" powershell.exe -NoProfile -File ".roo/skills/epf-build/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf"
``` ```
@@ -1,4 +1,4 @@
# epf-build v1.13 — Build external data processor or report (EPF/ERF) from XML sources # epf-build v1.16 — Build external data processor or report (EPF/ERF) from XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -46,7 +46,7 @@
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf" .\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# epf-build v1.13 — Build external data processor or report (EPF/ERF) from XML sources # epf-build v1.16 — Build external data processor or report (EPF/ERF) from XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -120,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -352,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -420,7 +436,7 @@ def main():
} }
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints) extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef: if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef:
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)")
sys.exit(1) sys.exit(1)
# --- Auto-create stub database if no connection specified --- # --- Auto-create stub database if no connection specified ---
@@ -441,14 +457,14 @@ def main():
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra) stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
result = subprocess.run(stub_cmd, capture_output=False) result = subprocess.run(stub_cmd, capture_output=False)
if result.returncode != 0: if result.returncode != 0:
print("Error: failed to create stub database", file=sys.stderr) print("Error: failed to create stub database")
sys.exit(1) sys.exit(1)
args.InfoBasePath = auto_base_path args.InfoBasePath = auto_base_path
auto_created_base = auto_base_path auto_created_base = auto_base_path
# --- Validate source file --- # --- Validate source file ---
if not os.path.isfile(args.SourceFile): if not os.path.isfile(args.SourceFile):
print(f"Error: source file not found: {args.SourceFile}", file=sys.stderr) print(f"Error: source file not found: {args.SourceFile}")
sys.exit(1) sys.exit(1)
# --- Ensure output directory exists --- # --- Ensure output directory exists ---
@@ -482,9 +498,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"External data processor/report built successfully: {args.OutputFile}") print(f"External data processor/report built successfully: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
else: else:
print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr) print(f"Error building external data processor/report (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Build arguments --- # --- Build arguments ---
@@ -521,9 +537,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Build completed successfully: {args.OutputFile}") print(f"Build completed successfully: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
else: else:
print(f"Error building (code: {exit_code})", file=sys.stderr) print(f"Error building (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -39,7 +39,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/epf-dump/scripts/epf-dump.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -64,8 +64,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <па
```powershell ```powershell
# Разборка обработки (файловая база) # Разборка обработки (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МояОбработка.epf" -OutputDir "src" powershell.exe -NoProfile -File ".roo/skills/epf-dump/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МояОбработка.epf" -OutputDir "src"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МояОбработка.epf" -OutputDir "src" powershell.exe -NoProfile -File ".roo/skills/epf-dump/scripts/epf-dump.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МояОбработка.epf" -OutputDir "src"
``` ```
@@ -1,4 +1,4 @@
# epf-dump v1.12 — Dump external data processor or report (EPF/ERF) to XML sources # epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -49,7 +49,7 @@
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src" .\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# epf-dump v1.12 — Dump external data processor or report (EPF/ERF) to XML sources # epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -120,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -128,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -196,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -245,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -283,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -300,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -319,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where 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 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. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments 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 = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -352,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -428,20 +444,20 @@ def main():
# --- Validate database connection --- # --- Validate database connection ---
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef", file=sys.stderr) print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef")
print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.") print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
if args.Format == "Plain": if args.Format == "Plain":
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1) sys.exit(1)
# --- Validate input file --- # --- Validate input file ---
if not os.path.isfile(args.InputFile): if not os.path.isfile(args.InputFile):
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr) print(f"Error: input file not found: {args.InputFile}")
sys.exit(1) sys.exit(1)
# --- Ensure output directory exists --- # --- Ensure output directory exists ---
@@ -473,9 +489,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"External data processor/report dumped successfully to: {args.OutputDir}") print(f"External data processor/report dumped successfully to: {args.OutputDir}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr) print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
else: else:
print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr) print(f"Error dumping external data processor/report (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Build arguments --- # --- Build arguments ---
@@ -513,9 +529,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Dump completed successfully to: {args.OutputDir}") print(f"Dump completed successfully to: {args.OutputDir}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr) print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
else: else:
print(f"Error dumping (code: {exit_code})", file=sys.stderr) print(f"Error dumping (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -37,7 +37,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"] powershell.exe -NoProfile -File ".roo/skills/epf-init/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"]
``` ```
## Дальнейшие шаги ## Дальнейшие шаги
@@ -24,7 +24,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка" powershell.exe -NoProfile -File ".roo/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка"
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка/МояОбработка.xml" powershell.exe -NoProfile -File ".roo/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка/МояОбработка.xml"
``` ```
@@ -1,8 +1,9 @@
# epf-validate v1.5 — Validate 1C external data processor / report structure # epf-validate v1.6 — Validate 1C external data processor / report structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects # Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory, Position=0)]
[Alias('Path')] [Alias('Path')]
[string]$ObjectPath, [string]$ObjectPath,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# epf-validate v1.5 — Validate 1C external data processor / report structure # epf-validate v1.6 — Validate 1C external data processor / report structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects # Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
@@ -42,7 +42,7 @@ allowed-tools:
Используй общий скрипт из epf-build: Используй общий скрипт из epf-build:
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-build.ps1" <параметры> powershell.exe -NoProfile -File ".roo/skills/epf-build/scripts/epf-build.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -66,8 +66,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-bu
```powershell ```powershell
# Сборка отчёта (файловая база) # Сборка отчёта (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf" powershell.exe -NoProfile -File ".roo/skills/epf-build/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf" powershell.exe -NoProfile -File ".roo/skills/epf-build/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf"
``` ```

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