mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-20 18:19:43 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de347c4796 |
@@ -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,7 +24,7 @@ allowed-tools:
|
||||
| `NoValidate` | Пропустить авто-валидацию |
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-edit.ps1" -ConfigPath '<path>' -Operation modify-property -Value 'Version=1.0.0.1'
|
||||
python ".agents/skills/cf-edit/scripts/cf-edit.py" -ConfigPath '<path>' -Operation modify-property -Value 'Version=1.0.0.1'
|
||||
```
|
||||
|
||||
## Операции
|
||||
+39
-6
@@ -1,4 +1,4 @@
|
||||
# cf-edit v1.7 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.19 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
||||
@@ -44,6 +44,16 @@ function Get-RootUuid([string]$xmlPath) {
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
@@ -80,10 +90,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
@@ -150,10 +163,17 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
Assert-EditAllowed $resolvedPath 'editable'
|
||||
|
||||
# --- Load XML with PreserveWhitespace ---
|
||||
# NB: парсер XML по спецификации схлопывает CRLF в LF, а вставки ниже собираются с
|
||||
# явным CRLF — поэтому EOL приводится к целевому в точке записи (см. финализацию).
|
||||
$script:xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$script:xmlDoc.PreserveWhitespace = $true
|
||||
$script:xmlDoc.Load($resolvedPath)
|
||||
|
||||
# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
|
||||
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
|
||||
$script:formatVersion = $script:xmlDoc.DocumentElement.GetAttribute("version")
|
||||
if (-not $script:formatVersion) { $script:formatVersion = "2.17" }
|
||||
|
||||
$script:addCount = 0
|
||||
$script:removeCount = 0
|
||||
$script:modifyCount = 0
|
||||
@@ -196,7 +216,7 @@ Info "Configuration: $($script:objName)"
|
||||
$script:typeOrder = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -212,7 +232,7 @@ $script:typeOrder = @(
|
||||
$script:typeToDir = @{
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
|
||||
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
|
||||
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
|
||||
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
|
||||
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
|
||||
@@ -673,7 +693,9 @@ $bodyBlock$declarations
|
||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||
$caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($caiPath, $caiXml, $utf8Bom)
|
||||
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||
$caiXml = ($caiXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($caiPath, $caiXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||
$script:modifyCount++
|
||||
Info "Wrote panel layout: $caiPath"
|
||||
}
|
||||
@@ -700,6 +722,7 @@ $script:ruTypeMap = @{
|
||||
"регистррасчёта" = "CalculationRegister"
|
||||
"бизнеспроцесс" = "BusinessProcess"
|
||||
"задача" = "Task"
|
||||
"бот" = "Bot"
|
||||
"планобмена" = "ExchangePlan"
|
||||
"хранилищенастроек" = "SettingsStorage"
|
||||
}
|
||||
@@ -850,7 +873,7 @@ function Do-SetHomePage($valArg) {
|
||||
|
||||
$hpXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$($script:formatVersion)">
|
||||
<WorkingAreaTemplate>$tmpl</WorkingAreaTemplate>
|
||||
$leftXml
|
||||
$rightXml
|
||||
@@ -861,7 +884,9 @@ $rightXml
|
||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||
$hpPath = Join-Path $extDir "HomePageWorkArea.xml"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($hpPath, $hpXml, $utf8Bom)
|
||||
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||
$hpXml = ($hpXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($hpPath, $hpXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||
$script:modifyCount++
|
||||
Info "Wrote home page layout: $hpPath"
|
||||
}
|
||||
@@ -963,6 +988,14 @@ $memStream.Close()
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||
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)
|
||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||
+141
-21
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-edit v1.7 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.19 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -12,6 +12,65 @@ import uuid as _uuid
|
||||
from html import escape as html_escape
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
class CIDict(dict):
|
||||
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||
# сохраняется — от него зависит порядок эмиссии.
|
||||
def _actual(self, key):
|
||||
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||
return key
|
||||
ci = self.__dict__.get('_ci')
|
||||
if ci is None or len(ci) != len(self):
|
||||
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||
self.__dict__['_ci'] = ci
|
||||
return ci.get(key.lower(), key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return dict.__getitem__(self, self._actual(key))
|
||||
|
||||
def __contains__(self, key):
|
||||
return dict.__contains__(self, self._actual(key))
|
||||
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, self._actual(key), default)
|
||||
|
||||
def pop(self, key, *default):
|
||||
return dict.pop(self, self._actual(key), *default)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||
dict.__setitem__(self, self._actual(key), value)
|
||||
|
||||
def ci_json(obj):
|
||||
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||
if isinstance(obj, dict):
|
||||
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||
if isinstance(obj, list):
|
||||
return [ci_json(v) for v in obj]
|
||||
return obj
|
||||
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
@@ -33,6 +92,18 @@ def _sg_root_uuid(xml_path):
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
@@ -72,6 +143,9 @@ def _sg_get_edit_mode(cfg_dir):
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
@@ -79,6 +153,8 @@ def assert_edit_allowed(target_path, require):
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
@@ -182,7 +258,7 @@ XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
TYPE_ORDER = [
|
||||
"Language", "Subsystem", "StyleItem", "Style",
|
||||
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
||||
"FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
|
||||
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan",
|
||||
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
||||
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
||||
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
||||
@@ -198,7 +274,7 @@ TYPE_ORDER = [
|
||||
TYPE_TO_DIR = {
|
||||
"Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles",
|
||||
"CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates",
|
||||
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
|
||||
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
|
||||
"XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
|
||||
"EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions",
|
||||
"FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups",
|
||||
@@ -307,13 +383,50 @@ def parse_batch_value(val):
|
||||
return items
|
||||
|
||||
|
||||
def save_xml_bom(tree, path):
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||||
if not xml_bytes.endswith(b"\n"):
|
||||
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 (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", 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_bom(tree, path):
|
||||
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:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
@@ -326,7 +439,7 @@ def main():
|
||||
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("-Value", default=None)
|
||||
parser.add_argument("-NoValidate", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
if args.DefinitionFile and args.Operation:
|
||||
print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr)
|
||||
@@ -357,6 +470,10 @@ def main():
|
||||
tree = etree.parse(resolved_path, xml_parser)
|
||||
xml_root = tree.getroot()
|
||||
|
||||
# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
|
||||
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
|
||||
format_version = xml_root.get('version') or '2.17'
|
||||
|
||||
add_count = 0
|
||||
remove_count = 0
|
||||
modify_count = 0
|
||||
@@ -705,7 +822,7 @@ def main():
|
||||
layout = value
|
||||
if isinstance(layout, str):
|
||||
try:
|
||||
layout = json.loads(layout)
|
||||
layout = ci_json(json.loads(layout))
|
||||
except json.JSONDecodeError:
|
||||
print(f"set-panels value must be valid JSON object", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -771,6 +888,7 @@ def main():
|
||||
"регистррасчета": "CalculationRegister",
|
||||
"регистррасчёта": "CalculationRegister",
|
||||
"бизнеспроцесс": "BusinessProcess",
|
||||
"бот": "Bot",
|
||||
"задача": "Task", "планобмена": "ExchangePlan",
|
||||
"хранилищенастроек": "SettingsStorage",
|
||||
}
|
||||
@@ -859,7 +977,7 @@ def main():
|
||||
layout = value
|
||||
if isinstance(layout, str):
|
||||
try:
|
||||
layout = json.loads(layout)
|
||||
layout = ci_json(json.loads(layout))
|
||||
except json.JSONDecodeError:
|
||||
print("set-home-page value must be valid JSON object", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -905,7 +1023,7 @@ def main():
|
||||
'<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" '
|
||||
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">\r\n'
|
||||
f'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">\r\n'
|
||||
f'\t<WorkingAreaTemplate>{tmpl}</WorkingAreaTemplate>\r\n'
|
||||
f'{left_xml}\r\n'
|
||||
f'{right_xml}\r\n'
|
||||
@@ -927,7 +1045,7 @@ def main():
|
||||
if not os.path.isabs(def_file):
|
||||
def_file = os.path.join(os.getcwd(), def_file)
|
||||
with open(def_file, "r", encoding="utf-8-sig") as fh:
|
||||
ops = json.loads(fh.read())
|
||||
ops = ci_json(json.loads(fh.read()))
|
||||
if isinstance(ops, list):
|
||||
operations = ops
|
||||
else:
|
||||
@@ -937,23 +1055,25 @@ def main():
|
||||
|
||||
for op in operations:
|
||||
op_name = op.get("operation", args.Operation or "")
|
||||
# PS сравнивает имя операции через switch, а он регистронезависим.
|
||||
op_key = str(op_name).lower()
|
||||
op_value = op.get("value", args.Value or "")
|
||||
|
||||
if op_name == "modify-property":
|
||||
if op_key == "modify-property":
|
||||
do_modify_property(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "add-childObject":
|
||||
elif op_key == "add-childobject":
|
||||
do_add_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "remove-childObject":
|
||||
elif op_key == "remove-childobject":
|
||||
do_remove_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "add-defaultRole":
|
||||
elif op_key == "add-defaultrole":
|
||||
do_add_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "remove-defaultRole":
|
||||
elif op_key == "remove-defaultrole":
|
||||
do_remove_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "set-defaultRoles":
|
||||
elif op_key == "set-defaultroles":
|
||||
do_set_default_roles(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "set-panels":
|
||||
elif op_key == "set-panels":
|
||||
do_set_panels(op_value)
|
||||
elif op_name == "set-home-page":
|
||||
elif op_key == "set-home-page":
|
||||
do_set_home_page(op_value)
|
||||
else:
|
||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
||||
@@ -23,7 +23,7 @@ allowed-tools:
|
||||
| `OutFile` | Записать результат в файл (UTF-8 BOM) |
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-info.ps1" -ConfigPath "<путь>"
|
||||
python ".agents/skills/cf-info/scripts/cf-info.py" -ConfigPath "<путь>"
|
||||
```
|
||||
|
||||
## Три режима
|
||||
+5
-3
@@ -1,7 +1,8 @@
|
||||
# cf-info v1.3 — Compact summary of 1C configuration root
|
||||
# cf-info v1.6 — Compact summary of 1C configuration root
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath,
|
||||
[Parameter(Mandatory=$true, Position=0)][Alias('Path')][string]$ConfigPath,
|
||||
[ValidateSet("overview","brief","full")]
|
||||
[string]$Mode = "overview",
|
||||
[Alias('Name')]
|
||||
@@ -89,7 +90,7 @@ function Get-PropML([string]$propName) {
|
||||
$typeOrder = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -105,6 +106,7 @@ $typeRuNames = @{
|
||||
"Language"="Языки"; "Subsystem"="Подсистемы"; "StyleItem"="Элементы стиля"; "Style"="Стили"
|
||||
"CommonPicture"="Общие картинки"; "SessionParameter"="Параметры сеанса"; "Role"="Роли"
|
||||
"CommonTemplate"="Общие макеты"; "FilterCriterion"="Критерии отбора"; "CommonModule"="Общие модули"
|
||||
"Bot"="Боты"
|
||||
"CommonAttribute"="Общие реквизиты"; "ExchangePlan"="Планы обмена"; "XDTOPackage"="XDTO-пакеты"
|
||||
"WebService"="Веб-сервисы"; "HTTPService"="HTTP-сервисы"; "WSReference"="WS-ссылки"
|
||||
"EventSubscription"="Подписки на события"; "ScheduledJob"="Регламентные задания"
|
||||
+26
-3
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-info v1.3 — Compact summary of 1C configuration root
|
||||
# cf-info v1.6 — Compact summary of 1C configuration root
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -12,6 +12,28 @@ from lxml import etree
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# --- Argument parsing ---
|
||||
parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False)
|
||||
parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory")
|
||||
@@ -20,7 +42,7 @@ parser.add_argument("-Section", "-Name", choices=["home-page"], default=None, he
|
||||
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
||||
parser.add_argument("-Offset", type=int, default=0, help="Lines to skip")
|
||||
parser.add_argument("-OutFile", default="", help="Write output to file")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# --- Output helper (collect all, paginate at the end) ---
|
||||
lines_buf = []
|
||||
@@ -95,7 +117,7 @@ def get_prop_ml(prop_name):
|
||||
type_order = [
|
||||
"Language", "Subsystem", "StyleItem", "Style",
|
||||
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
||||
"FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
|
||||
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan",
|
||||
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
||||
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
||||
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
||||
@@ -111,6 +133,7 @@ type_ru_names = {
|
||||
"Language": "Языки", "Subsystem": "Подсистемы", "StyleItem": "Элементы стиля", "Style": "Стили",
|
||||
"CommonPicture": "Общие картинки", "SessionParameter": "Параметры сеанса", "Role": "Роли",
|
||||
"CommonTemplate": "Общие макеты", "FilterCriterion": "Критерии отбора", "CommonModule": "Общие модули",
|
||||
"Bot": "Боты",
|
||||
"CommonAttribute": "Общие реквизиты", "ExchangePlan": "Планы обмена", "XDTOPackage": "XDTO-пакеты",
|
||||
"WebService": "Веб-сервисы", "HTTPService": "HTTP-сервисы", "WSReference": "WS-ссылки",
|
||||
"EventSubscription": "Подписки на события", "ScheduledJob": "Регламентные задания",
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: cf-init
|
||||
description: Создать пустую конфигурацию 1С (scaffold XML-исходников). Используй когда нужно начать новую конфигурацию с нуля
|
||||
argument-hint: <Name> [-Synonym <name>] [-OutputDir src]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /cf-init — Создание пустой конфигурации 1С
|
||||
|
||||
Создаёт scaffold исходников пустой конфигурации 1С: `Configuration.xml`, `Languages/Русский.xml`.
|
||||
|
||||
## Параметры и команда
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `Name` | Имя конфигурации (обязат.) |
|
||||
| `Synonym` | Синоним (= Name если не указан) |
|
||||
| `OutputDir` | Каталог для создания (default: `src`) |
|
||||
| `Version` | Версия конфигурации |
|
||||
| `Vendor` | Поставщик |
|
||||
| `CompatibilityMode` | Режим совместимости (default: `Version8_3_24`) |
|
||||
| `FormatVersion` | Версия формата выгрузки (default: `2.17`) |
|
||||
|
||||
Оба параметра задаются от **целевой платформы** — той, на которой конфигурация будет работать, — но по
|
||||
разным правилам.
|
||||
|
||||
`FormatVersion` — **не выше** версии формата целевой платформы: 8.3.24 — `2.17`, 8.3.25 — `2.18`,
|
||||
8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно: платформа читает свой формат и любой
|
||||
более старый, поэтому дефолт `2.17` подходит для всей линейки 8.3.24 и выше.
|
||||
|
||||
`CompatibilityMode` влияет на доступную функциональность. Если в задаче не оговорено иное — **равен
|
||||
целевой платформе** (`Version8_3_27` для 8.3.27); это то же самое, что «Не использовать» в
|
||||
Конфигураторе, и именно такое значение платформа проставляет новой базе. Более низкий режим берут
|
||||
осознанно — когда конфигурация должна работать и на старых платформах; он отключает возможности,
|
||||
появившиеся позже. Выше целевой платформы — нельзя: такая конфигурация загрузится, но работать на ней
|
||||
не будет.
|
||||
|
||||
```powershell
|
||||
python ".agents/skills/cf-init/scripts/cf-init.py" -Name "МояКонфигурация"
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Базовая конфигурация
|
||||
... -Name МояКонфигурация -Synonym "Моя конфигурация" -OutputDir test-tmp/cf
|
||||
|
||||
# С версией и поставщиком
|
||||
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
|
||||
|
||||
# Под платформу 8.3.27 — версия формата и режим совместимости вместе
|
||||
... -Name TestCfg -FormatVersion 2.20 -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cf-init TestConfig -OutputDir test-tmp/cf
|
||||
/cf-info test-tmp/cf — проверить созданное
|
||||
/cf-validate test-tmp/cf — валидировать
|
||||
```
|
||||
@@ -0,0 +1,339 @@
|
||||
# cf-init v1.14 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
[string]$Synonym = $Name,
|
||||
[string]$OutputDir = "src",
|
||||
[string]$Version,
|
||||
[string]$Vendor,
|
||||
[string]$CompatibilityMode = "Version8_3_24",
|
||||
# Версия формата выгрузки (MDClasses). Её задаёт ПЛАТФОРМА, которой выгружают, и от режима
|
||||
# совместимости она не зависит. Дефолт 2.17 — нижняя граница проверенного диапазона.
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||
# на нечисловое значение: это опечатка, а не версия.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
$formatRank = Get-FormatRank $FormatVersion
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||
if ($formatRank -eq 0) {
|
||||
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||
exit 1
|
||||
}
|
||||
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||
}
|
||||
|
||||
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
|
||||
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
|
||||
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
|
||||
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
|
||||
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
|
||||
# расхождение портов началось бы прямо здесь.
|
||||
if ($CompatibilityMode -and $CompatibilityMode.ToLowerInvariant() -eq 'dontuse') {
|
||||
[Console]::Error.WriteLine("WARNING: CompatibilityMode 'DontUse' is not `"no restrictions`" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).")
|
||||
}
|
||||
|
||||
# --- Resolve output dir ---
|
||||
if (-not [System.IO.Path]::IsPathRooted($OutputDir)) {
|
||||
$OutputDir = Join-Path (Get-Location).Path $OutputDir
|
||||
}
|
||||
|
||||
# --- Check existing ---
|
||||
$cfgFile = Join-Path $OutputDir "Configuration.xml"
|
||||
if (Test-Path $cfgFile) {
|
||||
Write-Error "Configuration.xml already exists: $cfgFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Generate UUIDs ---
|
||||
$uuidCfg = [guid]::NewGuid().ToString()
|
||||
$uuidLang = [guid]::NewGuid().ToString()
|
||||
# 7 ContainedObject ObjectIds
|
||||
$co1 = [guid]::NewGuid().ToString()
|
||||
$co2 = [guid]::NewGuid().ToString()
|
||||
$co3 = [guid]::NewGuid().ToString()
|
||||
$co4 = [guid]::NewGuid().ToString()
|
||||
$co5 = [guid]::NewGuid().ToString()
|
||||
$co6 = [guid]::NewGuid().ToString()
|
||||
$co7 = [guid]::NewGuid().ToString()
|
||||
|
||||
# --- Mobile functionalities ---
|
||||
# Версия формата как число — по ней ниже включаются вставки 2.21.
|
||||
$is221 = ($formatRank -ge 221)
|
||||
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
|
||||
$is218 = ($formatRank -ge 218)
|
||||
|
||||
$mobileFuncs = @(
|
||||
@("Biometrics","true"), @("Location","false"), @("BackgroundLocation","false"),
|
||||
@("BluetoothPrinters","false"), @("WiFiPrinters","false"), @("Contacts","false"),
|
||||
@("Calendars","false"), @("PushNotifications","false"), @("LocalNotifications","false"),
|
||||
@("InAppPurchases","false"), @("PersonalComputerFileExchange","false"), @("Ads","false"),
|
||||
@("NumberDialing","false"), @("CallProcessing","false"), @("CallLog","false"),
|
||||
@("AutoSendSMS","false"), @("ReceiveSMS","false"), @("SMSLog","false"),
|
||||
@("Camera","false"), @("Microphone","false"), @("MusicLibrary","false"),
|
||||
@("PictureAndVideoLibraries","false"), @("AudioPlaybackAndVibration","false"),
|
||||
@("BackgroundAudioPlaybackAndVibration","false"), @("InstallPackages","false"),
|
||||
@("OSBackup","true"), @("ApplicationUsageStatistics","false"),
|
||||
@("BarcodeScanning","false"), @("BackgroundAudioRecording","false"),
|
||||
@("AllFilesAccess","false"), @("Videoconferences","false"), @("NFC","false"),
|
||||
@("DocumentScanning","false"), @("SpeechToText","false"), @("Geofences","false"),
|
||||
@("IncomingShareRequests","false"), @("AllIncomingShareRequestsTypesProcessing","false")
|
||||
)
|
||||
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
|
||||
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
|
||||
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
|
||||
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
|
||||
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
|
||||
if ($is218) { $mobileFuncs += ,@("TextToSpeech","false") }
|
||||
|
||||
$mobileXml = ""
|
||||
foreach ($mf in $mobileFuncs) {
|
||||
$mobileXml += "`r`n`t`t`t`t<app:functionality>`r`n`t`t`t`t`t<app:functionality>$($mf[0])</app:functionality>`r`n`t`t`t`t`t<app:use>$($mf[1])</app:use>`r`n`t`t`t`t</app:functionality>"
|
||||
}
|
||||
|
||||
# --- Synonym XML ---
|
||||
$synonymXml = ""
|
||||
if ($Synonym) {
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
}
|
||||
|
||||
# --- Optional properties ---
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
|
||||
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
|
||||
|
||||
# --- Свойства и пространство имён формата 2.21 (платформа 8.5) ---
|
||||
# Значения и ПОЗИЦИИ сняты с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники,
|
||||
# выгруженные с 8.3.27 и с 8.5.1, различаются ровно этим. Порядок важен — вставки идут
|
||||
# на своё место, а не в конец.
|
||||
$nl = "`r`n"
|
||||
$f221AuxForms = ""; $f221WindowVariant = ""; $f221OpenVariant = ""; $f221Captions = ""; $f221Migration = ""
|
||||
$palNs = ""
|
||||
if ($is221) {
|
||||
$palNs = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||
# Скобки вокруг -join обязательны: без них `$nl + (массив) -join $nl` разбирается как
|
||||
# `($nl + массив) -join $nl`, массив склеивается пробелами и все теги уезжают в одну строку.
|
||||
$f221AuxForms = $nl + ((@(
|
||||
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
|
||||
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
|
||||
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
|
||||
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"
|
||||
) | ForEach-Object { "`t`t`t$_" }) -join $nl)
|
||||
$f221WindowVariant = $nl + "`t`t`t<MainClientApplicationWindowInterfaceVariant>NavigationLeft</MainClientApplicationWindowInterfaceVariant>" +
|
||||
$nl + "`t`t`t<ClientApplicationTheme>Auto</ClientApplicationTheme>"
|
||||
$f221OpenVariant = $nl + "`t`t`t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs</ClientApplicationWindowsOpenVariant>"
|
||||
$f221Captions = $nl + "`t`t`t<Caption/>" + $nl + "`t`t`t<ShortCaption/>"
|
||||
$f221Migration = $nl + "`t`t`t<Version85InterfaceMigrationMode>DontUse</Version85InterfaceMigrationMode>"
|
||||
}
|
||||
|
||||
# --- Configuration.xml ---
|
||||
$cfgXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
|
||||
<Configuration uuid="$uuidCfg">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>9cd510cd-abfc-11d4-9434-004095e12fc7</xr:ClassId>
|
||||
<xr:ObjectId>$co1</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>9fcd25a0-4822-11d4-9414-008048da11f9</xr:ClassId>
|
||||
<xr:ObjectId>$co2</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>e3687481-0a87-462c-a166-9f34594f9bba</xr:ClassId>
|
||||
<xr:ObjectId>$co3</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>9de14907-ec23-4a07-96f0-85521cb6b53b</xr:ClassId>
|
||||
<xr:ObjectId>$co4</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>51f2d5d8-ea4d-4064-8892-82951750031e</xr:ClassId>
|
||||
<xr:ObjectId>$co5</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>e68182ea-4237-4383-967f-90c1e3370bc7</xr:ClassId>
|
||||
<xr:ObjectId>$co6</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>fb282519-d103-4dd3-bc12-cb271d631dfc</xr:ClassId>
|
||||
<xr:ObjectId>$co7</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$(Esc-XmlText ($Name))</Name>
|
||||
<Synonym>$synonymXml</Synonym>
|
||||
<Comment/>
|
||||
<NamePrefix/>
|
||||
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
|
||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
</UsePurposes>
|
||||
<ScriptVariant>Russian</ScriptVariant>
|
||||
<DefaultRoles/>
|
||||
$vendorEl
|
||||
$versionEl
|
||||
<UpdateCatalogAddress/>
|
||||
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||
<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
|
||||
<AdditionalFullTextSearchDictionaries/>
|
||||
<CommonSettingsStorage/>
|
||||
<ReportsUserSettingsStorage/>
|
||||
<ReportsVariantsStorage/>
|
||||
<FormDataSettingsStorage/>
|
||||
<DynamicListsUserSettingsStorage/>
|
||||
<URLExternalDataStorage/>
|
||||
<Content/>
|
||||
<DefaultReportForm/>
|
||||
<DefaultReportVariantForm/>
|
||||
<DefaultReportSettingsForm/>
|
||||
<DefaultReportAppearanceTemplate/>
|
||||
<DefaultDynamicListSettingsForm/>
|
||||
<DefaultSearchForm/>
|
||||
<DefaultDataHistoryChangeHistoryForm/>
|
||||
<DefaultDataHistoryVersionDataForm/>
|
||||
<DefaultDataHistoryVersionDifferencesForm/>
|
||||
<DefaultCollaborationSystemUsersChoiceForm/>$f221AuxForms
|
||||
<RequiredMobileApplicationPermissions/>
|
||||
<UsedMobileApplicationFunctionalities>$mobileXml
|
||||
</UsedMobileApplicationFunctionalities>
|
||||
<StandaloneConfigurationRestrictionRoles/>
|
||||
<MobileApplicationURLs/>
|
||||
<AllowedIncomingShareRequestTypes/>$f221WindowVariant
|
||||
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>$f221OpenVariant
|
||||
<DefaultInterface/>$f221Captions
|
||||
<DefaultStyle/>
|
||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
<BriefInformation/>
|
||||
<DetailedInformation/>
|
||||
<Copyright/>
|
||||
<VendorInformationAddress/>
|
||||
<ConfigurationInformationAddress/>
|
||||
<DataLockControlMode>Managed</DataLockControlMode>
|
||||
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||
<ModalityUseMode>DontUse</ModalityUseMode>
|
||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>$f221Migration
|
||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
<CompatibilityMode>$CompatibilityMode</CompatibilityMode>
|
||||
<DefaultConstantsForm/>
|
||||
</Properties>
|
||||
<ChildObjects>
|
||||
<Language>Русский</Language>
|
||||
</ChildObjects>
|
||||
</Configuration>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
# --- Languages/Русский.xml ---
|
||||
$langXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
|
||||
<Language uuid="$uuidLang">
|
||||
<Properties>
|
||||
<Name>Русский</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Русский</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<LanguageCode>ru</LanguageCode>
|
||||
</Properties>
|
||||
</Language>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
# --- Ext/ClientApplicationInterface.xml (default ERP-style panel layout) ---
|
||||
# Open panel on top, Sections panel on left; Functions/Favorites/History declared
|
||||
# via panelDef but not placed by default. Without this file the web client renders
|
||||
# section icons without labels (icon-only mode).
|
||||
$openPanelInst = [guid]::NewGuid().ToString()
|
||||
$sectionsPanelInst = [guid]::NewGuid().ToString()
|
||||
$caiXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
|
||||
<top>
|
||||
<panel id="$openPanelInst">
|
||||
<uuid>cbab57f2-a0f3-4f0a-89ea-4cb19570ab75</uuid>
|
||||
</panel>
|
||||
</top>
|
||||
<left>
|
||||
<panel id="$sectionsPanelInst">
|
||||
<uuid>b553047f-c9aa-4157-978d-448ecad24248</uuid>
|
||||
</panel>
|
||||
</left>
|
||||
<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>
|
||||
<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>
|
||||
<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>
|
||||
<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>
|
||||
<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>
|
||||
</ClientApplicationInterface>
|
||||
"@
|
||||
|
||||
# --- Create directories ---
|
||||
if (-not (Test-Path $OutputDir)) {
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
}
|
||||
$langDir = Join-Path $OutputDir "Languages"
|
||||
if (-not (Test-Path $langDir)) {
|
||||
New-Item -ItemType Directory -Path $langDir -Force | Out-Null
|
||||
}
|
||||
$extDir = Join-Path $OutputDir "Ext"
|
||||
if (-not (Test-Path $extDir)) {
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Write files with UTF-8 BOM ---
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $cfgFile $cfgXml $enc
|
||||
$langFile = Join-Path $langDir "Русский.xml"
|
||||
Write-XmlFile $langFile $langXml $enc
|
||||
$caiFile = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||
Write-XmlFile $caiFile $caiXml $enc
|
||||
|
||||
# --- Output ---
|
||||
Write-Host "[OK] Создана конфигурация: $Name"
|
||||
Write-Host " Каталог: $OutputDir"
|
||||
Write-Host " Configuration.xml: $cfgFile"
|
||||
Write-Host " Languages: $langFile"
|
||||
Write-Host " Ext/CAI: $caiFile"
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-init v1.14 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C configuration."""
|
||||
import sys, os, argparse, re, uuid
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description='Create empty 1C configuration scaffold', allow_abbrev=False)
|
||||
parser.add_argument('-Name', dest='Name', required=True)
|
||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||
parser.add_argument('-OutputDir', dest='OutputDir', default='src')
|
||||
parser.add_argument('-Version', dest='Version', default='')
|
||||
parser.add_argument('-Vendor', dest='Vendor', default='')
|
||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||
# Версия формата выгрузки (MDClasses) — её задаёт ПЛАТФОРМА, а не режим совместимости.
|
||||
# Дефолт 2.17 — нижняя граница проверенного диапазона.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
|
||||
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
|
||||
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
|
||||
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
|
||||
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
|
||||
# расхождение портов началось бы прямо здесь.
|
||||
if (args.CompatibilityMode or "").lower() == "dontuse":
|
||||
print("WARNING: CompatibilityMode 'DontUse' is not \"no restrictions\" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
output_dir = args.OutputDir
|
||||
version = args.Version
|
||||
vendor = args.Vendor
|
||||
compat = args.CompatibilityMode
|
||||
|
||||
# --- Resolve output dir ---
|
||||
if not os.path.isabs(output_dir):
|
||||
output_dir = os.path.join(os.getcwd(), output_dir)
|
||||
|
||||
# --- Check existing ---
|
||||
cfg_file = os.path.join(output_dir, "Configuration.xml")
|
||||
if os.path.exists(cfg_file):
|
||||
print(f"Configuration.xml already exists: {cfg_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Generate UUIDs ---
|
||||
uuid_cfg = new_uuid()
|
||||
uuid_lang = new_uuid()
|
||||
co = [new_uuid() for _ in range(7)]
|
||||
|
||||
# --- Mobile functionalities ---
|
||||
# Версия формата как число — по ней ниже включаются вставки 2.21.
|
||||
is_221 = format_rank_value >= 221
|
||||
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
|
||||
is_218 = format_rank_value >= 218
|
||||
|
||||
mobile_funcs = [
|
||||
("Biometrics","true"), ("Location","false"), ("BackgroundLocation","false"),
|
||||
("BluetoothPrinters","false"), ("WiFiPrinters","false"), ("Contacts","false"),
|
||||
("Calendars","false"), ("PushNotifications","false"), ("LocalNotifications","false"),
|
||||
("InAppPurchases","false"), ("PersonalComputerFileExchange","false"), ("Ads","false"),
|
||||
("NumberDialing","false"), ("CallProcessing","false"), ("CallLog","false"),
|
||||
("AutoSendSMS","false"), ("ReceiveSMS","false"), ("SMSLog","false"),
|
||||
("Camera","false"), ("Microphone","false"), ("MusicLibrary","false"),
|
||||
("PictureAndVideoLibraries","false"), ("AudioPlaybackAndVibration","false"),
|
||||
("BackgroundAudioPlaybackAndVibration","false"), ("InstallPackages","false"),
|
||||
("OSBackup","true"), ("ApplicationUsageStatistics","false"),
|
||||
("BarcodeScanning","false"), ("BackgroundAudioRecording","false"),
|
||||
("AllFilesAccess","false"), ("Videoconferences","false"), ("NFC","false"),
|
||||
("DocumentScanning","false"), ("SpeechToText","false"), ("Geofences","false"),
|
||||
("IncomingShareRequests","false"), ("AllIncomingShareRequestsTypesProcessing","false"),
|
||||
]
|
||||
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
|
||||
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
|
||||
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
|
||||
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
|
||||
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
|
||||
if is_218:
|
||||
mobile_funcs.append(("TextToSpeech", "false"))
|
||||
|
||||
mobile_xml = ""
|
||||
for func_name, func_use in mobile_funcs:
|
||||
mobile_xml += f"\r\n\t\t\t\t<app:functionality>\r\n\t\t\t\t\t<app:functionality>{func_name}</app:functionality>\r\n\t\t\t\t\t<app:use>{func_use}</app:use>\r\n\t\t\t\t</app:functionality>"
|
||||
|
||||
# --- Synonym XML ---
|
||||
synonym_xml = ""
|
||||
if synonym:
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
|
||||
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
|
||||
|
||||
class_ids = [
|
||||
"9cd510cd-abfc-11d4-9434-004095e12fc7",
|
||||
"9fcd25a0-4822-11d4-9414-008048da11f9",
|
||||
"e3687481-0a87-462c-a166-9f34594f9bba",
|
||||
"9de14907-ec23-4a07-96f0-85521cb6b53b",
|
||||
"51f2d5d8-ea4d-4064-8892-82951750031e",
|
||||
"e68182ea-4237-4383-967f-90c1e3370bc7",
|
||||
"fb282519-d103-4dd3-bc12-cb271d631dfc",
|
||||
]
|
||||
|
||||
# Свойства и пространство имён формата 2.21 (платформа 8.5). Значения и ПОЗИЦИИ сняты
|
||||
# с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники, выгруженные с 8.3.27 и
|
||||
# с 8.5.1, различаются ровно этим. Порядок важен — вставки идут на своё место.
|
||||
pal_ns = ""
|
||||
f221_aux_forms = f221_window_variant = f221_open_variant = f221_captions = f221_migration = ""
|
||||
if is_221:
|
||||
pal_ns = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||
f221_aux_forms = "\r\n" + "\r\n".join(
|
||||
f"\t\t\t{t}" for t in (
|
||||
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
|
||||
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
|
||||
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
|
||||
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"))
|
||||
f221_window_variant = ("\r\n\t\t\t<MainClientApplicationWindowInterfaceVariant>NavigationLeft"
|
||||
"</MainClientApplicationWindowInterfaceVariant>"
|
||||
"\r\n\t\t\t<ClientApplicationTheme>Auto</ClientApplicationTheme>")
|
||||
f221_open_variant = ("\r\n\t\t\t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs"
|
||||
"</ClientApplicationWindowsOpenVariant>")
|
||||
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
|
||||
f221_migration = ("\r\n\t\t\t<Version85InterfaceMigrationMode>DontUse"
|
||||
"</Version85InterfaceMigrationMode>")
|
||||
|
||||
contained_objects = ""
|
||||
for i in range(7):
|
||||
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
||||
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
|
||||
\t\t\t\t<xr:ObjectId>{co[i]}</xr:ObjectId>
|
||||
\t\t\t</xr:ContainedObject>\n"""
|
||||
|
||||
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>
|
||||
{contained_objects}\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<NamePrefix/>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
\t\t\t<UsePurposes>
|
||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
\t\t\t</UsePurposes>
|
||||
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
||||
\t\t\t<DefaultRoles/>
|
||||
\t\t\t{vendor_el}
|
||||
\t\t\t{version_el}
|
||||
\t\t\t<UpdateCatalogAddress/>
|
||||
\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
\t\t\t<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||
\t\t\t<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
|
||||
\t\t\t<AdditionalFullTextSearchDictionaries/>
|
||||
\t\t\t<CommonSettingsStorage/>
|
||||
\t\t\t<ReportsUserSettingsStorage/>
|
||||
\t\t\t<ReportsVariantsStorage/>
|
||||
\t\t\t<FormDataSettingsStorage/>
|
||||
\t\t\t<DynamicListsUserSettingsStorage/>
|
||||
\t\t\t<URLExternalDataStorage/>
|
||||
\t\t\t<Content/>
|
||||
\t\t\t<DefaultReportForm/>
|
||||
\t\t\t<DefaultReportVariantForm/>
|
||||
\t\t\t<DefaultReportSettingsForm/>
|
||||
\t\t\t<DefaultReportAppearanceTemplate/>
|
||||
\t\t\t<DefaultDynamicListSettingsForm/>
|
||||
\t\t\t<DefaultSearchForm/>
|
||||
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
|
||||
\t\t\t<DefaultDataHistoryVersionDataForm/>
|
||||
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
|
||||
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>{f221_aux_forms}
|
||||
\t\t\t<RequiredMobileApplicationPermissions/>
|
||||
\t\t\t<UsedMobileApplicationFunctionalities>{mobile_xml}
|
||||
\t\t\t</UsedMobileApplicationFunctionalities>
|
||||
\t\t\t<StandaloneConfigurationRestrictionRoles/>
|
||||
\t\t\t<MobileApplicationURLs/>
|
||||
\t\t\t<AllowedIncomingShareRequestTypes/>{f221_window_variant}
|
||||
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>{f221_open_variant}
|
||||
\t\t\t<DefaultInterface/>{f221_captions}
|
||||
\t\t\t<DefaultStyle/>
|
||||
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
\t\t\t<BriefInformation/>
|
||||
\t\t\t<DetailedInformation/>
|
||||
\t\t\t<Copyright/>
|
||||
\t\t\t<VendorInformationAddress/>
|
||||
\t\t\t<ConfigurationInformationAddress/>
|
||||
\t\t\t<DataLockControlMode>Managed</DataLockControlMode>
|
||||
\t\t\t<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||
\t\t\t<ModalityUseMode>DontUse</ModalityUseMode>
|
||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>{f221_migration}
|
||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
\t\t\t<CompatibilityMode>{compat}</CompatibilityMode>
|
||||
\t\t\t<DefaultConstantsForm/>
|
||||
\t\t</Properties>
|
||||
\t\t<ChildObjects>
|
||||
\t\t\t<Language>Русский</Language>
|
||||
\t\t</ChildObjects>
|
||||
\t</Configuration>
|
||||
</MetaDataObject>'''
|
||||
|
||||
# --- Languages/Русский.xml ---
|
||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>Русский</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>Русский</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<LanguageCode>ru</LanguageCode>
|
||||
\t\t</Properties>
|
||||
\t</Language>
|
||||
</MetaDataObject>'''
|
||||
|
||||
# --- Ext/ClientApplicationInterface.xml (default ERP-style panel layout) ---
|
||||
# Open panel on top, Sections panel on left; Functions/Favorites/History declared
|
||||
# via panelDef but not placed by default. Without this file the web client renders
|
||||
# section icons without labels (icon-only mode).
|
||||
open_panel_inst = new_uuid()
|
||||
sections_panel_inst = new_uuid()
|
||||
cai_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
|
||||
\t<top>
|
||||
\t\t<panel id="{open_panel_inst}">
|
||||
\t\t\t<uuid>cbab57f2-a0f3-4f0a-89ea-4cb19570ab75</uuid>
|
||||
\t\t</panel>
|
||||
\t</top>
|
||||
\t<left>
|
||||
\t\t<panel id="{sections_panel_inst}">
|
||||
\t\t\t<uuid>b553047f-c9aa-4157-978d-448ecad24248</uuid>
|
||||
\t\t</panel>
|
||||
\t</left>
|
||||
\t<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>
|
||||
\t<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>
|
||||
\t<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>
|
||||
\t<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>
|
||||
\t<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>
|
||||
</ClientApplicationInterface>'''
|
||||
|
||||
# --- Create directories ---
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
lang_dir = os.path.join(output_dir, "Languages")
|
||||
os.makedirs(lang_dir, exist_ok=True)
|
||||
ext_dir = os.path.join(output_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
# --- Write files ---
|
||||
write_xml_file(cfg_file, cfg_xml)
|
||||
lang_file = os.path.join(lang_dir, "Русский.xml")
|
||||
write_xml_file(lang_file, lang_xml)
|
||||
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
|
||||
write_xml_file(cai_file, cai_xml)
|
||||
|
||||
print(f"[OK] Создана конфигурация: {name}")
|
||||
print(f" Каталог: {output_dir}")
|
||||
print(f" Configuration.xml: {cfg_file}")
|
||||
print(f" Languages: {lang_file}")
|
||||
print(f" Ext/CAI: {cai_file}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -24,6 +24,6 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty/Configuration.xml"
|
||||
python ".agents/skills/cf-validate/scripts/cf-validate.py" -ConfigPath "upload/cfempty"
|
||||
python ".agents/skills/cf-validate/scripts/cf-validate.py" -ConfigPath "upload/cfempty/Configuration.xml"
|
||||
```
|
||||
+26
-6
@@ -1,7 +1,8 @@
|
||||
# cf-validate v1.3 — Validate 1C configuration root structure
|
||||
# cf-validate v1.8 — Validate 1C configuration root structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('Path')]
|
||||
[string]$ConfigPath,
|
||||
|
||||
@@ -89,6 +90,19 @@ $finalize = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Reference tables ---
|
||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
||||
@@ -104,11 +118,11 @@ $validClassIds = @(
|
||||
"fb282519-d103-4dd3-bc12-cb271d631dfc" # home page / client app interface
|
||||
)
|
||||
|
||||
# 44 types in canonical order
|
||||
# 45 types in canonical order
|
||||
$childObjectTypes = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -125,6 +139,7 @@ $childTypeDirMap = @{
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
||||
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
||||
"Bot"="Bots"
|
||||
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
||||
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
|
||||
@@ -202,10 +217,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
}
|
||||
|
||||
$version = $root.GetAttribute("version")
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
}
|
||||
|
||||
# Must have Configuration child
|
||||
+50
-6
@@ -1,10 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-validate v1.3 — Validate 1C configuration XML structure
|
||||
# cf-validate v1.8 — Validate 1C configuration XML structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
||||
import sys, os, argparse, re
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NS = {
|
||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||
@@ -33,11 +55,11 @@ VALID_CLASS_IDS = [
|
||||
'fb282519-d103-4dd3-bc12-cb271d631dfc', # home page / client app interface
|
||||
]
|
||||
|
||||
# 44 types in canonical order
|
||||
# 45 types in canonical order
|
||||
CHILD_OBJECT_TYPES = [
|
||||
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
||||
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan',
|
||||
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
||||
@@ -54,6 +76,7 @@ CHILD_TYPE_DIR_MAP = {
|
||||
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
||||
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
||||
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
||||
'Bot': 'Bots',
|
||||
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
||||
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
||||
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
||||
@@ -109,6 +132,20 @@ VALID_ENUM_VALUES = {
|
||||
|
||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
class Reporter:
|
||||
def __init__(self, max_errors, detailed=False):
|
||||
@@ -169,7 +206,7 @@ def main():
|
||||
parser.add_argument('-Detailed', action='store_true')
|
||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
config_path = args.ConfigPath
|
||||
max_errors = args.MaxErrors
|
||||
@@ -229,10 +266,17 @@ def main():
|
||||
check1_ok = False
|
||||
|
||||
version = root.get('version', '')
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
r.warn('1. Missing version attribute on MetaDataObject')
|
||||
elif version not in ('2.17', '2.20', '2.21'):
|
||||
r.warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
||||
elif version_rank == 0:
|
||||
r.error(f"1. Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
r.warn(f"1. Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
r.warn(f"1. Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
|
||||
# Must have Configuration child
|
||||
cfg_node = None
|
||||
@@ -31,6 +31,7 @@ allowed-tools:
|
||||
| `ExtensionPath` | Путь к каталогу расширения (обязат.) |
|
||||
| `ConfigPath` | Путь к конфигурации-источнику (обязат.) |
|
||||
| `Object` | Что заимствовать (обязат.), batch через `;;` |
|
||||
| `Module` | Создать пустые модули объекта: `ObjectModule`, `ManagerModule`, `RecordSetModule`, `ValueManagerModule` (через запятую) или `None`. У типов с единственным модулем (`CommonModule`, `HTTPService`, `WebService`) он создаётся и без параметра |
|
||||
| `BorrowMainAttribute` | Заимствовать основной реквизит формы. Без параметра — не заимствует. `Form` — реквизиты, используемые на форме. `All` — все реквизиты объекта. Требует форму в -Object |
|
||||
|
||||
## Формат -Object
|
||||
@@ -41,7 +42,6 @@ allowed-tools:
|
||||
- `Enum.ВидыОплат` — перечисление
|
||||
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
|
||||
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
|
||||
Поддерживаются все 44 типа объектов конфигурации.
|
||||
|
||||
### Заимствование форм
|
||||
|
||||
@@ -66,36 +66,44 @@ allowed-tools:
|
||||
2. `/meta-edit` — добавить новый реквизит в объект расширения
|
||||
3. `/form-edit` — вывести реквизит на заимствованную форму
|
||||
|
||||
**Защита существующих данных**: если зависимый объект уже заимствован с содержимым (реквизитами, формами) — скрипт не перезаписывает его, а добавляет только недостающее.
|
||||
**Защита существующих данных**: уже заимствованный объект не перезаписывается — добавляется только недостающее. Повторный вызов безопасен: собственные реквизиты расширения, заимствованные подобъекты и код в модулях сохраняются.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты"
|
||||
python ".agents/skills/cfe-borrow/scripts/cfe-borrow.py" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Заимствовать один объект
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -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 -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
|
||||
|
||||
# Несколько объектов за раз
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
|
||||
|
||||
# Заимствовать форму с основным реквизитом (реквизиты по DataPath формы)
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
|
||||
|
||||
# Заимствовать форму с ВСЕМИ реквизитами объекта
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <ExtensionPath>
|
||||
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||
```
|
||||
|
||||
Конфигурацию-источник передавай и валидатору: заимствованные формы он проверяет по ней.
|
||||
|
||||
+716
-104
File diff suppressed because it is too large
Load Diff
+775
-119
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A
|
||||
python ".agents/skills/cfe-diff/scripts/cfe-diff.py" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||
```
|
||||
|
||||
## Mode A — обзор расширения
|
||||
@@ -50,8 +50,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -Exte
|
||||
|
||||
```powershell
|
||||
# Обзор — что изменено в расширении
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||
|
||||
# Проверка переноса — все ли #Вставка перенесены
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode B
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode B
|
||||
```
|
||||
+6
-3
@@ -1,7 +1,8 @@
|
||||
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE)
|
||||
# cfe-diff v1.3 — Analyze and compare 1C configuration extension (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[string]$ExtensionPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
@@ -49,7 +50,9 @@ $childTypeDirMap = @{
|
||||
"SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria"
|
||||
"CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators"
|
||||
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
||||
"CommonAttribute"="CommonAttributes"
|
||||
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "XDTOPackage"="XDTOPackages"
|
||||
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||
"Bot"="Bots"
|
||||
}
|
||||
|
||||
# --- Parse extension Configuration.xml ---
|
||||
+30
-2
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE)
|
||||
# cfe-diff v1.3 — Analyze and compare 1C configuration extension (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -8,6 +8,28 @@ import re
|
||||
import sys
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# --- Namespace maps ---
|
||||
|
||||
MD_NSMAP = {
|
||||
@@ -60,6 +82,12 @@ CHILD_TYPE_DIR_MAP = {
|
||||
"Sequence": "Sequences",
|
||||
"IntegrationService": "IntegrationServices",
|
||||
"CommonAttribute": "CommonAttributes",
|
||||
"Style": "Styles",
|
||||
"XDTOPackage": "XDTOPackages",
|
||||
"WebService": "WebServices",
|
||||
"HTTPService": "HTTPServices",
|
||||
"WSReference": "WSReferences",
|
||||
"Bot": "Bots",
|
||||
}
|
||||
|
||||
|
||||
@@ -468,7 +496,7 @@ def main():
|
||||
parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
|
||||
parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
|
||||
parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
extension_path = args.ExtensionPath
|
||||
config_path = args.ConfigPath
|
||||
@@ -33,39 +33,39 @@ allowed-tools:
|
||||
| `Name` | Имя расширения (обязат.) | — |
|
||||
| `Synonym` | Синоним | = Name |
|
||||
| `NamePrefix` | Префикс собственных объектов | = Name + "_" |
|
||||
| `OutputDir` | Каталог для создания | `src` |
|
||||
| `OutputDir` | Каталог для создания; клади расширение в свой подкаталог, названный по имени: `src\cfe\<Name>` | `src` |
|
||||
| `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` |
|
||||
| `Version` | Версия расширения | — |
|
||||
| `Vendor` | Поставщик | — |
|
||||
| `CompatibilityMode` | Режим совместимости | `Version8_3_24` |
|
||||
| `CompatibilityMode` | Режим совместимости; при заданном `ConfigPath` определяется по базовой конфигурации и этот параметр не нужен | `Version8_3_24` |
|
||||
| `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — |
|
||||
| `NoRole` | Без основной роли | false |
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение"
|
||||
python ".agents/skills/cfe-init/scripts/cfe-init.py" -Name "МоёРасширение" -OutputDir "src\cfe\МоёРасширение" -ConfigPath "src\cf"
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Расширение для ERP с авто-определением совместимости из базовой конфигурации
|
||||
... -Name Расш1 -ConfigPath C:\WS\tasks\cfsrc\erp_8.3.24 -OutputDir src
|
||||
... -Name Расш1 -ConfigPath src\cf -OutputDir src\cfe\Расш1
|
||||
|
||||
# Расширение-исправление с явным режимом совместимости
|
||||
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src
|
||||
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src\cfe\Расш1
|
||||
|
||||
# Расширение-доработка с версией
|
||||
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src
|
||||
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src\cfe\МоёРасширение
|
||||
|
||||
# Без роли, с явным префиксом
|
||||
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src
|
||||
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src\cfe\ИсправлениеБага
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <OutputDir>
|
||||
/cfe-validate <OutputDir> -ConfigPath <ConfigPath>
|
||||
```
|
||||
|
||||
+58
-18
@@ -1,4 +1,4 @@
|
||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
||||
# cfe-init v1.10 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -16,6 +16,12 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Default NamePrefix ---
|
||||
@@ -121,20 +127,23 @@ $co7 = [guid]::NewGuid().ToString()
|
||||
# --- Synonym XML ---
|
||||
$synonymXml = ""
|
||||
if ($Synonym) {
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$([System.Security.SecurityElement]::Escape($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
}
|
||||
|
||||
# --- Optional properties ---
|
||||
$vendorXml = if ($Vendor) { [System.Security.SecurityElement]::Escape($Vendor) } else { "" }
|
||||
$versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version) } else { "" }
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
|
||||
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
|
||||
|
||||
# --- Role name ---
|
||||
$roleName = "${NamePrefix}ОсновнаяРоль"
|
||||
|
||||
# --- DefaultRoles XML ---
|
||||
$defaultRolesXml = ""
|
||||
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
|
||||
$defaultRolesEl = "<DefaultRoles/>"
|
||||
if (-not $NoRole) {
|
||||
$defaultRolesXml = "`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t"
|
||||
$defaultRolesEl = "<DefaultRoles>`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t</DefaultRoles>"
|
||||
}
|
||||
|
||||
# --- ChildObjects ---
|
||||
@@ -144,10 +153,32 @@ if (-not $NoRole) {
|
||||
}
|
||||
$childObjectsXml += "`r`n`t`t"
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
|
||||
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
|
||||
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
|
||||
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
|
||||
$f221Captions = ""
|
||||
if ((Get-FormatRank $formatVersion) -ge 221) {
|
||||
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
$f221Captions = "`r`n`t`t`t<Caption/>`r`n`t`t`t<ShortCaption/>"
|
||||
}
|
||||
|
||||
# --- Configuration.xml ---
|
||||
$cfgXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||
<Configuration uuid="$uuidCfg">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -181,21 +212,21 @@ $cfgXml = @"
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<ObjectBelonging>Adopted</ObjectBelonging>
|
||||
<Name>$([System.Security.SecurityElement]::Escape($Name))</Name>
|
||||
<Name>$(Esc-XmlText ($Name))</Name>
|
||||
<Synonym>$synonymXml</Synonym>
|
||||
<Comment/>
|
||||
<ConfigurationExtensionPurpose>$Purpose</ConfigurationExtensionPurpose>
|
||||
<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
||||
<NamePrefix>$([System.Security.SecurityElement]::Escape($NamePrefix))</NamePrefix>
|
||||
<NamePrefix>$(Esc-XmlText ($NamePrefix))</NamePrefix>
|
||||
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
|
||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
</UsePurposes>
|
||||
<ScriptVariant>Russian</ScriptVariant>
|
||||
<DefaultRoles>$defaultRolesXml</DefaultRoles>
|
||||
<Vendor>$vendorXml</Vendor>
|
||||
<Version>$versionXml</Version>
|
||||
$defaultRolesEl
|
||||
$vendorEl
|
||||
$versionEl$f221Captions
|
||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
<BriefInformation/>
|
||||
<DetailedInformation/>
|
||||
@@ -212,7 +243,7 @@ $cfgXml = @"
|
||||
# --- Languages/Русский.xml (adopted format) ---
|
||||
$langXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||
<Language uuid="$uuidLang">
|
||||
<InternalInfo/>
|
||||
<Properties>
|
||||
@@ -229,10 +260,10 @@ $langXml = @"
|
||||
# --- Role XML ---
|
||||
$roleXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||
<Role uuid="$uuidRole">
|
||||
<Properties>
|
||||
<Name>$([System.Security.SecurityElement]::Escape($roleName))</Name>
|
||||
<Name>$(Esc-XmlText ($roleName))</Name>
|
||||
<Synonym/>
|
||||
<Comment/>
|
||||
</Properties>
|
||||
@@ -252,9 +283,18 @@ if (-not (Test-Path $langDir)) {
|
||||
# --- Write files with UTF-8 BOM ---
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
[System.IO.File]::WriteAllText($cfgFile, $cfgXml, $enc)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $cfgFile $cfgXml $enc
|
||||
$langFile = Join-Path $langDir "Русский.xml"
|
||||
[System.IO.File]::WriteAllText($langFile, $langXml, $enc)
|
||||
Write-XmlFile $langFile $langXml $enc
|
||||
|
||||
# --- Role ---
|
||||
if (-not $NoRole) {
|
||||
@@ -263,7 +303,7 @@ if (-not $NoRole) {
|
||||
New-Item -ItemType Directory -Path $roleDir -Force | Out-Null
|
||||
}
|
||||
$roleFile = Join-Path $roleDir "$roleName.xml"
|
||||
[System.IO.File]::WriteAllText($roleFile, $roleXml, $enc)
|
||||
Write-XmlFile $roleFile $roleXml $enc
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
+103
-22
@@ -1,20 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
||||
# cfe-init v1.10 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C configuration extension."""
|
||||
import sys, os, argparse, uuid
|
||||
import sys, os, re, argparse, uuid
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -29,7 +71,7 @@ def main():
|
||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||
parser.add_argument('-ConfigPath', dest='ConfigPath', default=None)
|
||||
parser.add_argument('-NoRole', dest='NoRole', action='store_true')
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
@@ -126,18 +168,23 @@ def main():
|
||||
# --- Synonym XML ---
|
||||
synonym_xml = ""
|
||||
if synonym:
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
|
||||
vendor_xml = esc_xml(vendor) if vendor else ""
|
||||
version_xml = esc_xml(version) if version else ""
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
|
||||
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
|
||||
|
||||
# --- Role name ---
|
||||
role_name = f"{name_prefix}ОсновнаяРоль"
|
||||
|
||||
# --- DefaultRoles XML ---
|
||||
default_roles_xml = ""
|
||||
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
|
||||
default_roles_el = "<DefaultRoles/>"
|
||||
if not args.NoRole:
|
||||
default_roles_xml = f'\r\n\t\t\t\t<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>\r\n\t\t\t'
|
||||
default_roles_el = ('<DefaultRoles>\r\n\t\t\t\t'
|
||||
f'<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>'
|
||||
'\r\n\t\t\t</DefaultRoles>')
|
||||
|
||||
# --- ChildObjects ---
|
||||
child_objects_xml = f"\r\n\t\t\t<Language>Русский</Language>"
|
||||
@@ -156,6 +203,40 @@ def main():
|
||||
]
|
||||
|
||||
contained_objects = ""
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
|
||||
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
|
||||
f221_captions = ""
|
||||
if format_rank(format_version) >= 221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
|
||||
for i in range(7):
|
||||
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
||||
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
|
||||
@@ -163,27 +244,27 @@ def main():
|
||||
\t\t\t</xr:ContainedObject>\n"""
|
||||
|
||||
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>
|
||||
{contained_objects}\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<ObjectBelonging>Adopted</ObjectBelonging>
|
||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<ConfigurationExtensionPurpose>{purpose}</ConfigurationExtensionPurpose>
|
||||
\t\t\t<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
||||
\t\t\t<NamePrefix>{esc_xml(name_prefix)}</NamePrefix>
|
||||
\t\t\t<NamePrefix>{esc_xml_text(name_prefix)}</NamePrefix>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
\t\t\t<UsePurposes>
|
||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
\t\t\t</UsePurposes>
|
||||
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
||||
\t\t\t<DefaultRoles>{default_roles_xml}</DefaultRoles>
|
||||
\t\t\t<Vendor>{vendor_xml}</Vendor>
|
||||
\t\t\t<Version>{version_xml}</Version>
|
||||
\t\t\t{default_roles_el}
|
||||
\t\t\t{vendor_el}
|
||||
\t\t\t{version_el}{f221_captions}
|
||||
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
\t\t\t<BriefInformation/>
|
||||
\t\t\t<DetailedInformation/>
|
||||
@@ -198,7 +279,7 @@ def main():
|
||||
|
||||
# --- Languages/Русский.xml (adopted format) ---
|
||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<InternalInfo/>
|
||||
\t\t<Properties>
|
||||
@@ -213,10 +294,10 @@ def main():
|
||||
|
||||
# --- Role XML ---
|
||||
role_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Role uuid="{uuid_role}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml(role_name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(role_name)}</Name>
|
||||
\t\t\t<Synonym/>
|
||||
\t\t\t<Comment/>
|
||||
\t\t</Properties>
|
||||
@@ -229,9 +310,9 @@ def main():
|
||||
os.makedirs(lang_dir, exist_ok=True)
|
||||
|
||||
# --- Write files ---
|
||||
write_utf8_bom(cfg_file, cfg_xml)
|
||||
write_xml_file(cfg_file, cfg_xml)
|
||||
lang_file = os.path.join(lang_dir, "Русский.xml")
|
||||
write_utf8_bom(lang_file, lang_xml)
|
||||
write_xml_file(lang_file, lang_xml)
|
||||
|
||||
# --- Role ---
|
||||
role_file = None
|
||||
@@ -239,7 +320,7 @@ def main():
|
||||
role_dir = os.path.join(output_dir, "Roles")
|
||||
os.makedirs(role_dir, exist_ok=True)
|
||||
role_file = os.path.join(role_dir, f"{role_name}.xml")
|
||||
write_utf8_bom(role_file, role_xml)
|
||||
write_xml_file(role_file, role_xml)
|
||||
|
||||
# --- Output ---
|
||||
print(f"[OK] Создано расширение: {name}")
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: cfe-patch-method
|
||||
description: Генерация и актуализация перехватчика метода в расширении 1С (CFE). Используй когда нужно перехватить метод заимствованного объекта — вставить код до, после, вместо оригинала, изменить его тело (ИзменениеИКонтроль) — или актуализировать перехватчик после изменения оригинала
|
||||
argument-hint: -ExtensionPath <path> -ConfigPath <path> -ModulePath "Catalog.X.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /cfe-patch-method — Генерация перехватчика метода
|
||||
|
||||
Читает оригинал метода из конфигурации-источника и генерирует `.bsl`-перехватчик заимствованного объекта: с правильной директивой контекста, полной сигнатурой, обрамляющими инструкциями препроцессора и областями. Для `ИзменениеИКонтроль` копирует всё тело оригинала. Создаёт файл модуля, дописывает в существующий или актуализирует уже заимствованный метод.
|
||||
|
||||
## Предусловие
|
||||
|
||||
Объект должен быть заимствован в расширение (`/cfe-borrow`). Нужен доступ к исходникам базовой конфигурации (`-ConfigPath`) — оттуда читается оригинал метода. `NamePrefix` берётся из `Configuration.xml` расширения.
|
||||
|
||||
### Авто-определение ConfigPath
|
||||
|
||||
Если пользователь не указал `-ConfigPath` — попробуй определить автоматически:
|
||||
1. Прочитай `.v8-project.json` из корня проекта
|
||||
2. Разреши целевую базу (по имени, ветке или `default` — алгоритм из `/db-list`)
|
||||
3. Если у базы есть поле `configSrc` — используй как `-ConfigPath`
|
||||
4. Если `configSrc` нет — спроси у пользователя
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Описание | По умолчанию |
|
||||
|----------|----------|--------------|
|
||||
| `ExtensionPath` | Путь к расширению (обязат.) | — |
|
||||
| `ConfigPath` | Путь к конфигурации-источнику | обязат., кроме случая, когда `ModulePath` — путь к файлу |
|
||||
| `ModulePath` | Логическое имя (`Тип.Имя.Модуль`) **или** путь к файлу модуля `.bsl` | обязат. для генерации |
|
||||
| `MethodName` | Имя перехватываемого метода | обязат. для генерации |
|
||||
| `InterceptorType` | `Before` / `After` / `Instead` / `ModificationAndControl` | обязат. для генерации |
|
||||
| `Check` | Проверить контролируемые методы на дрейф (только отчёт) | — |
|
||||
| `Actualize` | Актуализировать дрейфнувшие контролируемые методы | — |
|
||||
|
||||
## Формат ModulePath
|
||||
|
||||
| ModulePath | Файл |
|
||||
|------------|------|
|
||||
| `Catalog.X.ObjectModule` | `Catalogs/X/Ext/ObjectModule.bsl` |
|
||||
| `Catalog.X.ManagerModule` | `Catalogs/X/Ext/ManagerModule.bsl` |
|
||||
| `Catalog.X.Form.Y` | `Catalogs/X/Forms/Y/Ext/Form/Module.bsl` |
|
||||
| `CommonModule.X` | `CommonModules/X/Ext/Module.bsl` |
|
||||
| `Document.X.ObjectModule` | `Documents/X/Ext/ObjectModule.bsl` |
|
||||
| `Document.X.Form.Y` | `Documents/X/Forms/Y/Ext/Form/Module.bsl` |
|
||||
|
||||
Аналогично для Report, DataProcessor, InformationRegister и других типов.
|
||||
|
||||
Вместо логического имени в `ModulePath` можно передать **путь к файлу** модуля-источника `.bsl` — тогда `-ConfigPath` указывать не нужно (оригинал читается прямо из этого файла). Путь модуля расширения определяется от типовой папки в пути автоматически.
|
||||
|
||||
## Типы перехвата
|
||||
|
||||
| InterceptorType | Декоратор | Назначение | Применим к |
|
||||
|-----------------|-----------|------------|------------|
|
||||
| `Before` | `&Перед` | Код до вызова оригинального метода | процедуры |
|
||||
| `After` | `&После` | Код после вызова оригинального метода | процедуры |
|
||||
| `Instead` | `&Вместо` | Замена метода; в теле — скаффолд `ПродолжитьВызов(...)` | процедуры и функции |
|
||||
| `ModificationAndControl` | `&ИзменениеИКонтроль` | Копия тела оригинала для правки маркерами `#Вставка`/`#Удаление` | процедуры и функции |
|
||||
|
||||
## Маркеры `#Вставка` / `#Удаление` (для `ModificationAndControl`)
|
||||
|
||||
`&ИзменениеИКонтроль` вставляет в перехватчик **копию тела оригинала**. Дальше отредактируй тело под свою доработку, **помечая каждое изменение** — платформа так отличает твою правку от неизменного оригинала:
|
||||
|
||||
- **Добавляешь код** → оберни его `#Вставка` … `#КонецВставки`.
|
||||
- **Удаляешь код оригинала** → оберни удаляемые строки `#Удаление` … `#КонецУдаления`, но сами строки **оставь между маркерами** (платформа сверяет их с оригиналом).
|
||||
- **Заменяешь** → `#Удаление` старое `#КонецУдаления` сразу за ним `#Вставка` новое `#КонецВставки`.
|
||||
|
||||
Пример:
|
||||
```bsl
|
||||
&ИзменениеИКонтроль("ПриЗаписи")
|
||||
Процедура Расш_ПриЗаписи(Отказ)
|
||||
СуммаДокумента = РассчитатьСумму();
|
||||
#Вставка
|
||||
// доработка: округляем
|
||||
СуммаДокумента = Окр(СуммаДокумента, 2);
|
||||
#КонецВставки
|
||||
#Удаление
|
||||
Записать();
|
||||
#КонецУдаления
|
||||
#Вставка
|
||||
ЗаписатьСПроверкой(Отказ);
|
||||
#КонецВставки
|
||||
КонецПроцедуры
|
||||
```
|
||||
|
||||
Правила:
|
||||
- Маркеры — на **отдельной строке с 0-й колонки** (без отступа), даже внутри отступов и текста запроса (`|…`).
|
||||
- **Незамеченные (unmarked) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`).
|
||||
- Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай.
|
||||
|
||||
## Актуализация
|
||||
|
||||
После изменения оригинала в конфигурации-источнике перехватчик `&ИзменениеИКонтроль` может рассинхронизироваться — unmarked-контекст разойдётся с новым оригиналом. Платформа при загрузке об этом молчит, поэтому актуализируй сам:
|
||||
|
||||
- **Проверить** — `-Check`: отчёт по всем контролируемым методам расширения, ничего не пишет (`exit 1`, если есть дрейф или конфликт).
|
||||
- **Актуализировать** — `-Actualize`: переносит правки (`#Вставка`/`#Удаление`) на новый оригинал по всему расширению. Сузить область: `-ModulePath` — один модуль, `+ -MethodName` — один метод. Для одного метода то же делает повторный вызов с `-InterceptorType ModificationAndControl`.
|
||||
|
||||
Статусы в выводе:
|
||||
|
||||
- `[АКТУАЛЕН]` — оригинал не менялся, правок нет;
|
||||
- `[АКТУАЛИЗИРОВАН]` — тело обновлено по новому оригиналу, правки сохранены (в выводе — сводка);
|
||||
- `[АКТУАЛИЗИРОВАН-ЧАСТИЧНО]` — часть правок не удалось разместить (якорь изменился в новом оригинале). Они помечены `// [РЕСИНК-КОНФЛИКТ]` в модуле и не потеряны; путь к merge-воркспейсу — в выводе (начни с `index.md`, дальше по каждому конфликту его `conflict.md`, размести блоки вручную в `.bsl` расширения);
|
||||
- `[ПЕРЕНЕСЕНО В ОСНОВНУЮ]` — правка уже есть в новом оригинале (вставленный код внесён вендором) или удаляемый блок уже вырезан. Правка неактуальна — убирается из тела, дублировать/конфликтовать не нужно. Если так со всеми правками метода — перехватчик можно удалить. `-Check` этим не роняет `exit`.
|
||||
|
||||
Повторный вызов `Before`/`After`/`Instead` для уже перехваченного метода дубль не создаёт (`[ПРОПУЩЕН]`).
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
python ".agents/skills/cfe-patch-method/scripts/cfe-patch-method.py" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Код перед записью
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
|
||||
# Перехват После на форме
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||
|
||||
# Замена функции (ПродолжитьВызов)
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# ИзменениеИКонтроль — копия тела для правки маркерами
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||
|
||||
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
|
||||
... -ExtensionPath src\cfe\extname -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# Проверить все контролируемые методы расширения на дрейф
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Check
|
||||
|
||||
# Актуализировать дрейфнувшие контролируемые методы пачкой
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Actualize
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: cfe-validate
|
||||
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
|
||||
argument-hint: <ExtensionPath> [-ConfigPath <ConfigDir>] [-Detailed] [-MaxErrors 30]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /cfe-validate — валидация расширения конфигурации (CFE)
|
||||
|
||||
Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты, права ролей. Аналог `/cf-validate`, но для расширений.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Обяз. | Умолч. | Описание |
|
||||
|---------------|:-----:|---------|-------------------------------------------------|
|
||||
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
|
||||
| ConfigPath | нет | — | Каталог конфигурации, из которой заимствованы объекты |
|
||||
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
|
||||
| MaxErrors | нет | 30 | Остановиться после N ошибок |
|
||||
| OutFile | нет | — | Записать результат в файл |
|
||||
|
||||
### ConfigPath
|
||||
|
||||
Указывай всегда, когда конфигурация-источник доступна: без неё часть ошибок заимствованных форм не ловится, и расширение может пройти валидацию, а потом быть отвергнутым платформой при загрузке.
|
||||
|
||||
Если пользователь не указал путь — определи сам:
|
||||
1. Прочитай `.v8-project.json` из корня проекта
|
||||
2. Разреши целевую базу (по имени, ветке или `default`)
|
||||
3. Возьми её поле `configSrc`
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
python ".agents/skills/cfe-validate/scripts/cfe-validate.py" -ExtensionPath "src\cfe\extname"
|
||||
python ".agents/skills/cfe-validate/scripts/cfe-validate.py" -ExtensionPath "src\cfe\extname\Configuration.xml"
|
||||
python ".agents/skills/cfe-validate/scripts/cfe-validate.py" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf"
|
||||
```
|
||||
+387
-8
@@ -1,7 +1,8 @@
|
||||
# cfe-validate v1.4 — Validate 1C configuration extension structure (CFE)
|
||||
# cfe-validate v1.14 — Validate 1C configuration extension structure (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('Path')]
|
||||
[string]$ExtensionPath,
|
||||
|
||||
@@ -9,7 +10,11 @@ param(
|
||||
|
||||
[int]$MaxErrors = 30,
|
||||
|
||||
[string]$OutFile
|
||||
[string]$OutFile,
|
||||
|
||||
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -89,8 +94,42 @@ $finalize = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Reference tables ---
|
||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
# Модули заимствованных объектов: тип → виды модулей. Имя свойства в <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_]*$'
|
||||
|
||||
# 7 fixed ClassIds for Configuration
|
||||
@@ -108,7 +147,7 @@ $validClassIds = @(
|
||||
$childObjectTypes = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -122,7 +161,7 @@ $childObjectTypes = @(
|
||||
|
||||
# Type -> directory mapping
|
||||
$childTypeDirMap = @{
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"; "Bot"="Bots"
|
||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
||||
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
||||
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
||||
@@ -144,6 +183,46 @@ $childTypeDirMap = @{
|
||||
"IntegrationService"="IntegrationServices"
|
||||
}
|
||||
|
||||
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||
$generatedTypeCategories = @{
|
||||
"Catalog" = @("Object","Ref","Selection","List","Manager")
|
||||
"Document" = @("Object","Ref","Selection","List","Manager")
|
||||
"Enum" = @("Ref","Manager","List")
|
||||
"Constant" = @("Manager","ValueManager","ValueKey")
|
||||
"Report" = @("Object","Manager")
|
||||
"DataProcessor" = @("Object","Manager")
|
||||
"ExchangePlan" = @("Object","Ref","Selection","List","Manager")
|
||||
"Task" = @("Object","Ref","Selection","List","Manager")
|
||||
"BusinessProcess" = @("Object","Ref","Selection","List","Manager","RoutePointRef")
|
||||
"ChartOfCharacteristicTypes" = @("Object","Ref","Selection","List","Manager","Characteristic")
|
||||
"ChartOfAccounts" = @("Object","Ref","Selection","List","Manager","ExtDimensionTypes","ExtDimensionTypesRow")
|
||||
"ChartOfCalculationTypes" = @("Object","Ref","Selection","List","Manager","DisplacingCalculationTypes","DisplacingCalculationTypesRow","BaseCalculationTypes","BaseCalculationTypesRow","LeadingCalculationTypes","LeadingCalculationTypesRow")
|
||||
"InformationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","RecordManager")
|
||||
"AccumulationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey")
|
||||
"AccountingRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","ExtDimensions")
|
||||
"CalculationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","Recalcs")
|
||||
"DocumentJournal" = @("Selection","List","Manager")
|
||||
"Sequence" = @("Record","Manager","RecordSet")
|
||||
"FilterCriterion" = @("Manager","List")
|
||||
"SettingsStorage" = @("Manager")
|
||||
"IntegrationService" = @("Manager")
|
||||
"WSReference" = @("Manager")
|
||||
"DefinedType" = @("DefinedType")
|
||||
}
|
||||
|
||||
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||
$script:standardObjectFields = @(
|
||||
"Code","Description","Ref","Parent","Owner","DeletionMark","Predefined","IsFolder","LineNumber",
|
||||
"Number","Date","Posted","PredefinedDataName","RegisterRecords","DataVersion","RowsCount",
|
||||
"Код","Наименование","Ссылка","Родитель","Владелец","ПометкаУдаления","Предопределенный",
|
||||
"ЭтоГруппа","НомерСтроки","Номер","Дата","Проведен","ИмяПредопределенныхДанных",
|
||||
"Движения","ВерсияДанных","КоличествоСтрок"
|
||||
)
|
||||
|
||||
# Valid enum values for extension properties
|
||||
$validEnumValues = @{
|
||||
"ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
|
||||
@@ -195,10 +274,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
}
|
||||
|
||||
$version = $root.GetAttribute("version")
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
}
|
||||
|
||||
# Must have Configuration child
|
||||
@@ -536,6 +620,7 @@ if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- Check 9: Borrowed objects validation + Check 10: Sub-items ---
|
||||
$script:enumValuesIndex = @{}
|
||||
$script:borrowedTSIndex = @{}
|
||||
$script:formList = @()
|
||||
|
||||
# Helper: check if sub-item has explicit borrowed metadata
|
||||
@@ -639,6 +724,25 @@ if ($childObjNode) {
|
||||
} else {
|
||||
$borrowedOk++
|
||||
}
|
||||
|
||||
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||
$expectedCats = $generatedTypeCategories[$typeName]
|
||||
if ($expectedCats) {
|
||||
$objInfo = $objEl.SelectSingleNode("md:InternalInfo", $objNs)
|
||||
$foundCats = @{}
|
||||
if ($objInfo) {
|
||||
foreach ($gt in $objInfo.SelectNodes("xr:GeneratedType", $objNs)) {
|
||||
$cat = $gt.GetAttribute("category")
|
||||
if ($cat) { $foundCats[$cat] = $true }
|
||||
}
|
||||
}
|
||||
$missingCats = @($expectedCats | Where-Object { -not $foundCats.ContainsKey($_) })
|
||||
if ($missingCats.Count -gt 0) {
|
||||
Report-Error "9. Borrowed ${typeName}.${childName}: missing GeneratedType categor$(if ($missingCats.Count -eq 1) { 'y' } else { 'ies' }) $($missingCats -join ', ')"
|
||||
$check9Ok = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||
@@ -666,6 +770,12 @@ if ($childObjNode) {
|
||||
$tsInfo = $subItem.SelectSingleNode("md:InternalInfo", $objNs)
|
||||
$tsName = $subItem.SelectSingleNode("md:Properties/md:Name", $objNs)
|
||||
$tsLabel = if ($tsName) { $tsName.InnerText } else { "?" }
|
||||
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||
if ($tsName) {
|
||||
$tsKey = "${typeName}.${childName}"
|
||||
if (-not $script:borrowedTSIndex.ContainsKey($tsKey)) { $script:borrowedTSIndex[$tsKey] = @{} }
|
||||
$script:borrowedTSIndex[$tsKey][$tsName.InnerText] = $true
|
||||
}
|
||||
if (-not $tsInfo) {
|
||||
Report-Error "10. ${ctx}: TabularSection.${tsLabel} missing InternalInfo"
|
||||
$check10Ok = $false
|
||||
@@ -895,6 +1005,38 @@ foreach ($bf in $script:borrowedFormsWithTree) {
|
||||
}
|
||||
}
|
||||
|
||||
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
|
||||
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки ниже на
|
||||
# таких формах молча не срабатывали. Ищем сначала в <Attributes> самой формы, потом в <BaseForm>.
|
||||
$rootName = ""
|
||||
$rootMatch = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
|
||||
if ($rootMatch.Success) { $rootName = $rootMatch.Groups[1].Value }
|
||||
|
||||
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
|
||||
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
|
||||
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
|
||||
$acTables = @{}
|
||||
if ($rootName) {
|
||||
$rootPat = [regex]::Escape($rootName)
|
||||
foreach ($m in [regex]::Matches($raw, "<AdditionalColumns table=`"${rootPat}\.(\w+)`"")) {
|
||||
$acTables[$m.Groups[1].Value] = $true
|
||||
}
|
||||
}
|
||||
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
|
||||
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
|
||||
# поэтому ошибка.
|
||||
if ($acTables.Count -gt 0) {
|
||||
$ownerKey = ($ctx -split '\.Form\.')[0]
|
||||
$ownerTS = $script:borrowedTSIndex[$ownerKey]
|
||||
foreach ($tblName in $acTables.Keys) {
|
||||
$depCheckCount++
|
||||
if (-not $ownerTS -or -not $ownerTS.ContainsKey($tblName)) {
|
||||
Report-Error "12. ${ctx}: <AdditionalColumns table=`"${rootName}.${tblName}`"> — TabularSection.${tblName} not borrowed in extension"
|
||||
$check12Ok = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($mi in $missingItems) {
|
||||
Report-Warn "12. ${ctx}: references ${mi} not borrowed in extension"
|
||||
$check12Ok = $false
|
||||
@@ -930,6 +1072,243 @@ if ($script:borrowedFormsWithTree.Count -eq 0) {
|
||||
Report-OK "13. TypeLink: clean"
|
||||
}
|
||||
|
||||
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||
# не разрешится нигде, если Артикул — не реквизит объекта и не колонка из <Columns> самой формы.
|
||||
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||
if (-not $script:stopped -and $script:borrowedFormsWithTree.Count -gt 0) {
|
||||
if (-not $ConfigPath) {
|
||||
Out-Line "[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath"
|
||||
} else {
|
||||
$cfgRoot = $ConfigPath
|
||||
if (-not [System.IO.Path]::IsPathRooted($cfgRoot)) { $cfgRoot = Join-Path (Get-Location).Path $cfgRoot }
|
||||
if ((Test-Path $cfgRoot) -and -not (Test-Path $cfgRoot -PathType Container)) { $cfgRoot = Split-Path $cfgRoot -Parent }
|
||||
|
||||
if (-not (Test-Path (Join-Path $cfgRoot "Configuration.xml"))) {
|
||||
Report-Warn "14. -ConfigPath '$ConfigPath': Configuration.xml не найден — проверка путей пропущена"
|
||||
} else {
|
||||
$check14Ok = $true
|
||||
$pathCheckCount = 0
|
||||
|
||||
foreach ($bf in $script:borrowedFormsWithTree) {
|
||||
$raw = $bf.RawText
|
||||
$ctx = $bf.Context
|
||||
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||
$rootMatch14 = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
|
||||
if (-not $rootMatch14.Success) { continue }
|
||||
$rootName = $rootMatch14.Groups[1].Value
|
||||
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||
if ($rootMatch14.Value -match '>cfg:DynamicList<') { continue }
|
||||
$ownerKey = ($ctx -split '\.Form\.')[0]
|
||||
$ownerParts = $ownerKey -split '\.', 2
|
||||
if ($ownerParts.Count -lt 2) { continue }
|
||||
$ownerType = $ownerParts[0]; $ownerName = $ownerParts[1]
|
||||
$ownerDir = $childTypeDirMap[$ownerType]
|
||||
if (-not $ownerDir) { continue }
|
||||
$srcObjFile = Join-Path (Join-Path $cfgRoot $ownerDir) "${ownerName}.xml"
|
||||
if (-not (Test-Path $srcObjFile)) {
|
||||
Report-Warn "14. ${ctx}: объект-источник не найден в конфигурации ($ownerDir/${ownerName}.xml)"
|
||||
continue
|
||||
}
|
||||
|
||||
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||
$srcNames = @{}
|
||||
$srcTSColumns = @{}
|
||||
$srcDoc = New-Object System.Xml.XmlDocument
|
||||
$srcDoc.PreserveWhitespace = $false
|
||||
$srcDoc.Load($srcObjFile)
|
||||
$srcObjEl = $null
|
||||
foreach ($c in $srcDoc.DocumentElement.ChildNodes) {
|
||||
if ($c.NodeType -eq 'Element') { $srcObjEl = $c; break }
|
||||
}
|
||||
$srcChildObjects = if ($srcObjEl) { $srcObjEl.SelectSingleNode("*[local-name()='ChildObjects']") } else { $null }
|
||||
if ($srcChildObjects) {
|
||||
foreach ($sub in $srcChildObjects.ChildNodes) {
|
||||
if ($sub.NodeType -ne 'Element') { continue }
|
||||
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них замена
|
||||
# корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||
if ($sub.LocalName -notin @('Attribute','Dimension','Resource','TabularSection')) { continue }
|
||||
$nameNode = $sub.SelectSingleNode("*[local-name()='Properties']/*[local-name()='Name']")
|
||||
if (-not $nameNode) { continue }
|
||||
$subName = $nameNode.InnerText.Trim()
|
||||
$srcNames[$subName] = $true
|
||||
if ($sub.LocalName -ne 'TabularSection') { continue }
|
||||
$cols = @{}
|
||||
foreach ($colName in $sub.SelectNodes("*[local-name()='ChildObjects']/*[local-name()='Attribute']/*[local-name()='Properties']/*[local-name()='Name']")) {
|
||||
$cols[$colName.InnerText.Trim()] = $true
|
||||
}
|
||||
$srcTSColumns[$subName] = $cols
|
||||
}
|
||||
}
|
||||
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||
$rootPat14 = [regex]::Escape($rootName)
|
||||
foreach ($acm in [regex]::Matches($raw, "(?s)<AdditionalColumns table=`"${rootPat14}\.(\w+)`">(.*?)</AdditionalColumns>")) {
|
||||
$tbl = $acm.Groups[1].Value
|
||||
if (-not $srcTSColumns.ContainsKey($tbl)) { $srcTSColumns[$tbl] = @{} }
|
||||
foreach ($cm in [regex]::Matches($acm.Groups[2].Value, '<Column name="(\w+)"')) {
|
||||
$srcTSColumns[$tbl][$cm.Groups[1].Value] = $true
|
||||
}
|
||||
}
|
||||
|
||||
$badPaths = @{}
|
||||
foreach ($m in [regex]::Matches($raw, "<(?:\w+:)?\w*DataPath[^>]*>${rootPat14}\.([^<]+)</(?:\w+:)?\w*DataPath>")) {
|
||||
$segments = $m.Groups[1].Value -split '\.'
|
||||
$seg0 = $segments[0]
|
||||
$pathCheckCount++
|
||||
if ($script:standardObjectFields -contains $seg0) { continue }
|
||||
if (-not $srcNames.ContainsKey($seg0)) {
|
||||
$badPaths["${rootName}.${seg0}"] = "у ${ownerKey} нет такого реквизита или табличной части"
|
||||
continue
|
||||
}
|
||||
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||
# он ведёт в чужой объект, и это уже другая проверка.
|
||||
if ($segments.Count -lt 2 -or -not $srcTSColumns.ContainsKey($seg0)) { continue }
|
||||
$seg1 = $segments[1]
|
||||
if ($script:standardObjectFields -contains $seg1) { continue }
|
||||
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||
if ($seg1 -like "Total*" -and $srcTSColumns[$seg0].ContainsKey($seg1.Substring(5))) { continue }
|
||||
if (-not $srcTSColumns[$seg0].ContainsKey($seg1)) {
|
||||
$badPaths["${rootName}.${seg0}.${seg1}"] = "у табличной части ${seg0} нет колонки ${seg1}, и <Columns> формы её не объявляет"
|
||||
}
|
||||
}
|
||||
foreach ($bad in ($badPaths.Keys | Sort-Object)) {
|
||||
Report-Error "14. ${ctx}: путь '${bad}' — $($badPaths[$bad])"
|
||||
$check14Ok = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($check14Ok) {
|
||||
Report-OK "14. Object paths vs source config: $pathCheckCount checked"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- 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 }
|
||||
|
||||
# --- 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 ---
|
||||
$extRootDir = Split-Path $resolvedPath -Parent
|
||||
$ctrlCount = 0
|
||||
foreach ($bslFile in (Get-ChildItem -Path $extRootDir -Recurse -Filter *.bsl -File -ErrorAction SilentlyContinue)) {
|
||||
$txt = [System.IO.File]::ReadAllText($bslFile.FullName, [System.Text.Encoding]::UTF8)
|
||||
$ctrlCount += ([regex]::Matches($txt, '(?m)^\s*&ИзменениеИКонтроль\(')).Count
|
||||
}
|
||||
if ($ctrlCount -gt 0) {
|
||||
Out-Line "[INFO] Контролируемых методов (&ИзменениеИКонтроль): $ctrlCount — их актуальность здесь не проверяется. Сверьте: /cfe-patch-method -Check -ExtensionPath <ext> -ConfigPath <cf>"
|
||||
}
|
||||
|
||||
# --- Final output ---
|
||||
& $finalize
|
||||
|
||||
+399
-5
@@ -1,10 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-validate v1.4 — Validate 1C configuration extension XML structure (CFE)
|
||||
# cfe-validate v1.14 — Validate 1C configuration extension XML structure (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects."""
|
||||
import sys, os, argparse, re
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NS = {
|
||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||
@@ -37,7 +59,7 @@ VALID_CLASS_IDS = [
|
||||
CHILD_OBJECT_TYPES = [
|
||||
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
||||
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan',
|
||||
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
||||
@@ -49,11 +71,33 @@ CHILD_OBJECT_TYPES = [
|
||||
'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
|
||||
CHILD_TYPE_DIR_MAP = {
|
||||
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
||||
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
||||
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
||||
'Bot': 'Bots',
|
||||
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
||||
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
||||
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
||||
@@ -73,6 +117,50 @@ CHILD_TYPE_DIR_MAP = {
|
||||
'IntegrationService': 'IntegrationServices',
|
||||
}
|
||||
|
||||
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||
GENERATED_TYPE_CATEGORIES = {
|
||||
'Catalog': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'Document': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'Enum': ['Ref', 'Manager', 'List'],
|
||||
'Constant': ['Manager', 'ValueManager', 'ValueKey'],
|
||||
'Report': ['Object', 'Manager'],
|
||||
'DataProcessor': ['Object', 'Manager'],
|
||||
'ExchangePlan': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'Task': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'BusinessProcess': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'RoutePointRef'],
|
||||
'ChartOfCharacteristicTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'Characteristic'],
|
||||
'ChartOfAccounts': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'ExtDimensionTypes', 'ExtDimensionTypesRow'],
|
||||
'ChartOfCalculationTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'DisplacingCalculationTypes', 'DisplacingCalculationTypesRow', 'BaseCalculationTypes', 'BaseCalculationTypesRow', 'LeadingCalculationTypes', 'LeadingCalculationTypesRow'],
|
||||
'InformationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'RecordManager'],
|
||||
'AccumulationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey'],
|
||||
'AccountingRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'ExtDimensions'],
|
||||
'CalculationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'Recalcs'],
|
||||
'DocumentJournal': ['Selection', 'List', 'Manager'],
|
||||
'Sequence': ['Record', 'Manager', 'RecordSet'],
|
||||
'FilterCriterion': ['Manager', 'List'],
|
||||
'SettingsStorage': ['Manager'],
|
||||
'IntegrationService': ['Manager'],
|
||||
'WSReference': ['Manager'],
|
||||
'DefinedType': ['DefinedType'],
|
||||
}
|
||||
|
||||
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||
# Основной реквизит формы: <Attribute name="X"> с <MainAttribute>true</MainAttribute> внутри
|
||||
MAIN_ATTR_RE = re.compile(
|
||||
r'<Attribute name=\"([^\"]+)\"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>', re.DOTALL)
|
||||
|
||||
STANDARD_OBJECT_FIELDS = {
|
||||
'Code', 'Description', 'Ref', 'Parent', 'Owner', 'DeletionMark', 'Predefined', 'IsFolder', 'LineNumber',
|
||||
'Number', 'Date', 'Posted', 'PredefinedDataName', 'RegisterRecords', 'DataVersion', 'RowsCount',
|
||||
'Код', 'Наименование', 'Ссылка', 'Родитель', 'Владелец', 'ПометкаУдаления', 'Предопределенный',
|
||||
'ЭтоГруппа', 'НомерСтроки', 'Номер', 'Дата', 'Проведен', 'ИмяПредопределенныхДанных',
|
||||
'Движения', 'ВерсияДанных', 'КоличествоСтрок',
|
||||
}
|
||||
|
||||
# Valid enum values for extension properties
|
||||
VALID_ENUM_VALUES = {
|
||||
'ConfigurationExtensionCompatibilityMode': [
|
||||
@@ -94,6 +182,20 @@ VALID_ENUM_VALUES = {
|
||||
|
||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
class Reporter:
|
||||
def __init__(self, max_errors, detailed=False):
|
||||
@@ -154,11 +256,15 @@ def main():
|
||||
parser.add_argument('-Detailed', action='store_true')
|
||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||
args = parser.parse_args()
|
||||
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||
parser.add_argument('-ConfigPath', dest='ConfigPath', default='')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
extension_path = args.ExtensionPath
|
||||
max_errors = args.MaxErrors
|
||||
out_file = args.OutFile
|
||||
config_path_arg = args.ConfigPath
|
||||
|
||||
# --- Resolve path ---
|
||||
if not os.path.isabs(extension_path):
|
||||
@@ -214,10 +320,17 @@ def main():
|
||||
check1_ok = False
|
||||
|
||||
version = root.get('version', '')
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
r.warn('1. Missing version attribute on MetaDataObject')
|
||||
elif version not in ('2.17', '2.20', '2.21'):
|
||||
r.warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
||||
elif version_rank == 0:
|
||||
r.error(f"1. Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
r.warn(f"1. Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
r.warn(f"1. Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
|
||||
# Must have Configuration child
|
||||
cfg_node = None
|
||||
@@ -536,6 +649,7 @@ def main():
|
||||
MD = NS['md']
|
||||
XR = NS['xr']
|
||||
enum_values_index = {}
|
||||
borrowed_ts_index = {}
|
||||
form_list = []
|
||||
|
||||
def is_borrowed_sub_item(sub_item):
|
||||
@@ -635,6 +749,23 @@ def main():
|
||||
else:
|
||||
borrowed_ok_count += 1
|
||||
|
||||
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||
expected_cats = GENERATED_TYPE_CATEGORIES.get(type_name)
|
||||
if expected_cats:
|
||||
obj_info = obj_el.find(f'{{{MD}}}InternalInfo')
|
||||
found_cats = set()
|
||||
if obj_info is not None:
|
||||
for gt in obj_info.findall(f'{{{XR}}}GeneratedType'):
|
||||
cat = gt.get('category')
|
||||
if cat:
|
||||
found_cats.add(cat)
|
||||
missing_cats = [c for c in expected_cats if c not in found_cats]
|
||||
if missing_cats:
|
||||
word = 'category' if len(missing_cats) == 1 else 'categories'
|
||||
r.error(f"9. Borrowed {type_name}.{child_name}: missing GeneratedType {word} {', '.join(missing_cats)}")
|
||||
check9_ok = False
|
||||
|
||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||
obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects')
|
||||
if obj_child_objects is not None:
|
||||
@@ -662,6 +793,9 @@ def main():
|
||||
ts_info = sub_item.find(f'{{{MD}}}InternalInfo')
|
||||
ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||
ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?'
|
||||
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||
if ts_name_el is not None and ts_name_el.text:
|
||||
borrowed_ts_index.setdefault(f'{type_name}.{child_name}', {})[ts_name_el.text.strip()] = True
|
||||
if ts_info is None:
|
||||
r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo')
|
||||
check10_ok = False
|
||||
@@ -854,6 +988,29 @@ def main():
|
||||
elif entry['Enum'] not in enum_values_index or entry['Value'] not in enum_values_index.get(entry['Enum'], {}):
|
||||
missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}")
|
||||
|
||||
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
|
||||
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
|
||||
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
|
||||
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
|
||||
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
|
||||
# поэтому ошибка.
|
||||
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
|
||||
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки на
|
||||
# таких формах молча не срабатывали. Ищем сначала в <Attributes> формы, потом в <BaseForm>.
|
||||
root_match = MAIN_ATTR_RE.search(raw)
|
||||
root_name = root_match.group(1) if root_match else ""
|
||||
ac_tables = set()
|
||||
if root_name:
|
||||
ac_tables = set(re.findall(r'<AdditionalColumns table="' + re.escape(root_name) + r'\.(\w+)"', raw))
|
||||
if ac_tables:
|
||||
owner_key = ctx.split('.Form.')[0]
|
||||
owner_ts = borrowed_ts_index.get(owner_key, {})
|
||||
for tbl_name in sorted(ac_tables):
|
||||
dep_check_count += 1
|
||||
if tbl_name not in owner_ts:
|
||||
r.error(f'12. {ctx}: <AdditionalColumns table="{root_name}.{tbl_name}"> — TabularSection.{tbl_name} not borrowed in extension')
|
||||
check12_ok = False
|
||||
|
||||
for mi in missing_items:
|
||||
r.warn(f'12. {ctx}: references {mi} not borrowed in extension')
|
||||
check12_ok = False
|
||||
@@ -885,6 +1042,243 @@ def main():
|
||||
elif check13_ok:
|
||||
r.ok('13. TypeLink: clean')
|
||||
|
||||
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||
# не разрешится нигде, если Артикул — не колонка ТЧ и не колонка из <Columns> самой формы.
|
||||
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||
if not r.stopped and borrowed_forms_with_tree:
|
||||
if not config_path_arg:
|
||||
r.out('[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath')
|
||||
else:
|
||||
cfg_root = config_path_arg
|
||||
if not os.path.isabs(cfg_root):
|
||||
cfg_root = os.path.join(os.getcwd(), cfg_root)
|
||||
if os.path.exists(cfg_root) and not os.path.isdir(cfg_root):
|
||||
cfg_root = os.path.dirname(cfg_root)
|
||||
|
||||
if not os.path.isfile(os.path.join(cfg_root, 'Configuration.xml')):
|
||||
r.warn(f"14. -ConfigPath '{config_path_arg}': Configuration.xml не найден — проверка путей пропущена")
|
||||
else:
|
||||
check14_ok = True
|
||||
path_check_count = 0
|
||||
|
||||
for bf in borrowed_forms_with_tree:
|
||||
raw = bf['RawText']
|
||||
ctx = bf['Context']
|
||||
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||
root_match14 = MAIN_ATTR_RE.search(raw)
|
||||
if root_match14 is None:
|
||||
continue
|
||||
root_name14 = root_match14.group(1)
|
||||
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||
if '>cfg:DynamicList<' in root_match14.group(0):
|
||||
continue
|
||||
owner_key = ctx.split('.Form.')[0]
|
||||
owner_parts = owner_key.split('.', 1)
|
||||
if len(owner_parts) < 2:
|
||||
continue
|
||||
owner_type, owner_name = owner_parts
|
||||
owner_dir = CHILD_TYPE_DIR_MAP.get(owner_type)
|
||||
if not owner_dir:
|
||||
continue
|
||||
src_obj_file = os.path.join(cfg_root, owner_dir, f'{owner_name}.xml')
|
||||
if not os.path.isfile(src_obj_file):
|
||||
r.warn(f'14. {ctx}: объект-источник не найден в конфигурации ({owner_dir}/{owner_name}.xml)')
|
||||
continue
|
||||
|
||||
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||
src_names = set()
|
||||
src_ts_columns = {}
|
||||
src_tree = etree.parse(src_obj_file, etree.XMLParser(remove_blank_text=True))
|
||||
src_obj_el = None
|
||||
for c in src_tree.getroot():
|
||||
if isinstance(c.tag, str):
|
||||
src_obj_el = c
|
||||
break
|
||||
src_child_objects = src_obj_el.find(f'{{{MD}}}ChildObjects') if src_obj_el is not None else None
|
||||
if src_child_objects is not None:
|
||||
for sub in src_child_objects:
|
||||
if not isinstance(sub.tag, str):
|
||||
continue
|
||||
sub_ln = etree.QName(sub.tag).localname
|
||||
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них
|
||||
# замена корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||
if sub_ln not in ('Attribute', 'Dimension', 'Resource', 'TabularSection'):
|
||||
continue
|
||||
name_el = sub.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||
if name_el is None or not name_el.text:
|
||||
continue
|
||||
sub_name = name_el.text.strip()
|
||||
src_names.add(sub_name)
|
||||
if sub_ln != 'TabularSection':
|
||||
continue
|
||||
cols = set()
|
||||
for col_name in sub.findall(f'{{{MD}}}ChildObjects/{{{MD}}}Attribute/{{{MD}}}Properties/{{{MD}}}Name'):
|
||||
if col_name.text:
|
||||
cols.add(col_name.text.strip())
|
||||
src_ts_columns[sub_name] = cols
|
||||
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||
root_pat14 = re.escape(root_name14)
|
||||
for acm in re.finditer(r'<AdditionalColumns table="' + root_pat14 + r'\.(\w+)">(.*?)</AdditionalColumns>', raw, re.DOTALL):
|
||||
tbl = acm.group(1)
|
||||
cols = src_ts_columns.setdefault(tbl, set())
|
||||
for cm in re.finditer(r'<Column name="(\w+)"', acm.group(2)):
|
||||
cols.add(cm.group(1))
|
||||
|
||||
bad_paths = {}
|
||||
for m in re.finditer(r'<(?:\w+:)?\w*DataPath[^>]*>' + root_pat14 + r'\.([^<]+)</(?:\w+:)?\w*DataPath>', raw):
|
||||
segments = m.group(1).split('.')
|
||||
seg0 = segments[0]
|
||||
path_check_count += 1
|
||||
if seg0 in STANDARD_OBJECT_FIELDS:
|
||||
continue
|
||||
if seg0 not in src_names:
|
||||
bad_paths[f'{root_name14}.{seg0}'] = f'у {owner_key} нет такого реквизита или табличной части'
|
||||
continue
|
||||
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||
# он ведёт в чужой объект, и это уже другая проверка.
|
||||
if len(segments) < 2 or seg0 not in src_ts_columns:
|
||||
continue
|
||||
seg1 = segments[1]
|
||||
if seg1 in STANDARD_OBJECT_FIELDS:
|
||||
continue
|
||||
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||
if seg1.startswith('Total') and seg1[5:] in src_ts_columns[seg0]:
|
||||
continue
|
||||
if seg1 not in src_ts_columns[seg0]:
|
||||
bad_paths[f'{root_name14}.{seg0}.{seg1}'] = f'у табличной части {seg0} нет колонки {seg1}, и <Columns> формы её не объявляет'
|
||||
|
||||
for bad in sorted(bad_paths):
|
||||
r.error(f"14. {ctx}: путь '{bad}' — {bad_paths[bad]}")
|
||||
check14_ok = False
|
||||
|
||||
if check14_ok:
|
||||
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:
|
||||
r.finalize(out_file)
|
||||
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 ---
|
||||
ctrl_count = 0
|
||||
for dp, _dn, files in os.walk(config_dir):
|
||||
for fn in files:
|
||||
if fn.endswith('.bsl'):
|
||||
try:
|
||||
with open(os.path.join(dp, fn), 'r', encoding='utf-8-sig') as f:
|
||||
for ln in f:
|
||||
if re.match(r'^\s*&ИзменениеИКонтроль\(', ln):
|
||||
ctrl_count += 1
|
||||
except OSError:
|
||||
pass
|
||||
if ctrl_count > 0:
|
||||
r.out('[INFO] Контролируемых методов (&ИзменениеИКонтроль): %d — их актуальность здесь не проверяется. Сверьте: /cfe-patch-method -Check -ExtensionPath <ext> -ConfigPath <cf>' % ctrl_count)
|
||||
|
||||
# --- Final output ---
|
||||
r.finalize(out_file)
|
||||
sys.exit(1 if r.errors > 0 else 0)
|
||||
@@ -31,7 +31,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <параметры>
|
||||
python ".agents/skills/db-create/scripts/db-create.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -45,6 +45,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <п
|
||||
| `-UseTemplate <файл>` | нет | Создать из шаблона (.cf или .dt) |
|
||||
| `-AddToList` | нет | Добавить в список баз 1С |
|
||||
| `-ListName <имя>` | нет | Имя базы в списке |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -57,14 +59,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <п
|
||||
|
||||
```powershell
|
||||
# Создать файловую базу
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB"
|
||||
python ".agents/skills/db-create/scripts/db-create.py" -InfoBasePath "C:\Bases\NewDB"
|
||||
|
||||
# Создать серверную базу
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test"
|
||||
python ".agents/skills/db-create/scripts/db-create.py" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test"
|
||||
|
||||
# Создать из шаблона CF
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf"
|
||||
python ".agents/skills/db-create/scripts/db-create.py" -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 "Новая база"
|
||||
python ".agents/skills/db-create/scripts/db-create.py" -InfoBasePath "C:\Bases\NewDB" -AddToList -ListName "Новая база"
|
||||
```
|
||||
@@ -0,0 +1,476 @@
|
||||
# db-create v1.11 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Создание информационной базы 1С
|
||||
|
||||
.DESCRIPTION
|
||||
Создаёт новую информационную базу 1С (файловую или серверную).
|
||||
Поддерживает создание из шаблона и добавление в список баз.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UseTemplate
|
||||
Путь к файлу шаблона (.cf или .dt)
|
||||
|
||||
.PARAMETER AddToList
|
||||
Добавить в список баз 1С
|
||||
|
||||
.PARAMETER ListName
|
||||
Имя базы в списке
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UseTemplate,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AddToList,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ListName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$UseTemplate = ConvertTo-CleanPath $UseTemplate '-UseTemplate'
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-FileIbCreated {
|
||||
# File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$IbPath)
|
||||
$f = Join-Path $IbPath "1Cv8.1CD"
|
||||
return (Test-Path $f) -and ((Get-Item $f -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/UseTemplate' = '-UseTemplate'; '/AddToList' = '-AddToList'; '--db-path' = '-InfoBasePath'; '--load' = '-UseTemplate'; '--restore' = '-UseTemplate' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate template ---
|
||||
if ($UseTemplate -and -not (Test-Path $UseTemplate)) {
|
||||
Write-Host "Error: template file not found: $UseTemplate" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_create_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "create", "--db-path=$InfoBasePath", "--create-database")
|
||||
if ($UseTemplate) {
|
||||
if ([System.IO.Path]::GetExtension($UseTemplate) -ieq ".dt") {
|
||||
$arguments += "--restore=$UseTemplate"
|
||||
} else {
|
||||
$arguments += "--load=$UseTemplate", "--apply"
|
||||
}
|
||||
}
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$ibMissing = ($exitCode -eq 0) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||
if ($ibMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||
} elseif ($ibMissing) {
|
||||
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("CREATEINFOBASE")
|
||||
|
||||
# Quotes go INSIDE the token (File="path"): 1C's own parser wants them there, quoting
|
||||
# the whole token instead breaks a path with spaces. Hence -PreQuoted on the launch.
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "Srvr=`"$InfoBaseServer`";Ref=`"$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "File=`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
# --- Template ---
|
||||
if ($UseTemplate) {
|
||||
$arguments += "/UseTemplate", "`"$UseTemplate`""
|
||||
}
|
||||
|
||||
# --- Add to list ---
|
||||
if ($AddToList) {
|
||||
if ($ListName) {
|
||||
$arguments += "/AddToList", "`"$ListName`""
|
||||
} else {
|
||||
$arguments += "/AddToList"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "create_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
|
||||
$ibMissing = ($exitCode -eq 0) -and -not ($InfoBaseServer -and $InfoBaseRef) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||
if ($ibMissing) { $exitCode = 1 }
|
||||
|
||||
if ($exitCode -eq 0) {
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
Write-Host "Information base created successfully: $InfoBaseServer/$InfoBaseRef" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||
}
|
||||
} elseif ($ibMissing) {
|
||||
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-create v1.11 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
def file_ib_created(ib_path):
|
||||
"""File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
f = os.path.join(ib_path, "1Cv8.1CD")
|
||||
return os.path.isfile(f) and os.path.getsize(f) > 0
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create 1C information base",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UseTemplate", default="")
|
||||
parser.add_argument("-AddToList", action="store_true")
|
||||
parser.add_argument("-ListName", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
args.UseTemplate = clean_path(args.UseTemplate, "-UseTemplate")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/UseTemplate": "-UseTemplate",
|
||||
"/AddToList": "-AddToList",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--load": "-UseTemplate",
|
||||
"--restore": "-UseTemplate",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate template ---
|
||||
if args.UseTemplate and not os.path.exists(args.UseTemplate):
|
||||
print(f"Error: template file not found: {args.UseTemplate}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "create", f"--db-path={args.InfoBasePath}", "--create-database"]
|
||||
if args.UseTemplate:
|
||||
if os.path.splitext(args.UseTemplate)[1].lower() == ".dt":
|
||||
arguments.append(f"--restore={args.UseTemplate}")
|
||||
else:
|
||||
arguments.extend([f"--load={args.UseTemplate}", "--apply"])
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
print(f"Running: ibcmd {' '.join(format_args_for_display(arguments, engine))}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
ib_missing = exit_code == 0 and not file_ib_created(args.InfoBasePath)
|
||||
if ib_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||
elif ib_missing:
|
||||
print(
|
||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||
"— information base was not created",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_create_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["CREATEINFOBASE"]
|
||||
|
||||
# Quotes go INSIDE the token (File="path"): that is where 1C's parser expects them.
|
||||
# Quoting the whole token instead breaks a path with spaces — on both OSes.
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.append(f'Srvr="{args.InfoBaseServer}";Ref="{args.InfoBaseRef}"')
|
||||
else:
|
||||
arguments.append(f'File="{args.InfoBasePath}"')
|
||||
|
||||
# --- Template ---
|
||||
if args.UseTemplate:
|
||||
arguments.extend(["/UseTemplate", f'"{args.UseTemplate}"'])
|
||||
|
||||
# --- Add to list ---
|
||||
if args.AddToList:
|
||||
if args.ListName:
|
||||
arguments.extend(["/AddToList", f'"{args.ListName}"'])
|
||||
else:
|
||||
arguments.append("/AddToList")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "create_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(format_args_for_display(arguments, engine))}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
|
||||
is_server = bool(args.InfoBaseServer and args.InfoBaseRef)
|
||||
ib_missing = exit_code == 0 and not is_server and not file_ib_created(args.InfoBasePath)
|
||||
if ib_missing:
|
||||
exit_code = 1
|
||||
|
||||
if exit_code == 0:
|
||||
if is_server:
|
||||
print(f"Information base created successfully: {args.InfoBaseServer}/{args.InfoBaseRef}")
|
||||
else:
|
||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||
elif ib_missing:
|
||||
print(
|
||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||
"— information base was not created",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
print_platform_output(result)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -35,7 +35,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <параметры>
|
||||
python ".agents/skills/db-dump-cf/scripts/db-dump-cf.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -51,6 +51,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <п
|
||||
| `-OutputFile <путь>` | да | Путь к выходному CF-файлу |
|
||||
| `-Extension <имя>` | нет | Выгрузить расширение |
|
||||
| `-AllExtensions` | нет | Выгрузить все расширения |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -58,11 +60,11 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <п
|
||||
|
||||
```powershell
|
||||
# Выгрузка конфигурации (файловая база)
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\config.cf"
|
||||
python ".agents/skills/db-dump-cf/scripts/db-dump-cf.py" -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"
|
||||
python ".agents/skills/db-dump-cf/scripts/db-dump-cf.py" -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 "МоёРасширение"
|
||||
python ".agents/skills/db-dump-cf/scripts/db-dump-cf.py" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
```
|
||||
@@ -0,0 +1,496 @@
|
||||
# db-dump-cf v1.13 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка конфигурации 1С в CF-файл
|
||||
|
||||
.DESCRIPTION
|
||||
Выгружает конфигурацию информационной базы в бинарный CF-файл.
|
||||
Поддерживает выгрузку расширений.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному CF-файлу
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для выгрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Выгрузить все расширения
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "config.cf"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_cf_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config save does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "save", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$OutputFile"
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/DumpCfg", "`"$OutputFile`""
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-cf v1.13 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C configuration to CF file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
out_dir = os.path.dirname(args.OutputFile)
|
||||
if out_dir and not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.OutputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_cf_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpCfg", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_cf_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -38,7 +38,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <параметры>
|
||||
python ".agents/skills/db-dump-dt/scripts/db-dump-dt.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -52,6 +52,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <п
|
||||
| `-UserName <имя>` | нет | Имя пользователя |
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному DT-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -59,10 +61,10 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <п
|
||||
|
||||
```powershell
|
||||
# Выгрузка ИБ (файловая база)
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt"
|
||||
python ".agents/skills/db-dump-dt/scripts/db-dump-dt.py" -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"
|
||||
python ".agents/skills/db-dump-dt/scripts/db-dump-dt.py" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt"
|
||||
```
|
||||
|
||||
## Связанные навыки
|
||||
@@ -0,0 +1,470 @@
|
||||
# db-dump-dt v1.12 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка информационной базы 1С в DT-файл
|
||||
|
||||
.DESCRIPTION
|
||||
Выгружает информационную базу целиком (конфигурация + данные) в DT-файл.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному DT-файлу
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_dt_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "dump", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "$OutputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/DumpIB", "`"$OutputFile`""
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-dt v1.12 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C information base to DT file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
out_dir = os.path.dirname(args.OutputFile)
|
||||
if out_dir and not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "dump", f"--db-path={args.InfoBasePath}"]
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(args.OutputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_dt_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpIB", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_dt_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -37,7 +37,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <параметры>
|
||||
python ".agents/skills/db-dump-xml/scripts/db-dump-xml.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -56,6 +56,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <
|
||||
| `-Extension <имя>` | нет | Выгрузить расширение |
|
||||
| `-AllExtensions` | нет | Выгрузить все расширения |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -74,17 +76,17 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <
|
||||
|
||||
```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
|
||||
python ".agents/skills/db-dump-xml/scripts/db-dump-xml.py" -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
|
||||
python ".agents/skills/db-dump-xml/scripts/db-dump-xml.py" -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 "Справочник.Номенклатура,Документ.Заказ"
|
||||
python ".agents/skills/db-dump-xml/scripts/db-dump-xml.py" -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
|
||||
python ".agents/skills/db-dump-xml/scripts/db-dump-xml.py" -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 "МоёРасширение"
|
||||
python ".agents/skills/db-dump-xml/scripts/db-dump-xml.py" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение"
|
||||
```
|
||||
@@ -0,0 +1,566 @@
|
||||
# db-dump-xml v1.15 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка конфигурации 1С в XML-файлы
|
||||
|
||||
.DESCRIPTION
|
||||
Выполняет выгрузку конфигурации 1С в файлы в четырёх режимах:
|
||||
- Full: полная выгрузка всей конфигурации
|
||||
- Changes: инкрементальная выгрузка изменённых объектов
|
||||
- Partial: выгрузка конкретных объектов из списка
|
||||
- UpdateInfo: обновление только ConfigDumpInfo.xml
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER ConfigDir
|
||||
Каталог для выгрузки конфигурации
|
||||
|
||||
.PARAMETER Mode
|
||||
Режим выгрузки: Full, Changes, Partial, UpdateInfo (по умолчанию Changes)
|
||||
|
||||
.PARAMETER Objects
|
||||
Имена объектов метаданных через запятую (для режима Partial)
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для выгрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Выгрузить все расширения
|
||||
|
||||
.PARAMETER Format
|
||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$ConfigDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Full", "Changes", "Partial", "UpdateInfo")]
|
||||
[string]$Mode = "Changes",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Objects,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-DirNonEmpty {
|
||||
# Postcondition: the platform must have written files into the output directory.
|
||||
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
if ($Mode -eq "Partial" -and -not $Objects) {
|
||||
Write-Host "Error: -Objects required for Partial mode" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Create output dir if needed ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null
|
||||
Write-Host "Created output directory: $ConfigDir"
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_xml_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
$arguments = @("infobase", "config", "export", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
||||
} elseif ($Mode -eq "UpdateInfo") {
|
||||
Write-Host "Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8" -ForegroundColor Red
|
||||
exit 1
|
||||
} elseif ($Mode -eq "Partial") {
|
||||
$objList = @($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
$arguments = @("infobase", "config", "export", "objects") + $objList
|
||||
$arguments += "--out=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
} else {
|
||||
$arguments = @("infobase", "config", "export", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$ConfigDir"
|
||||
}
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration exported successfully to: $ConfigDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not exported" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error exporting configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/DumpConfigToFiles", "`"$ConfigDir`""
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
switch ($Mode) {
|
||||
"Full" {
|
||||
Write-Host "Executing full configuration dump..."
|
||||
}
|
||||
"Changes" {
|
||||
Write-Host "Executing incremental configuration dump..."
|
||||
$arguments += "-update"
|
||||
$arguments += "-force"
|
||||
}
|
||||
"Partial" {
|
||||
Write-Host "Executing partial configuration dump..."
|
||||
$objectList = $Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
|
||||
$listFile = Join-Path $tempDir "dump_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($listFile, $objectList, $utf8Bom)
|
||||
|
||||
$arguments += "-listFile", "`"$listFile`""
|
||||
Write-Host "Objects to dump: $($objectList.Count)"
|
||||
foreach ($obj in $objectList) { Write-Host " $obj" }
|
||||
}
|
||||
"UpdateInfo" {
|
||||
Write-Host "Updating ConfigDumpInfo.xml..."
|
||||
$arguments += "-configDumpInfoOnly"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Dump completed successfully" -ForegroundColor Green
|
||||
Write-Host "Configuration dumped to: $ConfigDir"
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-xml v1.15 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def dir_nonempty(path):
|
||||
"""Postcondition: the platform must have written files into the output directory.
|
||||
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isdir(path) and any(os.scandir(path))
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C configuration to XML files",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump")
|
||||
parser.add_argument(
|
||||
"-Mode",
|
||||
default="Changes",
|
||||
choices=["Full", "Changes", "Partial", "UpdateInfo"],
|
||||
help="Dump mode (default: Changes)",
|
||||
)
|
||||
parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)")
|
||||
parser.add_argument("-Extension", default="", help="Extension name to dump")
|
||||
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
if args.Mode == "Partial" and not args.Objects:
|
||||
print("Error: -Objects required for Partial mode", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Create output dir if needed ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
os.makedirs(args.ConfigDir, exist_ok=True)
|
||||
print(f"Created output directory: {args.ConfigDir}")
|
||||
|
||||
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
||||
if engine == "ibcmd":
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||
elif args.Mode == "UpdateInfo":
|
||||
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif args.Mode == "Partial":
|
||||
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
|
||||
arguments = ["infobase", "config", "export", "objects"] + obj_list
|
||||
arguments += [f"--out={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
else:
|
||||
arguments = ["infobase", "config", "export", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.ConfigDir)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration exported successfully to: {args.ConfigDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_xml_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
if args.Mode == "Full":
|
||||
print("Executing full configuration dump...")
|
||||
elif args.Mode == "Changes":
|
||||
print("Executing incremental configuration dump...")
|
||||
arguments.append("-update")
|
||||
arguments.append("-force")
|
||||
elif args.Mode == "Partial":
|
||||
print("Executing partial configuration dump...")
|
||||
object_list = [obj.strip() for obj in args.Objects.split(",") if obj.strip()]
|
||||
|
||||
list_file = os.path.join(temp_dir, "dump_list.txt")
|
||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(object_list))
|
||||
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
print(f"Objects to dump: {len(object_list)}")
|
||||
for obj in object_list:
|
||||
print(f" {obj}")
|
||||
elif args.Mode == "UpdateInfo":
|
||||
print("Updating ConfigDumpInfo.xml...")
|
||||
arguments.append("-configDumpInfoOnly")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print("Dump completed successfully")
|
||||
print(f"Configuration dumped to: {args.ConfigDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -29,6 +29,7 @@ allowed-tools:
|
||||
```json
|
||||
{
|
||||
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
|
||||
"v8args": ["/UseHwLicenses+"],
|
||||
"databases": [
|
||||
{
|
||||
"id": "dev",
|
||||
@@ -61,6 +62,8 @@ allowed-tools:
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
|
||||
| `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` |
|
||||
| `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) |
|
||||
| `databases` | array | Массив баз данных |
|
||||
| `default` | string | id базы по умолчанию |
|
||||
|
||||
@@ -36,7 +36,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <параметры>
|
||||
python ".agents/skills/db-load-cf/scripts/db-load-cf.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -52,6 +52,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <п
|
||||
| `-InputFile <путь>` | да | Путь к CF-файлу |
|
||||
| `-Extension <имя>` | нет | Загрузить как расширение |
|
||||
| `-AllExtensions` | нет | Загрузить все расширения из архива |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -63,11 +65,11 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <п
|
||||
|
||||
```powershell
|
||||
# Файловая база
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\config.cf"
|
||||
python ".agents/skills/db-load-cf/scripts/db-load-cf.py" -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"
|
||||
python ".agents/skills/db-load-cf/scripts/db-load-cf.py" -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 "МоёРасширение"
|
||||
python ".agents/skills/db-load-cf/scripts/db-load-cf.py" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
```
|
||||
@@ -0,0 +1,497 @@
|
||||
# db-load-cf v1.14 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка конфигурации 1С из CF-файла
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает конфигурацию из бинарного CF-файла в информационную базу.
|
||||
Поддерживает загрузку расширений.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к CF-файлу для загрузки
|
||||
|
||||
.PARAMETER Extension
|
||||
Загрузить как расширение
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения из архива
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "config.cf"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_cf_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config load does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "load", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$InputFile"
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/LoadCfg", "`"$InputFile`""
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-cf v1.14 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C configuration from CF file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.InputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_cf_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/LoadCfg", f'"{args.InputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_cf_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -52,7 +52,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <параметры>
|
||||
python ".agents/skills/db-load-dt/scripts/db-load-dt.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -68,6 +68,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <п
|
||||
| `-InputFile <путь>` | да | Путь к DT-файлу |
|
||||
| `-JobsCount <N>` | нет | Число фоновых заданий загрузки (0 = по числу процессоров) |
|
||||
| `-UnlockCode <код>` | нет | Код разблокировки (`/UC`), если заблокировано начало сеансов |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -80,10 +82,10 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <п
|
||||
|
||||
```powershell
|
||||
# Файловая база
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt"
|
||||
python ".agents/skills/db-load-dt/scripts/db-load-dt.py" -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
|
||||
python ".agents/skills/db-load-dt/scripts/db-load-dt.py" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4
|
||||
```
|
||||
|
||||
## Связанные навыки
|
||||
@@ -0,0 +1,487 @@
|
||||
# db-load-dt v1.13 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка информационной базы 1С из DT-файла
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает информационную базу целиком (конфигурация + данные) из DT-файла.
|
||||
ВНИМАНИЕ: операция полностью перезаписывает базу.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к DT-файлу для загрузки
|
||||
|
||||
.PARAMETER JobsCount
|
||||
Количество фоновых заданий для загрузки (0 = по числу процессоров)
|
||||
|
||||
.PARAMETER UnlockCode
|
||||
Код разблокировки базы (/UC) — если заблокировано начало сеансов
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[int]$JobsCount = 0,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UnlockCode,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_dt_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "restore", "--db-path=$InfoBasePath")
|
||||
if (-not (Test-Path (Join-Path $InfoBasePath "1Cv8.1CD"))) { $arguments += "--create-database" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "$InputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
if ($UnlockCode) { $arguments += "/UC`"$UnlockCode`"" }
|
||||
|
||||
$arguments += "/RestoreIB", "`"$InputFile`""
|
||||
if ($JobsCount -gt 0) { $arguments += "-JobsCount", "$JobsCount" }
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-dt v1.13 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C information base from DT file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-JobsCount", type=int, default=0)
|
||||
parser.add_argument("-UnlockCode", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "restore", f"--db-path={args.InfoBasePath}"]
|
||||
if not os.path.isfile(os.path.join(args.InfoBasePath, "1Cv8.1CD")):
|
||||
arguments.append("--create-database")
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(args.InputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Information base restored successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_dt_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
if args.UnlockCode:
|
||||
arguments.append(f'/UC"{args.UnlockCode}"')
|
||||
|
||||
arguments.extend(["/RestoreIB", f'"{args.InputFile}"'])
|
||||
if args.JobsCount > 0:
|
||||
arguments.extend(["-JobsCount", str(args.JobsCount)])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_dt_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print(f"Information base restored successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -38,7 +38,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <параметры>
|
||||
python ".agents/skills/db-load-git/scripts/db-load-git.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -59,6 +59,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-DryRun` | нет | Только показать что будет загружено (без загрузки) |
|
||||
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -70,8 +72,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <
|
||||
|
||||
```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
|
||||
python ".agents/skills/db-load-git/scripts/db-load-git.py" -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"
|
||||
python ".agents/skills/db-load-git/scripts/db-load-git.py" -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\WS\cfsrc" -Source Commit -CommitRange "HEAD~3..HEAD"
|
||||
```
|
||||
@@ -0,0 +1,738 @@
|
||||
# db-load-git v1.20 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка изменений из Git в базу 1С
|
||||
|
||||
.DESCRIPTION
|
||||
Определяет изменённые файлы конфигурации по данным Git и выполняет
|
||||
частичную загрузку в информационную базу.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER ConfigDir
|
||||
Каталог XML-выгрузки конфигурации (git-репозиторий)
|
||||
|
||||
.PARAMETER Source
|
||||
Источник изменений: All, Staged, Unstaged, Commit (по умолчанию All)
|
||||
|
||||
.PARAMETER CommitRange
|
||||
Диапазон коммитов (для Source=Commit), напр. HEAD~3..HEAD
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для загрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения
|
||||
|
||||
.PARAMETER Format
|
||||
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER DryRun
|
||||
Только показать что будет загружено (без загрузки)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source All
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source Commit -CommitRange "HEAD~3..HEAD"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$ConfigDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("All", "Staged", "Unstaged", "Commit")]
|
||||
[string]$Source = "All",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$CommitRange,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$DryRun,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Helper: map sub-file path (BSL, HTML, etc.) to object XML ---
|
||||
function Get-ObjectXmlFromSubFile {
|
||||
param([string]$RelativePath)
|
||||
|
||||
$parts = $RelativePath -split '[\\/]'
|
||||
if ($parts.Count -ge 2) {
|
||||
return "$($parts[0])/$($parts[1]).xml"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- Resolve V8Path (skip if DryRun) ---
|
||||
if (-not $DryRun) {
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Detect engine + validate connection (skip if DryRun) ---
|
||||
$engine = "1cv8"
|
||||
if (-not $DryRun) {
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Find-SilentRejections {
|
||||
param([string]$LogText)
|
||||
$patterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу',
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||
)
|
||||
$found = @()
|
||||
if ($LogText) {
|
||||
foreach ($line in ($LogText -split "`r?`n")) {
|
||||
foreach ($pat in $patterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$found += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||
return $found
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate Commit mode ---
|
||||
if ($Source -eq "Commit" -and -not $CommitRange) {
|
||||
Write-Host "Error: -CommitRange required for Source=Commit" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Check git ---
|
||||
try {
|
||||
$null = git --version 2>&1
|
||||
} catch {
|
||||
Write-Host "Error: git not found in PATH" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Get changed files from Git ---
|
||||
# Все git-вызовы для сбора путей идут через один хелпер с -c core.quotePath=false,
|
||||
# иначе кириллические пути возвращаются в octal-виде и не распознаются (зеркало run_git в .py).
|
||||
function Invoke-GitLines {
|
||||
param([string[]]$GitArgs)
|
||||
$out = git -c core.quotePath=false @GitArgs 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { return $out }
|
||||
return @()
|
||||
}
|
||||
|
||||
$changedFiles = @()
|
||||
$ConfigDir = (Resolve-Path $ConfigDir).Path.TrimEnd('\')
|
||||
$configDirNormalized = $ConfigDir.Replace('\', '/')
|
||||
|
||||
Push-Location $ConfigDir
|
||||
try {
|
||||
switch ($Source) {
|
||||
"Staged" {
|
||||
Write-Host "Getting staged changes..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
|
||||
}
|
||||
"Unstaged" {
|
||||
Write-Host "Getting unstaged changes..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
|
||||
}
|
||||
"Commit" {
|
||||
Write-Host "Getting changes from $CommitRange..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative', $CommitRange)
|
||||
}
|
||||
"All" {
|
||||
Write-Host "Getting all uncommitted changes..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$changedFiles = $changedFiles | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
|
||||
if ($changedFiles.Count -eq 0) {
|
||||
Write-Host "No changes found"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Git changes detected: $($changedFiles.Count) files"
|
||||
|
||||
# --- Filter and map to config files ---
|
||||
$configFiles = @()
|
||||
$supportSkipped = @()
|
||||
|
||||
foreach ($file in $changedFiles) {
|
||||
$file = $file.Trim().Replace('\', '/')
|
||||
if ([string]::IsNullOrWhiteSpace($file)) { continue }
|
||||
|
||||
# Skip service files (not partially loadable). Support-state files are tracked
|
||||
# to warn the user: support changes apply only via a full load.
|
||||
if ($file -match 'ParentConfigurations\.bin$') { $supportSkipped += $file; continue }
|
||||
if ($file -eq "ConfigDumpInfo.xml" -or $file -match '(^|/)ConfigDumpInfo\.xml$') { continue }
|
||||
|
||||
$fullPath = Join-Path $ConfigDir $file
|
||||
|
||||
if ($file -match '\.xml$') {
|
||||
# XML file — add directly if exists
|
||||
if (Test-Path $fullPath) {
|
||||
if ($configFiles -notcontains $file) {
|
||||
$configFiles += $file
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Non-XML (BSL, HTML, etc.) — map to parent object XML + include all Ext/ files
|
||||
$objectXml = Get-ObjectXmlFromSubFile -RelativePath $file
|
||||
if ($objectXml) {
|
||||
$fullXmlPath = Join-Path $ConfigDir $objectXml
|
||||
if (Test-Path $fullXmlPath) {
|
||||
if ($configFiles -notcontains $objectXml) {
|
||||
$configFiles += $objectXml
|
||||
}
|
||||
if ((Test-Path $fullPath) -and $configFiles -notcontains $file) {
|
||||
$configFiles += $file
|
||||
}
|
||||
|
||||
# Add all files from Ext/ directory of the object
|
||||
$parts = $file -split '[\\/]'
|
||||
if ($parts.Count -ge 2) {
|
||||
$extDir = Join-Path (Join-Path $ConfigDir $parts[0]) "$($parts[1])\Ext"
|
||||
if (Test-Path $extDir) {
|
||||
Get-ChildItem -Path $extDir -Recurse -File | ForEach-Object {
|
||||
$extRelPath = $_.FullName.Replace("$ConfigDir\", '').Replace('\', '/')
|
||||
if ($configFiles -notcontains $extRelPath) {
|
||||
$configFiles += $extRelPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($supportSkipped.Count -gt 0) {
|
||||
Write-Host "[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):" -ForegroundColor Yellow
|
||||
foreach ($sf in $supportSkipped) { Write-Host " - $sf" -ForegroundColor Yellow }
|
||||
Write-Host " Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full)." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($configFiles.Count -eq 0) {
|
||||
Write-Host "No configuration files found in changes"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Files for loading: $($configFiles.Count)"
|
||||
foreach ($f in $configFiles) { Write-Host " $f" }
|
||||
|
||||
# --- DryRun: stop here ---
|
||||
if ($DryRun) {
|
||||
Write-Host ""
|
||||
Write-Host "DryRun mode - no changes applied"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_git_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; import specific files) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "import", "files") + $configFiles
|
||||
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "Error loading changes (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Changes loaded successfully ($($configFiles.Count) files)" -ForegroundColor Green
|
||||
Write-PlatformOutput $output
|
||||
if ($UpdateDB) {
|
||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||
if ($Password) { $applyArgs += "--password=$Password" }
|
||||
$applyArgs += "--data=$tempDir"
|
||||
$applyArgs += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||
$applyOut = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Write list file (UTF-8 with BOM) ---
|
||||
$listFile = Join-Path $tempDir "load_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($listFile, $configFiles, $utf8Bom)
|
||||
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||
$arguments += "-listFile", "`"$listFile`""
|
||||
$arguments += "-Format", $Format
|
||||
$arguments += "-partial"
|
||||
$arguments += "-updateConfigDumpInfo"
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- UpdateDB ---
|
||||
if ($UpdateDB) {
|
||||
$arguments += "/UpdateDBCfg"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host ""
|
||||
Write-Host "Executing partial configuration load..."
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
Write-Host ""
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
$logContent = $null
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-git v1.20 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def get_object_xml_from_subfile(relative_path):
|
||||
"""Map sub-file path (BSL, HTML, etc.) to object XML path."""
|
||||
parts = re.split(r"[\\/]", relative_path)
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0]}/{parts[1]}.xml"
|
||||
return None
|
||||
|
||||
|
||||
def run_git(config_dir, git_args):
|
||||
"""Run a git command in config_dir and return output lines on success."""
|
||||
result = subprocess.run(
|
||||
["git", "-c", "core.quotePath=false"] + git_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=config_dir,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return [line for line in result.stdout.splitlines() if line.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load Git changes into 1C database",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration (git repo)")
|
||||
parser.add_argument(
|
||||
"-Source",
|
||||
default="All",
|
||||
choices=["All", "Staged", "Unstaged", "Commit"],
|
||||
help="Change source (default: All)",
|
||||
)
|
||||
parser.add_argument("-CommitRange", default="", help="Commit range (for Source=Commit), e.g. HEAD~3..HEAD")
|
||||
parser.add_argument("-Extension", default="", help="Extension name to load")
|
||||
parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="File format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)")
|
||||
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
parser.add_argument("-StrictLog", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
|
||||
# --- Resolve V8Path (skip if DryRun) ---
|
||||
v8path = None
|
||||
if not args.DryRun:
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
# --- Detect engine + validate connection (skip if DryRun) ---
|
||||
engine = "1cv8"
|
||||
if not args.DryRun:
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Commit mode ---
|
||||
if args.Source == "Commit" and not args.CommitRange:
|
||||
print("Error: -CommitRange required for Source=Commit", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Check git ---
|
||||
try:
|
||||
subprocess.run(["git", "--version"], capture_output=True, text=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("Error: git not found in PATH", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Get changed files from Git ---
|
||||
changed_files = []
|
||||
|
||||
if args.Source == "Staged":
|
||||
print("Getting staged changes...")
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--cached", "--name-only", "--relative"])
|
||||
elif args.Source == "Unstaged":
|
||||
print("Getting unstaged changes...")
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative"])
|
||||
changed_files += run_git(args.ConfigDir, ["ls-files", "--others", "--exclude-standard"])
|
||||
elif args.Source == "Commit":
|
||||
print(f"Getting changes from {args.CommitRange}...")
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative", args.CommitRange])
|
||||
elif args.Source == "All":
|
||||
print("Getting all uncommitted changes...")
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--cached", "--name-only", "--relative"])
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative"])
|
||||
changed_files += run_git(args.ConfigDir, ["ls-files", "--others", "--exclude-standard"])
|
||||
|
||||
# Deduplicate and filter blanks
|
||||
changed_files = list(dict.fromkeys(f for f in changed_files if f.strip()))
|
||||
|
||||
if len(changed_files) == 0:
|
||||
print("No changes found")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Git changes detected: {len(changed_files)} files")
|
||||
|
||||
# --- Filter and map to config files ---
|
||||
config_files = []
|
||||
support_skipped = []
|
||||
|
||||
for file in changed_files:
|
||||
file = file.strip().replace("\\", "/")
|
||||
if not file:
|
||||
continue
|
||||
|
||||
# Skip service files (not partially loadable). Support-state files are
|
||||
# tracked to warn: support changes apply only via a full load.
|
||||
if file.endswith("ParentConfigurations.bin"):
|
||||
support_skipped.append(file)
|
||||
continue
|
||||
if file == "ConfigDumpInfo.xml" or file.endswith("/ConfigDumpInfo.xml"):
|
||||
continue
|
||||
|
||||
full_path = os.path.join(args.ConfigDir, file)
|
||||
|
||||
if file.endswith(".xml"):
|
||||
# XML file — add directly if exists
|
||||
if os.path.exists(full_path):
|
||||
if file not in config_files:
|
||||
config_files.append(file)
|
||||
else:
|
||||
# Non-XML (BSL, HTML, etc.) — map to parent object XML + include all Ext/ files
|
||||
object_xml = get_object_xml_from_subfile(file)
|
||||
if object_xml:
|
||||
full_xml_path = os.path.join(args.ConfigDir, object_xml)
|
||||
if os.path.exists(full_xml_path):
|
||||
if object_xml not in config_files:
|
||||
config_files.append(object_xml)
|
||||
if os.path.exists(full_path) and file not in config_files:
|
||||
config_files.append(file)
|
||||
|
||||
# Add all files from Ext/ directory of the object
|
||||
parts = re.split(r"[\\/]", file)
|
||||
if len(parts) >= 2:
|
||||
ext_dir = os.path.join(args.ConfigDir, parts[0], parts[1], "Ext")
|
||||
if os.path.isdir(ext_dir):
|
||||
for root, dirs, files in os.walk(ext_dir):
|
||||
for fname in files:
|
||||
abs_path = os.path.join(root, fname)
|
||||
rel_path = os.path.relpath(abs_path, args.ConfigDir).replace("\\", "/")
|
||||
if rel_path not in config_files:
|
||||
config_files.append(rel_path)
|
||||
|
||||
if support_skipped:
|
||||
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):", file=sys.stderr)
|
||||
for sf in support_skipped:
|
||||
print(f" - {sf}", file=sys.stderr)
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).", file=sys.stderr)
|
||||
|
||||
if len(config_files) == 0:
|
||||
print("No configuration files found in changes")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Files for loading: {len(config_files)}")
|
||||
for f in config_files:
|
||||
print(f" {f}")
|
||||
|
||||
# --- DryRun: stop here ---
|
||||
if args.DryRun:
|
||||
print("")
|
||||
print("DryRun mode - no changes applied")
|
||||
sys.exit(0)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_git_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch (file infobase only; import specific files) ---
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "import", "files"] + config_files
|
||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
print(f"Changes loaded successfully ({len(config_files)} files)")
|
||||
exit_code = 0
|
||||
if args.UpdateDB:
|
||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.UserName:
|
||||
apply_args.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
apply_args.append(f"--password={args.Password}")
|
||||
apply_args.append(f"--data={ib_data}")
|
||||
apply_args.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
|
||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||
exit_code = ar.returncode
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Write list file (UTF-8 with BOM) ---
|
||||
list_file = os.path.join(temp_dir, "load_list.txt")
|
||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(config_files))
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- UpdateDB ---
|
||||
if args.UpdateDB:
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print("")
|
||||
print("Executing partial configuration load...")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
print("")
|
||||
if exit_code == 0:
|
||||
print("Load completed successfully")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
silent_failures = find_silent_rejections(log_content)
|
||||
if silent_failures:
|
||||
print(
|
||||
f"[warning] platform reported success, but the log contains "
|
||||
f"{len(silent_failures)} problem(s):"
|
||||
)
|
||||
for line in silent_failures:
|
||||
print(f" {line}")
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -38,7 +38,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" <параметры>
|
||||
python ".agents/skills/db-load-xml/scripts/db-load-xml.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -59,6 +59,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" <
|
||||
| `-AllExtensions` | нет | Загрузить все расширения |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -88,14 +90,14 @@ Documents/Заказ/Forms/ФормаДокумента.xml
|
||||
|
||||
```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
|
||||
python ".agents/skills/db-load-xml/scripts/db-load-xml.py" -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"
|
||||
python ".agents/skills/db-load-xml/scripts/db-load-xml.py" -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 "МоёРасширение"
|
||||
python ".agents/skills/db-load-xml/scripts/db-load-xml.py" -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
|
||||
python ".agents/skills/db-load-xml/scripts/db-load-xml.py" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full -UpdateDB
|
||||
```
|
||||
@@ -0,0 +1,679 @@
|
||||
# db-load-xml v1.21 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка конфигурации 1С из XML-файлов
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает конфигурацию в информационную базу из XML-файлов.
|
||||
Поддерживает полную и частичную загрузку.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER ConfigDir
|
||||
Каталог XML-исходников конфигурации
|
||||
|
||||
.PARAMETER Mode
|
||||
Режим загрузки: Full или Partial (по умолчанию Full)
|
||||
|
||||
.PARAMETER Files
|
||||
Относительные пути файлов через запятую (для режима Partial)
|
||||
|
||||
.PARAMETER ListFile
|
||||
Путь к файлу со списком файлов (альтернатива -Files, для режима Partial)
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для загрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения
|
||||
|
||||
.PARAMETER Format
|
||||
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$ConfigDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Full", "Partial")]
|
||||
[string]$Mode = "Full",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Files,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ListFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
$ListFile = ConvertTo-CleanPath $ListFile '-ListFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Find-SilentRejections {
|
||||
param([string]$LogText)
|
||||
$patterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу',
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||
)
|
||||
$found = @()
|
||||
if ($LogText) {
|
||||
foreach ($line in ($LogText -split "`r?`n")) {
|
||||
foreach ($pat in $patterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$found += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||
return $found
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate config dir ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) {
|
||||
Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_xml_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
||||
} elseif ($Mode -eq "Partial" -or $Files -or $ListFile) {
|
||||
# partial: import specific files (relative to ConfigDir)
|
||||
$fileList = @()
|
||||
if ($ListFile) {
|
||||
if (-not (Test-Path $ListFile)) {
|
||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$fileList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
} elseif ($Files) {
|
||||
$fileList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
if ($fileList.Count -eq 0) {
|
||||
Write-Host "Error: -Files or -ListFile required for partial import" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "import", "files") + $fileList
|
||||
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
} else {
|
||||
$arguments = @("infobase", "config", "import", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$ConfigDir"
|
||||
}
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "Error loading configuration from files (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
|
||||
Write-PlatformOutput $output
|
||||
|
||||
if ($UpdateDB) {
|
||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||
if ($Password) { $applyArgs += "--password=$Password" }
|
||||
$applyArgs += "--data=$tempDir"
|
||||
$applyArgs += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||
$applyOut = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||
|
||||
if ($Mode -eq "Full") {
|
||||
Write-Host "Executing full configuration load..."
|
||||
} else {
|
||||
Write-Host "Executing partial configuration load..."
|
||||
|
||||
# Build list file
|
||||
$rawList = @()
|
||||
if ($ListFile) {
|
||||
if (-not (Test-Path $ListFile)) {
|
||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$rawList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
} else {
|
||||
$rawList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
|
||||
# Support-state service files are NOT partially loadable — exclude with a hint.
|
||||
$supportRe = 'ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$'
|
||||
$supportFiles = @($rawList | Where-Object { $_ -match $supportRe })
|
||||
$fileList = @($rawList | Where-Object { $_ -notmatch $supportRe })
|
||||
if ($supportFiles.Count -gt 0) {
|
||||
Write-Host "[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):" -ForegroundColor Yellow
|
||||
foreach ($sf in $supportFiles) { Write-Host " - $sf" -ForegroundColor Yellow }
|
||||
Write-Host " Смена состояния поддержки применяется только полной загрузкой: -Mode Full." -ForegroundColor Yellow
|
||||
}
|
||||
if ($fileList.Count -eq 0) {
|
||||
Write-Host "Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$generatedListFile = Join-Path $tempDir "load_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($generatedListFile, $fileList, $utf8Bom)
|
||||
Write-Host "Files to load: $($fileList.Count)"
|
||||
foreach ($f in $fileList) { Write-Host " $f" }
|
||||
|
||||
$arguments += "-listFile", "`"$generatedListFile`""
|
||||
$arguments += "-partial"
|
||||
$arguments += "-updateConfigDumpInfo"
|
||||
}
|
||||
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- UpdateDB ---
|
||||
if ($UpdateDB) {
|
||||
$arguments += "/UpdateDBCfg"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Read log ---
|
||||
$logContent = $null
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# --- Scan log for silent rejections ---
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
|
||||
# --- Result ---
|
||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||
# rejection warnings) is always printed to stdout for visibility. With -StrictLog,
|
||||
# elevate exit code to 1 when rejection patterns are found even if platform said 0.
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-xml v1.21 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C configuration from XML files",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
|
||||
parser.add_argument(
|
||||
"-Mode",
|
||||
default="Full",
|
||||
choices=["Full", "Partial"],
|
||||
help="Load mode (default: Full)",
|
||||
)
|
||||
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
|
||||
parser.add_argument("-ListFile", default="", help="Path to file list (alternative to -Files, for Partial mode)")
|
||||
parser.add_argument("-Extension", default="", help="Extension name to load")
|
||||
parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="File format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
||||
parser.add_argument(
|
||||
"-StrictLog",
|
||||
action="store_true",
|
||||
help="Treat silent rejection warnings in the log as errors (elevate exit code to 1)",
|
||||
)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
args.ListFile = clean_path(args.ListFile, "-ListFile")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
if args.Mode == "Partial" and not args.Files and not args.ListFile:
|
||||
print("Error: -Files or -ListFile required for Partial mode", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
||||
if engine == "ibcmd":
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||
elif args.Mode == "Partial" or args.Files or args.ListFile:
|
||||
# partial: import specific files (relative to ConfigDir)
|
||||
if args.ListFile:
|
||||
if not os.path.isfile(args.ListFile):
|
||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||
file_list = [ln.strip() for ln in f if ln.strip()]
|
||||
elif args.Files:
|
||||
file_list = [p.strip() for p in args.Files.split(",") if p.strip()]
|
||||
else:
|
||||
file_list = []
|
||||
if not file_list:
|
||||
print("Error: -Files or -ListFile required for partial import", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "import", "files"] + file_list
|
||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
else:
|
||||
arguments = ["infobase", "config", "import", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.ConfigDir)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
||||
exit_code = 0
|
||||
if args.UpdateDB:
|
||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.UserName:
|
||||
apply_args.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
apply_args.append(f"--password={args.Password}")
|
||||
apply_args.append(f"--data={ib_data}")
|
||||
apply_args.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
|
||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||
exit_code = ar.returncode
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_xml_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
|
||||
if args.Mode == "Full":
|
||||
print("Executing full configuration load...")
|
||||
else:
|
||||
print("Executing partial configuration load...")
|
||||
|
||||
# Build list file
|
||||
if args.ListFile:
|
||||
if not os.path.isfile(args.ListFile):
|
||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||
raw_list = [ln.strip() for ln in f if ln.strip()]
|
||||
else:
|
||||
raw_list = [f.strip() for f in args.Files.split(",") if f.strip()]
|
||||
|
||||
# Support-state service files are NOT partially loadable — exclude with a hint.
|
||||
support_re = re.compile(r"ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$")
|
||||
support_files = [x for x in raw_list if support_re.search(x)]
|
||||
file_list = [x for x in raw_list if not support_re.search(x)]
|
||||
if support_files:
|
||||
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):", file=sys.stderr)
|
||||
for sf in support_files:
|
||||
print(f" - {sf}", file=sys.stderr)
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.", file=sys.stderr)
|
||||
if not file_list:
|
||||
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
generated_list_file = os.path.join(temp_dir, "load_list.txt")
|
||||
with open(generated_list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(file_list))
|
||||
print(f"Files to load: {len(file_list)}")
|
||||
for fl in file_list:
|
||||
print(f" {fl}")
|
||||
|
||||
arguments += ["-listFile", f'"{generated_list_file}"']
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- UpdateDB ---
|
||||
if args.UpdateDB:
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Read log ---
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
except Exception:
|
||||
log_content = ""
|
||||
|
||||
# --- Scan log for silent rejections ---
|
||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
||||
silent_failures = find_silent_rejections(log_content)
|
||||
|
||||
# --- Result ---
|
||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||
# rejection warnings) is always printed to stdout for visibility. With -StrictLog,
|
||||
# elevate exit code to 1 when rejection patterns are found even if platform said 0.
|
||||
if exit_code == 0:
|
||||
print("Load completed successfully")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
|
||||
print_platform_output(result)
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||
# Поток — stdout, как у PS1-порта: предупреждение относится к содержимому загрузки, а не к
|
||||
# отказу навыка, и при code 0 остаётся предупреждением. Раньше py писал его в stderr —
|
||||
# наблюдаемое поведение портов расходилось, и один кейс не мог проверить оба.
|
||||
if silent_failures:
|
||||
print(
|
||||
f"[warning] platform reported success, but the log contains "
|
||||
f"{len(silent_failures)} problem(s):"
|
||||
)
|
||||
for f in silent_failures:
|
||||
print(f" {f}")
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -36,7 +36,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <параметры>
|
||||
python ".agents/skills/db-run/scripts/db-run.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -52,6 +52,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <пар
|
||||
| `-Execute <файл.epf>` | нет | Запуск внешней обработки сразу после старта |
|
||||
| `-CParam <строка>` | нет | Параметр запуска (/C) |
|
||||
| `-URL <ссылка>` | нет | Навигационная ссылка (формат `e1cib/...`) |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -63,14 +64,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <пар
|
||||
|
||||
```powershell
|
||||
# Простой запуск
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin"
|
||||
python ".agents/skills/db-run/scripts/db-run.py" -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"
|
||||
python ".agents/skills/db-run/scripts/db-run.py" -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/Справочник.Номенклатура"
|
||||
python ".agents/skills/db-run/scripts/db-run.py" -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 "ЗапуститьОбновление"
|
||||
python ".agents/skills/db-run/scripts/db-run.py" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -CParam "ЗапуститьОбновление"
|
||||
```
|
||||
@@ -0,0 +1,369 @@
|
||||
# db-run v1.8 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Запуск 1С:Предприятие
|
||||
|
||||
.DESCRIPTION
|
||||
Запускает информационную базу в режиме 1С:Предприятие (пользовательский режим).
|
||||
Запуск в фоне — не ждёт завершения процесса.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER Execute
|
||||
Путь к внешней обработке для запуска
|
||||
|
||||
.PARAMETER CParam
|
||||
Параметр запуска (/C)
|
||||
|
||||
.PARAMETER URL
|
||||
Навигационная ссылка (e1cib/...)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -Execute "C:\epf\МояОбработка.epf"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -CParam "ЗапуститьОбновление"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Execute,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$CParam,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$URL,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$Execute = ConvertTo-CleanPath $Execute '-Execute'
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Resolve additional arguments ---
|
||||
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
|
||||
$engine = "1cv8"
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '/Execute' = '-Execute'; '/C' = '-CParam'; '/URL' = '-URL' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
function Format-ArgToken {
|
||||
# ShellExecute re-joins the argument string, so quote each extra token that needs it.
|
||||
param([string]$Token)
|
||||
if ($Token -match '[\s"]') { return ' "' + ($Token -replace '"', '\"') + '"' }
|
||||
return " $Token"
|
||||
}
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Build arguments as single string ---
|
||||
# Note: Start-Process without -NoNewWindow uses ShellExecute.
|
||||
# Passing ArgumentList as array can corrupt Cyrillic when ShellExecute
|
||||
# re-joins elements. Single string avoids this.
|
||||
$argString = "ENTERPRISE"
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$argString += " /S `"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$argString += " /F `"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $argString += " /N`"$UserName`"" }
|
||||
if ($Password) { $argString += " /P`"$Password`"" }
|
||||
|
||||
# --- Optional params ---
|
||||
if ($Execute) {
|
||||
$ext = [System.IO.Path]::GetExtension($Execute).ToLower()
|
||||
if ($ext -eq ".erf") {
|
||||
Write-Host "[WARN] /Execute не поддерживает ERF-файлы (внешние отчёты)." -ForegroundColor Yellow
|
||||
Write-Host " Откройте отчёт через «Файл -> Открыть»: $Execute" -ForegroundColor Yellow
|
||||
Write-Host " Запускаю базу без /Execute." -ForegroundColor Yellow
|
||||
$Execute = ""
|
||||
}
|
||||
}
|
||||
if ($Execute) {
|
||||
$argString += " /Execute `"$Execute`""
|
||||
}
|
||||
if ($CParam) {
|
||||
$argString += " /C `"$CParam`""
|
||||
}
|
||||
if ($URL) {
|
||||
$argString += " /URL `"$URL`""
|
||||
}
|
||||
|
||||
$argString += " /DisableStartupDialogs"
|
||||
|
||||
# The display string is built from the same tokens with secret-prone values redacted.
|
||||
$displayString = $argString
|
||||
foreach ($tok in $extraArgs) { $argString += (Format-ArgToken $tok) }
|
||||
foreach ($tok in (Format-ArgsForDisplay $extraArgs $engine)) { $displayString += (Format-ArgToken $tok) }
|
||||
|
||||
# --- Execute (background) ---
|
||||
# Redact the password/user before printing the command line — never leak secrets.
|
||||
$displayArg = Protect-Secrets $displayString @($Password, $UserName)
|
||||
Write-Host "Running: 1cv8.exe $displayArg"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $argString -PassThru
|
||||
|
||||
# --- Bounded early-exit check ---
|
||||
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||
# report that honestly instead of a blind "launched".
|
||||
$deadline = (Get-Date).AddMilliseconds(1500)
|
||||
while ((Get-Date) -lt $deadline -and -not $proc.HasExited) {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ($proc.HasExited) {
|
||||
Write-Host "Error: 1C:Enterprise exited immediately (code: $($proc.ExitCode))" -ForegroundColor Red
|
||||
if ($proc.ExitCode -ne 0) { exit $proc.ExitCode } else { exit 1 }
|
||||
}
|
||||
Write-Host "PID: $($proc.Id)"
|
||||
Write-Host "1C:Enterprise launched" -ForegroundColor Green
|
||||
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-run v1.8 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Launch 1C:Enterprise",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-Execute", default="")
|
||||
parser.add_argument("-CParam", default="")
|
||||
parser.add_argument("-URL", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
args.Execute = clean_path(args.Execute, "-Execute")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
# --- Resolve additional arguments ---
|
||||
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
|
||||
engine = "1cv8"
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"/Execute": "-Execute",
|
||||
"/C": "-CParam",
|
||||
"/URL": "-URL",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["ENTERPRISE"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
# --- Optional params ---
|
||||
execute = args.Execute
|
||||
if execute:
|
||||
ext = os.path.splitext(execute)[1].lower()
|
||||
if ext == ".erf":
|
||||
print("[WARN] /Execute does not support ERF files (external reports).")
|
||||
print(f" Open the report via File -> Open: {execute}")
|
||||
print(" Launching database without /Execute.")
|
||||
execute = ""
|
||||
|
||||
if execute:
|
||||
arguments.extend(["/Execute", execute])
|
||||
if args.CParam:
|
||||
arguments.extend(["/C", args.CParam])
|
||||
if args.URL:
|
||||
arguments.extend(["/URL", args.URL])
|
||||
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(extra_args)
|
||||
|
||||
# --- Execute (background) ---
|
||||
# Redact the password/user before printing the command line — never leak secrets.
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
proc = subprocess.Popen([v8path] + arguments)
|
||||
|
||||
# --- Bounded early-exit check ---
|
||||
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||
# report that honestly instead of a blind "launched".
|
||||
deadline = time.monotonic() + 1.5
|
||||
while time.monotonic() < deadline and proc.poll() is None:
|
||||
time.sleep(0.2)
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
print(f"Error: 1C:Enterprise exited immediately (code: {rc})", file=sys.stderr)
|
||||
sys.exit(rc if rc and rc > 0 else 1)
|
||||
print(f"PID: {proc.pid}")
|
||||
print("1C:Enterprise launched")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -35,7 +35,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <параметры>
|
||||
python ".agents/skills/db-update/scripts/db-update.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -53,6 +53,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
|
||||
| `-Dynamic <+/->` | нет | `+` — динамическое обновление, `-` — отключить |
|
||||
| `-Server` | нет | Обновление на стороне сервера |
|
||||
| `-WarningsAsErrors` | нет | Предупреждения считать ошибками |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -76,11 +78,11 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
|
||||
|
||||
```powershell
|
||||
# Обычное обновление (файловая база)
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin"
|
||||
python ".agents/skills/db-update/scripts/db-update.py" -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 "+"
|
||||
python ".agents/skills/db-update/scripts/db-update.py" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -Dynamic "+"
|
||||
|
||||
# Обновление расширения
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Extension "МоёРасширение"
|
||||
python ".agents/skills/db-update/scripts/db-update.py" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Extension "МоёРасширение"
|
||||
```
|
||||
@@ -0,0 +1,567 @@
|
||||
# db-update v1.15 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Обновление конфигурации базы данных 1С
|
||||
|
||||
.DESCRIPTION
|
||||
Применяет изменения основной конфигурации к конфигурации базы данных.
|
||||
Поддерживает динамическое обновление, обновление расширений.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для обновления
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Обновить все расширения
|
||||
|
||||
.PARAMETER Dynamic
|
||||
Динамическое обновление: "+" включить, "-" отключить
|
||||
|
||||
.PARAMETER Server
|
||||
Обновление на стороне сервера
|
||||
|
||||
.PARAMETER WarningsAsErrors
|
||||
Предупреждения считать ошибками
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB" -Dynamic "+" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("+", "-")]
|
||||
[string]$Dynamic,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$Server,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$WarningsAsErrors,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Find-SilentRejections {
|
||||
param([string]$LogText)
|
||||
$patterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу',
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||
)
|
||||
$found = @()
|
||||
if ($LogText) {
|
||||
foreach ($line in ($LogText -split "`r?`n")) {
|
||||
foreach ($pat in $patterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$found += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||
return $found
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_update_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config apply does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($Dynamic -eq "+") { $arguments += "--dynamic=auto" }
|
||||
elseif ($Dynamic -eq "-") { $arguments += "--dynamic=disable" }
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/UpdateDBCfg"
|
||||
|
||||
# --- Options ---
|
||||
if ($Dynamic) {
|
||||
$arguments += "-Dynamic$Dynamic"
|
||||
}
|
||||
if ($Server) {
|
||||
$arguments += "-Server"
|
||||
}
|
||||
if ($WarningsAsErrors) {
|
||||
$arguments += "-WarningsAsErrors"
|
||||
}
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "update_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
$logContent = $null
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-update v1.15 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Update 1C database configuration",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
|
||||
parser.add_argument("-Server", action="store_true")
|
||||
parser.add_argument("-WarningsAsErrors", action="store_true")
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
parser.add_argument("-StrictLog", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.Dynamic == "+":
|
||||
arguments.append("--dynamic=auto")
|
||||
elif args.Dynamic == "-":
|
||||
arguments.append("--dynamic=disable")
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_update_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
# --- Options ---
|
||||
if args.Dynamic:
|
||||
arguments.append(f"-Dynamic{args.Dynamic}")
|
||||
if args.Server:
|
||||
arguments.append("-Server")
|
||||
if args.WarningsAsErrors:
|
||||
arguments.append("-WarningsAsErrors")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "update_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
silent_failures = find_silent_rejections(log_content)
|
||||
if silent_failures:
|
||||
print(
|
||||
f"[warning] platform reported success, but the log contains "
|
||||
f"{len(silent_failures)} problem(s):"
|
||||
)
|
||||
for line in silent_failures:
|
||||
print(f" {line}")
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -40,7 +40,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <параметры>
|
||||
python ".agents/skills/epf-build/scripts/epf-build.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -55,6 +55,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <п
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному EPF/ERF-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
|
||||
|
||||
@@ -62,8 +64,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <п
|
||||
|
||||
```powershell
|
||||
# Сборка обработки (файловая база)
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf"
|
||||
python ".agents/skills/epf-build/scripts/epf-build.py" -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"
|
||||
python ".agents/skills/epf-build/scripts/epf-build.py" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf"
|
||||
```
|
||||
@@ -0,0 +1,510 @@
|
||||
# epf-build v1.13 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Сборка внешней обработки/отчёта 1С из XML-исходников
|
||||
|
||||
.DESCRIPTION
|
||||
Собирает EPF/ERF-файл из XML-исходников с помощью платформы 1С.
|
||||
Общий скрипт для epf-build и erf-build.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER SourceFile
|
||||
Путь к корневому XML-файлу исходников
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному EPF/ERF-файлу
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МояОбработка.xml" -OutputFile "build\МояОбработка.epf"
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$SourceFile,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$SourceFile = ConvertTo-CleanPath $SourceFile '-SourceFile'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
if ($engine -eq "ibcmd" -and $InfoBaseServer -and $InfoBaseRef) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Auto-create stub database if no connection specified ---
|
||||
$autoCreatedBase = $null
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
$sourceDir = Split-Path $SourceFile -Parent
|
||||
$autoBasePath = Join-Path $env:TEMP "epf_stub_db_$(Get-Random)"
|
||||
$stubScript = Join-Path $PSScriptRoot "stub-db-create.ps1"
|
||||
Write-Host "No database specified. Creating temporary stub database..."
|
||||
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
|
||||
# explicit ones are forwarded: the stub reads .v8-project.json itself.
|
||||
# Invoked via -Command, not -File: -File takes the tail literally, so an array
|
||||
# parameter would arrive as a single comma-glued token.
|
||||
$q = { param($s) "'" + ($s -replace "'", "''") + "'" }
|
||||
$stubCmd = "& $(& $q $stubScript) -SourceDir $(& $q $sourceDir) -V8Path $(& $q $V8Path) -TempBasePath $(& $q $autoBasePath)"
|
||||
if ($AdditionalV8Arguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalV8Arguments " + (($AdditionalV8Arguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
if ($AdditionalIbcmdArguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalIbcmdArguments " + (($AdditionalIbcmdArguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
$stubProc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -Command `"$stubCmd`"" -NoNewWindow -Wait -PassThru
|
||||
if ($stubProc.ExitCode -ne 0) {
|
||||
Write-Host "Error: failed to create stub database" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$InfoBasePath = $autoBasePath
|
||||
$autoCreatedBase = $autoBasePath
|
||||
}
|
||||
|
||||
# --- Validate source file ---
|
||||
if (-not (Test-Path $SourceFile)) {
|
||||
Write-Host "Error: source file not found: $SourceFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "epf_build_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch: build EPF/ERF via config import --out ---
|
||||
$srcDir = Split-Path $SourceFile -Parent
|
||||
$arguments = @("infobase", "config", "import", "$srcDir", "--out=$OutputFile", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "External data processor/report built successfully: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error building external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/LoadExternalDataProcessorOrReportFromFiles", "`"$SourceFile`"", "`"$OutputFile`""
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "build_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Build completed successfully: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error building (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($autoCreatedBase -and (Test-Path $autoCreatedBase)) {
|
||||
Remove-Item -Path $autoCreatedBase -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-build v1.13 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build external data processor or report (EPF/ERF) from XML sources",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-SourceFile", required=True, help="Path to root XML source file")
|
||||
parser.add_argument("-OutputFile", required=True, help="Path to output EPF/ERF file")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.SourceFile = clean_path(args.SourceFile, "-SourceFile")
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Auto-create stub database if no connection specified ---
|
||||
auto_created_base = None
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
source_dir = os.path.dirname(os.path.abspath(args.SourceFile))
|
||||
auto_base_path = os.path.join(tempfile.gettempdir(), f"epf_stub_db_{random.randint(0, 999999)}")
|
||||
stub_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stub-db-create.py")
|
||||
print("No database specified. Creating temporary stub database...")
|
||||
stub_cmd = [sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path,
|
||||
"-TempBasePath", auto_base_path]
|
||||
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
|
||||
# explicit ones are forwarded: the stub reads .v8-project.json itself.
|
||||
if v8_extra:
|
||||
stub_cmd += ["-AdditionalV8Arguments"] + list(v8_extra)
|
||||
if ibcmd_extra:
|
||||
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
|
||||
result = subprocess.run(stub_cmd, capture_output=False)
|
||||
if result.returncode != 0:
|
||||
print("Error: failed to create stub database", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
args.InfoBasePath = auto_base_path
|
||||
auto_created_base = auto_base_path
|
||||
|
||||
# --- Validate source file ---
|
||||
if not os.path.isfile(args.SourceFile):
|
||||
print(f"Error: source file not found: {args.SourceFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
out_dir = os.path.dirname(args.OutputFile)
|
||||
if out_dir and not os.path.exists(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"epf_build_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch: build EPF/ERF via config import --out ---
|
||||
src_dir = os.path.dirname(os.path.abspath(args.SourceFile))
|
||||
arguments = ["infobase", "config", "import", src_dir, f"--out={args.OutputFile}", f"--db-path={args.InfoBasePath}"]
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"External data processor/report built successfully: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadExternalDataProcessorOrReportFromFiles", f'"{args.SourceFile}"', f'"{args.OutputFile}"']
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "build_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Build completed successfully: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error building (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
if auto_created_base and os.path.exists(auto_created_base):
|
||||
shutil.rmtree(auto_created_base, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+289
-14
@@ -1,4 +1,4 @@
|
||||
# stub-db-create v1.2 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -7,20 +7,191 @@ param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$V8Path,
|
||||
|
||||
[string]$TempBasePath
|
||||
[string]$TempBasePath,
|
||||
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
$SourceDir = ConvertTo-CleanPath $SourceDir '-SourceDir'
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$TempBasePath = ConvertTo-CleanPath $TempBasePath '-TempBasePath'
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 1. Scan XML files for reference types ---
|
||||
|
||||
$typeMap = @{} # MetadataType -> @(Name1, Name2, ...)
|
||||
|
||||
# Версия формата заглушечной конфигурации. Платформа грузит формат не новее себя, поэтому зашитая
|
||||
# версия ломала бы сборку исходников более старого формата на соответствующей ей платформе. Берём
|
||||
# версию из корня собираемого объекта (ExternalDataProcessor/ExternalReport); вложенные файлы —
|
||||
# запасной вариант, если корень почему-то не попался.
|
||||
$srcRootVersion = ""
|
||||
$srcAnyVersion = ""
|
||||
|
||||
$xmlFiles = Get-ChildItem -Path $SourceDir -Filter "*.xml" -Recurse -File
|
||||
foreach ($f in $xmlFiles) {
|
||||
$content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
|
||||
|
||||
if ($content -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') {
|
||||
$ver = $Matches[1]
|
||||
if (-not $srcAnyVersion) { $srcAnyVersion = $ver }
|
||||
if (-not $srcRootVersion -and $content -match '<(ExternalDataProcessor|ExternalReport)[ >]') {
|
||||
$srcRootVersion = $ver
|
||||
}
|
||||
}
|
||||
|
||||
# Ref types: cfg:CatalogRef.XXX or d5p1:CatalogRef.XXX (and similar depth prefixes d4p1, d3p1, etc.)
|
||||
$refPattern = '(?:cfg:|d\dp1:)(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.([A-Za-z\u0400-\u04FF\d_]+)'
|
||||
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
||||
@@ -187,7 +358,24 @@ if ($hasRefTypes) {
|
||||
$cfgDir = Join-Path $TempBasePath "cfg"
|
||||
New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null
|
||||
|
||||
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
||||
# Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||
# одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||
# заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||
# конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||
# формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||
#
|
||||
$srcVersion = if ($srcRootVersion) { $srcRootVersion } elseif ($srcAnyVersion) { $srcAnyVersion } else { "2.17" }
|
||||
$srcRank = Get-FormatRank $srcVersion
|
||||
$stubFormatVersion = if ($srcRank -gt 0 -and $srcRank -lt (Get-FormatRank "2.17")) { $srcVersion } else { "2.17" }
|
||||
# Режим совместимости заглушки — по той же логике. Платформа отказывается работать с
|
||||
# конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий
|
||||
# формата из docs/1c-configuration-spec.md.
|
||||
$compatByFormat = @{ "2.13" = "Version8_3_20"; "2.14" = "Version8_3_21"; "2.15" = "Version8_3_22"; "2.16" = "Version8_3_23" }
|
||||
$stubCompatMode = if ($compatByFormat.ContainsKey($stubFormatVersion)) { $compatByFormat[$stubFormatVersion] } else { "Version8_3_24" }
|
||||
|
||||
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="' + $stubFormatVersion + '"'
|
||||
|
||||
# GeneratedType definitions per metadata type
|
||||
$gtDefs = @{
|
||||
@@ -371,7 +559,7 @@ if ($hasRefTypes) {
|
||||
<Synonym/>
|
||||
<Comment/>
|
||||
<NamePrefix/>
|
||||
<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
||||
<ConfigurationExtensionCompatibilityMode>$stubCompatMode</ConfigurationExtensionCompatibilityMode>
|
||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
@@ -422,7 +610,7 @@ if ($hasRefTypes) {
|
||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
||||
<CompatibilityMode>$stubCompatMode</CompatibilityMode>
|
||||
<DefaultConstantsForm/>
|
||||
</Properties>
|
||||
<ChildObjects>$childXml
|
||||
@@ -1253,7 +1441,89 @@ $propsXml </Properties>$childObjLine
|
||||
}
|
||||
|
||||
# --- 5a. Stub via ibcmd (one call: create [--import --apply]) ---
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
$stubEngine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-TempBasePath'; '--db-path' = '-TempBasePath' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $stubEngine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
function Format-ArgToken {
|
||||
# Start-Process takes these argument lists as one string, so quote each token that needs it.
|
||||
param([string]$Token)
|
||||
if ($Token -match '[\s"]') { return ' "' + ($Token -replace '"', '\"') + '"' }
|
||||
return " $Token"
|
||||
}
|
||||
$extraArgString = -join ($extraArgs | ForEach-Object { Format-ArgToken $_ })
|
||||
if ($stubEngine -eq "ibcmd") {
|
||||
Write-Host "Creating infobase (ibcmd): $TempBasePath"
|
||||
$ibData = Join-Path $env:TEMP "stub_data_$(Get-Random)"
|
||||
@@ -1261,11 +1531,13 @@ if ($stubEngine -eq "ibcmd") {
|
||||
$ibArgs = @("infobase", "create", "--db-path=$TempBasePath", "--create-database")
|
||||
if ($hasRefTypes) { $ibArgs += "--import=$(Join-Path $TempBasePath 'cfg')", "--apply", "--force" }
|
||||
$ibArgs += "--data=$ibData"
|
||||
$ibOut = & $V8Path @ibArgs 2>&1
|
||||
$ibRc = $LASTEXITCODE
|
||||
$ibArgs += $extraArgs
|
||||
$__ib = Invoke-PlatformProcess $V8Path $ibArgs
|
||||
$ibOut = $__ib.Output
|
||||
$ibRc = $__ib.ExitCode
|
||||
Remove-Item -Path $ibData -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if ($ibRc -ne 0) {
|
||||
if ($ibOut) { Write-Host ($ibOut | Out-String) }
|
||||
Write-PlatformOutput $ibOut
|
||||
Write-Error "Failed to create stub infobase (code: $ibRc)"
|
||||
exit 1
|
||||
}
|
||||
@@ -1277,9 +1549,10 @@ if ($stubEngine -eq "ibcmd") {
|
||||
|
||||
# --- 5. Create infobase ---
|
||||
Write-Host "Creating infobase: $TempBasePath"
|
||||
$createArgs = "CREATEINFOBASE File=`"$TempBasePath`" /DisableStartupDialogs"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $createArgs -NoNewWindow -Wait -PassThru
|
||||
$createArgs = "CREATEINFOBASE File=`"$TempBasePath`" /DisableStartupDialogs" + $extraArgString
|
||||
$proc = Invoke-PlatformProcess $V8Path @($createArgs) -PreQuoted
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
Write-PlatformOutput $proc.Output
|
||||
Write-Error "Failed to create infobase (code: $($proc.ExitCode))"
|
||||
exit 1
|
||||
}
|
||||
@@ -1290,10 +1563,11 @@ if ($hasRefTypes) {
|
||||
# LoadConfigFromFiles
|
||||
Write-Host "Loading configuration from files..."
|
||||
$loadLog = Join-Path $env:TEMP "stub_load_log.txt"
|
||||
$loadArgs = "DESIGNER /F`"$TempBasePath`" /LoadConfigFromFiles `"$cfgDir`" /Out `"$loadLog`" /DisableStartupDialogs"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $loadArgs -NoNewWindow -Wait -PassThru
|
||||
$loadArgs = "DESIGNER /F`"$TempBasePath`" /LoadConfigFromFiles `"$cfgDir`" /Out `"$loadLog`" /DisableStartupDialogs" + $extraArgString
|
||||
$proc = Invoke-PlatformProcess $V8Path @($loadArgs) -PreQuoted
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
if (Test-Path $loadLog) { Get-Content $loadLog -Raw -ErrorAction SilentlyContinue | Write-Host }
|
||||
Write-PlatformOutput $proc.Output
|
||||
Write-Error "Failed to load config (code: $($proc.ExitCode))"
|
||||
exit 1
|
||||
}
|
||||
@@ -1301,10 +1575,11 @@ if ($hasRefTypes) {
|
||||
# UpdateDBCfg
|
||||
Write-Host "Updating database configuration..."
|
||||
$updateLog = Join-Path $env:TEMP "stub_update_log.txt"
|
||||
$updateArgs = "DESIGNER /F`"$TempBasePath`" /UpdateDBCfg /Out `"$updateLog`" /DisableStartupDialogs"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $updateArgs -NoNewWindow -Wait -PassThru
|
||||
$updateArgs = "DESIGNER /F`"$TempBasePath`" /UpdateDBCfg /Out `"$updateLog`" /DisableStartupDialogs" + $extraArgString
|
||||
$proc = Invoke-PlatformProcess $V8Path @($updateArgs) -PreQuoted
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
if (Test-Path $updateLog) { Get-Content $updateLog -Raw -ErrorAction SilentlyContinue | Write-Host }
|
||||
Write-PlatformOutput $proc.Output
|
||||
Write-Error "Failed to update DB config (code: $($proc.ExitCode))"
|
||||
exit 1
|
||||
}
|
||||
+346
-21
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# stub-db-create v1.2 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -12,6 +12,256 @@ import tempfile
|
||||
import uuid
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@@ -89,6 +339,66 @@ def scan_ref_types(source_dir):
|
||||
return type_map
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def detect_stub_format_version(source_dir):
|
||||
"""Версия формата заглушечной конфигурации.
|
||||
|
||||
Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||
одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||
заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||
конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||
формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||
|
||||
Версию исходников берём из корня собираемого объекта (ExternalDataProcessor/ExternalReport);
|
||||
вложенные файлы — запасной вариант, если корень почему-то не попался.
|
||||
"""
|
||||
root_version = ""
|
||||
any_version = ""
|
||||
ver_pattern = re.compile(r'<MetaDataObject[^>]+version="(\d+\.\d+)"')
|
||||
root_pattern = re.compile(r'<(ExternalDataProcessor|ExternalReport)[ >]')
|
||||
for dirpath, _, filenames in os.walk(source_dir):
|
||||
for fn in filenames:
|
||||
if not fn.endswith('.xml'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(dirpath, fn), 'r', encoding='utf-8-sig') as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
continue
|
||||
m = ver_pattern.search(content)
|
||||
if not m:
|
||||
continue
|
||||
if not any_version:
|
||||
any_version = m.group(1)
|
||||
if not root_version and root_pattern.search(content):
|
||||
root_version = m.group(1)
|
||||
src_version = root_version or any_version or "2.17"
|
||||
src_rank = format_rank(src_version)
|
||||
return src_version if 0 < src_rank < format_rank("2.17") else "2.17"
|
||||
|
||||
|
||||
# Режим совместимости заглушки — по той же логике, что и версия формата. Платформа отказывается
|
||||
# работать с конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий формата
|
||||
# из docs/1c-configuration-spec.md.
|
||||
COMPAT_BY_FORMAT = {
|
||||
"2.13": "Version8_3_20",
|
||||
"2.14": "Version8_3_21",
|
||||
"2.15": "Version8_3_22",
|
||||
"2.16": "Version8_3_23",
|
||||
}
|
||||
|
||||
|
||||
def stub_compatibility_mode(format_version):
|
||||
return COMPAT_BY_FORMAT.get(format_version, "Version8_3_24")
|
||||
|
||||
|
||||
def scan_register_columns(source_dir):
|
||||
"""Scan Form.xml for register record set columns referenced via DataPath.
|
||||
Returns {"RegisterType.RegisterName": {"col1": True, "col2": True}}."""
|
||||
@@ -167,7 +477,7 @@ NS = (
|
||||
'xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" '
|
||||
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
CLASS_IDS = [
|
||||
@@ -781,11 +1091,24 @@ def main():
|
||||
parser.add_argument('-SourceDir', required=True)
|
||||
parser.add_argument('-V8Path', required=True)
|
||||
parser.add_argument('-TempBasePath', default='')
|
||||
args = parser.parse_args()
|
||||
parser.add_argument('-AdditionalV8Arguments', nargs='*', default=[],
|
||||
help='Extra 1cv8 arguments, e.g. /UseHwLicenses+')
|
||||
parser.add_argument('-AdditionalIbcmdArguments', nargs='*', default=[],
|
||||
help='Extra ibcmd arguments in --key=value form')
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.SourceDir = clean_path(args.SourceDir, "-SourceDir")
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.TempBasePath = clean_path(args.TempBasePath, "-TempBasePath")
|
||||
|
||||
type_map = scan_ref_types(args.SourceDir)
|
||||
register_columns = scan_register_columns(args.SourceDir)
|
||||
has_ref_types = len(type_map) > 0
|
||||
stub_format_version = detect_stub_format_version(args.SourceDir)
|
||||
stub_compat = stub_compatibility_mode(stub_format_version)
|
||||
ns_decl = f'{NS} version="{stub_format_version}"'
|
||||
|
||||
temp_base = args.TempBasePath or os.path.join(tempfile.gettempdir(), f'epf_stub_db_{random.randint(0,999999)}')
|
||||
|
||||
@@ -817,7 +1140,7 @@ def main():
|
||||
child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>'
|
||||
|
||||
cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {NS}>
|
||||
<MetaDataObject {ns_decl}>
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>{co_xml}
|
||||
\t\t</InternalInfo>
|
||||
@@ -826,7 +1149,7 @@ def main():
|
||||
\t\t\t<Synonym/>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<NamePrefix/>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>{stub_compat}</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
\t\t\t<UsePurposes>
|
||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
@@ -877,7 +1200,7 @@ def main():
|
||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
\t\t\t<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
\t\t\t<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
||||
\t\t\t<CompatibilityMode>{stub_compat}</CompatibilityMode>
|
||||
\t\t\t<DefaultConstantsForm/>
|
||||
\t\t</Properties>
|
||||
\t\t<ChildObjects>{child_xml}
|
||||
@@ -891,7 +1214,7 @@ def main():
|
||||
lang_dir = os.path.join(cfg_dir, 'Languages')
|
||||
os.makedirs(lang_dir, exist_ok=True)
|
||||
lang_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {NS}>
|
||||
<MetaDataObject {ns_decl}>
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>\u0420\u0443\u0441\u0441\u043a\u0438\u0439</Name>
|
||||
@@ -1020,7 +1343,7 @@ def main():
|
||||
child_obj_xml = '\n\t\t<ChildObjects/>'
|
||||
|
||||
obj_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {NS}>
|
||||
<MetaDataObject {ns_decl}>
|
||||
\t<{tag} uuid="{obj_uuid}">{internal_xml}
|
||||
\t\t<Properties>
|
||||
{props_xml}
|
||||
@@ -1036,6 +1359,10 @@ def main():
|
||||
|
||||
# Stub via ibcmd (one call: create [--import --apply])
|
||||
stub_engine = "ibcmd" if os.path.basename(args.V8Path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {"/F": "-TempBasePath", "--db-path": "-TempBasePath"}
|
||||
extra_args = resolve_extra_args(stub_engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
if stub_engine == "ibcmd":
|
||||
import shutil
|
||||
print(f'Creating infobase (ibcmd): {temp_base}')
|
||||
@@ -1044,7 +1371,8 @@ def main():
|
||||
if has_ref_types:
|
||||
ib_args += [f'--import={os.path.join(temp_base, "cfg")}', '--apply', '--force']
|
||||
ib_args.append(f'--data={ib_data}')
|
||||
result = subprocess.run(ib_args, capture_output=True, encoding='utf-8', errors='replace')
|
||||
ib_args.extend(extra_args)
|
||||
result = run_ibcmd(ib_args, warn_no_user=False)
|
||||
shutil.rmtree(ib_data, ignore_errors=True)
|
||||
if result.returncode != 0:
|
||||
if result.stdout:
|
||||
@@ -1062,11 +1390,10 @@ def main():
|
||||
|
||||
# Create infobase
|
||||
print(f'Creating infobase: {temp_base}')
|
||||
result = subprocess.run(
|
||||
[args.V8Path, 'CREATEINFOBASE', f'File={temp_base}', '/DisableStartupDialogs'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = run_v8(args.V8Path, ['CREATEINFOBASE', f'File="{temp_base}"', '/DisableStartupDialogs']
|
||||
+ [quote_if_needed(a) for a in extra_args])
|
||||
if result.returncode != 0:
|
||||
print_platform_output(result)
|
||||
print(f'Failed to create infobase (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1074,21 +1401,18 @@ def main():
|
||||
cfg_dir = os.path.join(temp_base, 'cfg')
|
||||
# LoadConfigFromFiles
|
||||
print('Loading configuration from files...')
|
||||
result = subprocess.run(
|
||||
[args.V8Path, 'DESIGNER', f'/F{temp_base}', '/LoadConfigFromFiles', cfg_dir, '/DisableStartupDialogs'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/LoadConfigFromFiles', f'"{cfg_dir}"',
|
||||
'/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args])
|
||||
if result.returncode != 0:
|
||||
print_platform_output(result)
|
||||
print(f'Failed to load config (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# UpdateDBCfg
|
||||
print('Updating database configuration...')
|
||||
update_log = os.path.join(tempfile.gettempdir(), 'stub_update_log.txt')
|
||||
result = subprocess.run(
|
||||
[args.V8Path, 'DESIGNER', f'/F{temp_base}', '/UpdateDBCfg', '/Out', update_log, '/DisableStartupDialogs'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/UpdateDBCfg', '/Out', f'"{update_log}"',
|
||||
'/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args])
|
||||
if result.returncode != 0:
|
||||
if os.path.isfile(update_log):
|
||||
try:
|
||||
@@ -1096,6 +1420,7 @@ def main():
|
||||
print(f.read())
|
||||
except Exception:
|
||||
pass
|
||||
print_platform_output(result)
|
||||
print(f'Failed to update DB config (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -39,7 +39,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <параметры>
|
||||
python ".agents/skills/epf-dump/scripts/epf-dump.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -55,6 +55,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <па
|
||||
| `-InputFile <путь>` | да | Путь к EPF/ERF-файлу |
|
||||
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
||||
|
||||
@@ -62,8 +64,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <па
|
||||
|
||||
```powershell
|
||||
# Разборка обработки (файловая база)
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МояОбработка.epf" -OutputDir "src"
|
||||
python ".agents/skills/epf-dump/scripts/epf-dump.py" -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"
|
||||
python ".agents/skills/epf-dump/scripts/epf-dump.py" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МояОбработка.epf" -OutputDir "src"
|
||||
```
|
||||
@@ -0,0 +1,497 @@
|
||||
# epf-dump v1.12 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Разборка внешней обработки/отчёта 1С в XML-исходники
|
||||
|
||||
.DESCRIPTION
|
||||
Разбирает EPF/ERF-файл во XML-исходники с помощью платформы 1С.
|
||||
Общий скрипт для epf-dump и erf-dump.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к EPF/ERF-файлу
|
||||
|
||||
.PARAMETER OutputDir
|
||||
Каталог для выгрузки исходников
|
||||
|
||||
.PARAMETER Format
|
||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МояОбработка.epf" -OutputDir "src"
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
$OutputDir = ConvertTo-CleanPath $OutputDir '-OutputDir'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate database connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef" -ForegroundColor Red
|
||||
Write-Host "Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-DirNonEmpty {
|
||||
# Postcondition: the platform must have written files into the output directory.
|
||||
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
if (-not (Test-Path $OutputDir)) {
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "epf_dump_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch: dump EPF/ERF via config export --file ---
|
||||
$arguments = @("infobase", "config", "export", "--file=$InputFile", "$OutputDir", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "External data processor/report dumped successfully to: $OutputDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/DumpExternalDataProcessorOrReportToFiles", "`"$OutputDir`"", "`"$InputFile`""
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Dump completed successfully to: $OutputDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-dump v1.12 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def dir_nonempty(path):
|
||||
"""Postcondition: the platform must have written files into the output directory.
|
||||
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isdir(path) and any(os.scandir(path))
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump external data processor or report (EPF/ERF) to XML sources",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-InputFile", required=True, help="Path to EPF/ERF file")
|
||||
parser.add_argument("-OutputDir", required=True, help="Directory for dumped XML sources")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
args.OutputDir = clean_path(args.OutputDir, "-OutputDir")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate database connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef", file=sys.stderr)
|
||||
print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
if not os.path.exists(args.OutputDir):
|
||||
os.makedirs(args.OutputDir, exist_ok=True)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"epf_dump_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch: dump EPF/ERF via config export --file ---
|
||||
arguments = ["infobase", "config", "export", f"--file={args.InputFile}", args.OutputDir, f"--db-path={args.InfoBasePath}"]
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/DumpExternalDataProcessorOrReportToFiles", f'"{args.OutputDir}"', f'"{args.InputFile}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Dump completed successfully to: {args.OutputDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: epf-init
|
||||
description: Создать пустую внешнюю обработку 1С (scaffold XML-исходников). Используй когда нужно создать новую внешнюю обработку с нуля
|
||||
argument-hint: <Name> [Synonym]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Glob
|
||||
- Grep
|
||||
---
|
||||
|
||||
# /epf-init — Создание новой обработки
|
||||
|
||||
Генерирует минимальный набор XML-исходников для внешней обработки 1С: корневой файл метаданных и каталог обработки.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/epf-init <Name> [Synonym] [SrcDir] [FormatVersion]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|---------------|:------------:|--------------|------------------------------------------------|
|
||||
| Name | да | — | Имя обработки (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
|
||||
|
||||
`FormatVersion` — **не выше** версии формата платформы, на которой объект будут собирать и открывать:
|
||||
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
|
||||
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
|
||||
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
|
||||
но на них навыки не проверялись — такое значение принимается с предупреждением.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
python ".agents/skills/epf-init/scripts/init.py" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"]
|
||||
```
|
||||
|
||||
## Дальнейшие шаги
|
||||
|
||||
- Добавить форму: `/form-add`
|
||||
- Добавить макет: `/template-add`
|
||||
- Добавить справку: `/help-add`
|
||||
- Собрать EPF: `/epf-build`
|
||||
@@ -0,0 +1,148 @@
|
||||
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
|
||||
[string]$Synonym = $Name,
|
||||
|
||||
[string]$SrcDir = "src",
|
||||
|
||||
# Версия формата выгрузки. Своей конфигурации у автономной обработки нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри обработки
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||
# на нечисловое значение: это опечатка, а не версия.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
$formatRank = Get-FormatRank $FormatVersion
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||
if ($formatRank -eq 0) {
|
||||
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||
exit 1
|
||||
}
|
||||
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||
}
|
||||
|
||||
$uuid1 = [guid]::NewGuid().ToString()
|
||||
$uuid2 = [guid]::NewGuid().ToString()
|
||||
$uuid3 = [guid]::NewGuid().ToString()
|
||||
$uuid4 = [guid]::NewGuid().ToString()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
|
||||
# платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if ($formatRank -ge 221) {
|
||||
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
$xml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<ExternalDataProcessor uuid="$uuid1">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>c3831ec8-d8d5-4f93-8a22-f9bfae07327f</xr:ClassId>
|
||||
<xr:ObjectId>$uuid2</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:GeneratedType name="ExternalDataProcessorObject.$Name" category="Object">
|
||||
<xr:TypeId>$uuid3</xr:TypeId>
|
||||
<xr:ValueId>$uuid4</xr:ValueId>
|
||||
</xr:GeneratedType>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$(Esc-XmlText $Name)</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<DefaultForm/>
|
||||
<AuxiliaryForm/>
|
||||
</Properties>
|
||||
<ChildObjects/>
|
||||
</ExternalDataProcessor>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
$rootFile = Join-Path $SrcDir "$Name.xml"
|
||||
$processorDir = Join-Path $SrcDir $Name
|
||||
|
||||
if (Test-Path $rootFile) {
|
||||
Write-Error "Файл уже существует: $rootFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $SrcDir)) {
|
||||
New-Item -ItemType Directory -Path $SrcDir -Force | Out-Null
|
||||
}
|
||||
$extDir = Join-Path $processorDir "Ext"
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||
|
||||
# --- Модуль объекта ---
|
||||
|
||||
$moduleBsl = @"
|
||||
#Область ОписаниеПеременных
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ПрограммныйИнтерфейс
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти
|
||||
"@
|
||||
|
||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||
# самого скрипта, а он в репозитории хранится с LF.
|
||||
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||
|
||||
Write-Host "[OK] Создана обработка: $rootFile"
|
||||
Write-Host " Каталог: $processorDir"
|
||||
Write-Host " Модуль: $modulePath"
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C external data processor."""
|
||||
import sys, os, re, argparse, uuid
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description='Init 1C external data processor scaffold', allow_abbrev=False)
|
||||
parser.add_argument('-Name', dest='Name', required=True)
|
||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
src_dir = args.SrcDir
|
||||
|
||||
uuid1 = new_uuid()
|
||||
uuid2 = new_uuid()
|
||||
uuid3 = new_uuid()
|
||||
uuid4 = new_uuid()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
format_version = args.FormatVersion
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
|
||||
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if format_rank(format_version) >= 221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
|
||||
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<ExternalDataProcessor uuid="{uuid1}">
|
||||
\t\t<InternalInfo>
|
||||
\t\t\t<xr:ContainedObject>
|
||||
\t\t\t\t<xr:ClassId>c3831ec8-d8d5-4f93-8a22-f9bfae07327f</xr:ClassId>
|
||||
\t\t\t\t<xr:ObjectId>{uuid2}</xr:ObjectId>
|
||||
\t\t\t</xr:ContainedObject>
|
||||
\t\t\t<xr:GeneratedType name="ExternalDataProcessorObject.{name}" category="Object">
|
||||
\t\t\t\t<xr:TypeId>{uuid3}</xr:TypeId>
|
||||
\t\t\t\t<xr:ValueId>{uuid4}</xr:ValueId>
|
||||
\t\t\t</xr:GeneratedType>
|
||||
\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<DefaultForm/>
|
||||
\t\t\t<AuxiliaryForm/>
|
||||
\t\t</Properties>
|
||||
\t\t<ChildObjects/>
|
||||
\t</ExternalDataProcessor>
|
||||
</MetaDataObject>'''
|
||||
|
||||
root_file = os.path.join(src_dir, f"{name}.xml")
|
||||
processor_dir = os.path.join(src_dir, name)
|
||||
|
||||
if os.path.exists(root_file):
|
||||
print(f"Файл уже существует: {root_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
ext_dir = os.path.join(processor_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
|
||||
# --- Модуль объекта ---
|
||||
module_bsl = """\
|
||||
#Область ОписаниеПеременных
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ПрограммныйИнтерфейс
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти"""
|
||||
|
||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
print(f"[OK] Создана обработка: {root_file}")
|
||||
print(f" Каталог: {processor_dir}")
|
||||
print(f" Модуль: {module_path}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -24,7 +24,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка/МояОбработка.xml"
|
||||
python ".agents/skills/epf-validate/scripts/epf-validate.py" -ObjectPath "src/МояОбработка"
|
||||
python ".agents/skills/epf-validate/scripts/epf-validate.py" -ObjectPath "src/МояОбработка/МояОбработка.xml"
|
||||
```
|
||||
|
||||
+23
-4
@@ -1,8 +1,9 @@
|
||||
# epf-validate v1.2 — 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
|
||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('Path')]
|
||||
[string]$ObjectPath,
|
||||
|
||||
@@ -111,6 +112,19 @@ $finalize = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Reference tables ---
|
||||
|
||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
@@ -183,10 +197,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
}
|
||||
|
||||
$version = $root.GetAttribute("version")
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
}
|
||||
|
||||
# Detect type: ExternalDataProcessor or ExternalReport
|
||||
+48
-4
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-validate v1.2 — 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
|
||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||
|
||||
@@ -10,6 +10,28 @@ import sys
|
||||
from io import StringIO
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
||||
@@ -38,6 +60,21 @@ CHILD_TYPE_ORDER = {
|
||||
}
|
||||
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def localname(el):
|
||||
return etree.QName(el.tag).localname
|
||||
|
||||
@@ -50,7 +87,7 @@ def main():
|
||||
parser.add_argument("-Detailed", action="store_true")
|
||||
parser.add_argument("-MaxErrors", type=int, default=30)
|
||||
parser.add_argument("-OutFile", default=None)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
max_errors = args.MaxErrors
|
||||
|
||||
@@ -163,10 +200,17 @@ def main():
|
||||
check1_ok = False
|
||||
|
||||
version = root.get("version", "")
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
report_warn("1. Missing version attribute on MetaDataObject")
|
||||
elif version not in ("2.17", "2.20", "2.21"):
|
||||
report_warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
||||
elif version_rank == 0:
|
||||
report_error(f"1. Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
report_warn(f"1. Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
report_warn(f"1. Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
|
||||
# Detect type
|
||||
child_elements = []
|
||||
@@ -42,7 +42,7 @@ allowed-tools:
|
||||
Используй общий скрипт из epf-build:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-build.ps1" <параметры>
|
||||
python ".agents/skills/epf-build/scripts/epf-build.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -57,6 +57,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-bu
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному ERF-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
|
||||
|
||||
@@ -64,8 +66,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-bu
|
||||
|
||||
```powershell
|
||||
# Сборка отчёта (файловая база)
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf"
|
||||
python ".agents/skills/epf-build/scripts/epf-build.py" -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"
|
||||
python ".agents/skills/epf-build/scripts/epf-build.py" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf"
|
||||
```
|
||||
@@ -41,7 +41,7 @@ allowed-tools:
|
||||
Используй общий скрипт из epf-dump:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dump.ps1" <параметры>
|
||||
python ".agents/skills/epf-dump/scripts/epf-dump.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
@@ -57,6 +57,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dum
|
||||
| `-InputFile <путь>` | да | Путь к ERF-файлу |
|
||||
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
||||
|
||||
@@ -64,8 +66,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dum
|
||||
|
||||
```powershell
|
||||
# Разборка отчёта (файловая база)
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МойОтчёт.erf" -OutputDir "src"
|
||||
python ".agents/skills/epf-dump/scripts/epf-dump.py" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МойОтчёт.erf" -OutputDir "src"
|
||||
|
||||
# Серверная база
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dump.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МойОтчёт.erf" -OutputDir "src"
|
||||
python ".agents/skills/epf-dump/scripts/epf-dump.py" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МойОтчёт.erf" -OutputDir "src"
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: erf-init
|
||||
description: Создать пустой внешний отчёт 1С (scaffold XML-исходников). Используй когда нужно создать новый внешний отчёт с нуля
|
||||
argument-hint: <Name> [Synonym] [--with-skd]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Glob
|
||||
- Grep
|
||||
---
|
||||
|
||||
# /erf-init — Создание нового отчёта
|
||||
|
||||
Генерирует минимальный набор XML-исходников для внешнего отчёта 1С: корневой файл метаданных и каталог отчёта.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/erf-init <Name> [Synonym] [SrcDir] [FormatVersion] [--with-skd]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|---------------|:------------:|--------------|---------------------------------------|
|
||||
| Name | да | — | Имя отчёта (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
|
||||
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
|
||||
|
||||
`FormatVersion` — **не выше** версии формата платформы, на которой объект будут собирать и открывать:
|
||||
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
|
||||
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
|
||||
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
|
||||
но на них навыки не проверялись — такое значение принимается с предупреждением.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
python ".agents/skills/erf-init/scripts/init.py" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"] [-WithSKD]
|
||||
```
|
||||
|
||||
## Дальнейшие шаги
|
||||
|
||||
- Добавить форму: `/form-add`
|
||||
- Добавить макет: `/template-add`
|
||||
- Добавить справку: `/help-add`
|
||||
- Собрать ERF: `/erf-build`
|
||||
@@ -0,0 +1,238 @@
|
||||
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
|
||||
[string]$Synonym = $Name,
|
||||
|
||||
[string]$SrcDir = "src",
|
||||
|
||||
[switch]$WithSKD,
|
||||
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного отчёта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри отчёта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||
# на нечисловое значение: это опечатка, а не версия.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
$formatRank = Get-FormatRank $FormatVersion
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||
if ($formatRank -eq 0) {
|
||||
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||
exit 1
|
||||
}
|
||||
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||
}
|
||||
|
||||
$uuid1 = [guid]::NewGuid().ToString()
|
||||
$uuid2 = [guid]::NewGuid().ToString()
|
||||
$uuid3 = [guid]::NewGuid().ToString()
|
||||
$uuid4 = [guid]::NewGuid().ToString()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
|
||||
# платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if ($formatRank -ge 221) {
|
||||
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
# --- Формируем Properties ---
|
||||
|
||||
$mainDCSValue = ""
|
||||
$childObjectsContent = ""
|
||||
|
||||
if ($WithSKD) {
|
||||
$mainDCSValue = "ExternalReport.$Name.Template.ОсновнаяСхемаКомпоновкиДанных"
|
||||
$childObjectsContent = @"
|
||||
|
||||
<Template>ОсновнаяСхемаКомпоновкиДанных</Template>
|
||||
|
||||
"@
|
||||
}
|
||||
|
||||
$mainDCSElement = if ($mainDCSValue) {
|
||||
"<MainDataCompositionSchema>$mainDCSValue</MainDataCompositionSchema>"
|
||||
} else {
|
||||
"<MainDataCompositionSchema/>"
|
||||
}
|
||||
|
||||
$childObjectsXml = if ($childObjectsContent) {
|
||||
"<ChildObjects>$childObjectsContent</ChildObjects>"
|
||||
} else {
|
||||
"<ChildObjects/>"
|
||||
}
|
||||
|
||||
$xml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<ExternalReport uuid="$uuid1">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>e41aff26-25cf-4bb6-b6c1-3f478a75f374</xr:ClassId>
|
||||
<xr:ObjectId>$uuid2</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:GeneratedType name="ExternalReportObject.$Name" category="Object">
|
||||
<xr:TypeId>$uuid3</xr:TypeId>
|
||||
<xr:ValueId>$uuid4</xr:ValueId>
|
||||
</xr:GeneratedType>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$(Esc-XmlText $Name)</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<DefaultForm/>
|
||||
<AuxiliaryForm/>
|
||||
$mainDCSElement
|
||||
<DefaultSettingsForm/>
|
||||
<AuxiliarySettingsForm/>
|
||||
<DefaultVariantForm/>
|
||||
<VariantsStorage/>
|
||||
<SettingsStorage/>
|
||||
</Properties>
|
||||
$childObjectsXml
|
||||
</ExternalReport>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
$rootFile = Join-Path $SrcDir "$Name.xml"
|
||||
$reportDir = Join-Path $SrcDir $Name
|
||||
|
||||
if (Test-Path $rootFile) {
|
||||
Write-Error "Файл уже существует: $rootFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $SrcDir)) {
|
||||
New-Item -ItemType Directory -Path $SrcDir -Force | Out-Null
|
||||
}
|
||||
$extDir = Join-Path $reportDir "Ext"
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||
|
||||
# --- Модуль объекта ---
|
||||
|
||||
$moduleBsl = @"
|
||||
#Область ОписаниеПеременных
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ПрограммныйИнтерфейс
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти
|
||||
"@
|
||||
|
||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||
# самого скрипта, а он в репозитории хранится с LF.
|
||||
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||
|
||||
Write-Host "[OK] Создан отчёт: $rootFile"
|
||||
Write-Host " Каталог: $reportDir"
|
||||
Write-Host " Модуль: $modulePath"
|
||||
|
||||
# --- СКД-макет (если --WithSKD) ---
|
||||
|
||||
if ($WithSKD) {
|
||||
$templatesDir = Join-Path $reportDir "Templates"
|
||||
$skdName = "ОсновнаяСхемаКомпоновкиДанных"
|
||||
$skdMetaPath = Join-Path $templatesDir "$skdName.xml"
|
||||
$skdExtDir = Join-Path (Join-Path $templatesDir $skdName) "Ext"
|
||||
New-Item -ItemType Directory -Path $skdExtDir -Force | Out-Null
|
||||
|
||||
$skdUuid = [guid]::NewGuid().ToString()
|
||||
|
||||
$skdMetaXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<Template uuid="$skdUuid">
|
||||
<Properties>
|
||||
<Name>$skdName</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основная схема компоновки данных</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<TemplateType>DataCompositionSchema</TemplateType>
|
||||
</Properties>
|
||||
</Template>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
Write-XmlFile $skdMetaPath $skdMetaXml $enc
|
||||
|
||||
$skdContent = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||
xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"
|
||||
xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"
|
||||
xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"
|
||||
xmlns:v8="http://v8.1c.ru/8.1/data/core"
|
||||
xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"
|
||||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
</DataCompositionSchema>
|
||||
"@
|
||||
|
||||
$skdFilePath = Join-Path $skdExtDir "Template.xml"
|
||||
Write-XmlFile $skdFilePath $skdContent $enc
|
||||
|
||||
Write-Host " СКД: $skdMetaPath"
|
||||
Write-Host " Тело: $skdFilePath"
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C external report."""
|
||||
import sys, os, re, argparse, uuid
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description='Init 1C external report scaffold', allow_abbrev=False)
|
||||
parser.add_argument('-Name', dest='Name', required=True)
|
||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
parser.add_argument('-WithSKD', dest='WithSKD', action='store_true')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
src_dir = args.SrcDir
|
||||
|
||||
uuid1 = new_uuid()
|
||||
uuid2 = new_uuid()
|
||||
uuid3 = new_uuid()
|
||||
uuid4 = new_uuid()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
format_version = args.FormatVersion
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
|
||||
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if format_rank(format_version) >= 221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
|
||||
# --- Properties ---
|
||||
main_dcs_value = ""
|
||||
child_objects_content = ""
|
||||
|
||||
if args.WithSKD:
|
||||
main_dcs_value = f"ExternalReport.{name}.Template.ОсновнаяСхемаКомпоновкиДанных"
|
||||
child_objects_content = f"\n\t\t\t<Template>ОсновнаяСхемаКомпоновкиДанных</Template>\n"
|
||||
|
||||
main_dcs_element = f"<MainDataCompositionSchema>{main_dcs_value}</MainDataCompositionSchema>" if main_dcs_value else "<MainDataCompositionSchema/>"
|
||||
child_objects_xml = f"<ChildObjects>{child_objects_content}\t\t</ChildObjects>" if child_objects_content else "<ChildObjects/>"
|
||||
|
||||
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<ExternalReport uuid="{uuid1}">
|
||||
\t\t<InternalInfo>
|
||||
\t\t\t<xr:ContainedObject>
|
||||
\t\t\t\t<xr:ClassId>e41aff26-25cf-4bb6-b6c1-3f478a75f374</xr:ClassId>
|
||||
\t\t\t\t<xr:ObjectId>{uuid2}</xr:ObjectId>
|
||||
\t\t\t</xr:ContainedObject>
|
||||
\t\t\t<xr:GeneratedType name="ExternalReportObject.{name}" category="Object">
|
||||
\t\t\t\t<xr:TypeId>{uuid3}</xr:TypeId>
|
||||
\t\t\t\t<xr:ValueId>{uuid4}</xr:ValueId>
|
||||
\t\t\t</xr:GeneratedType>
|
||||
\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<DefaultForm/>
|
||||
\t\t\t<AuxiliaryForm/>
|
||||
\t\t\t{main_dcs_element}
|
||||
\t\t\t<DefaultSettingsForm/>
|
||||
\t\t\t<AuxiliarySettingsForm/>
|
||||
\t\t\t<DefaultVariantForm/>
|
||||
\t\t\t<VariantsStorage/>
|
||||
\t\t\t<SettingsStorage/>
|
||||
\t\t</Properties>
|
||||
\t\t{child_objects_xml}
|
||||
\t</ExternalReport>
|
||||
</MetaDataObject>'''
|
||||
|
||||
root_file = os.path.join(src_dir, f"{name}.xml")
|
||||
report_dir = os.path.join(src_dir, name)
|
||||
|
||||
if os.path.exists(root_file):
|
||||
print(f"Файл уже существует: {root_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
ext_dir = os.path.join(report_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
|
||||
# --- Модуль объекта ---
|
||||
module_bsl = """\
|
||||
#Область ОписаниеПеременных
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ПрограммныйИнтерфейс
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти"""
|
||||
|
||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
print(f"[OK] Создан отчёт: {root_file}")
|
||||
print(f" Каталог: {report_dir}")
|
||||
print(f" Модуль: {module_path}")
|
||||
|
||||
# --- СКД-макет ---
|
||||
if args.WithSKD:
|
||||
templates_dir = os.path.join(report_dir, "Templates")
|
||||
skd_name = "ОсновнаяСхемаКомпоновкиДанных"
|
||||
skd_meta_path = os.path.join(templates_dir, f"{skd_name}.xml")
|
||||
skd_ext_dir = os.path.join(templates_dir, skd_name, "Ext")
|
||||
os.makedirs(skd_ext_dir, exist_ok=True)
|
||||
|
||||
skd_uuid = new_uuid()
|
||||
|
||||
skd_meta_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Template uuid="{skd_uuid}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{skd_name}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>Основная схема компоновки данных</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<TemplateType>DataCompositionSchema</TemplateType>
|
||||
\t\t</Properties>
|
||||
\t</Template>
|
||||
</MetaDataObject>'''
|
||||
|
||||
write_xml_file(skd_meta_path, skd_meta_xml)
|
||||
|
||||
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||
\t\txmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"
|
||||
\t\txmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"
|
||||
\t\txmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"
|
||||
\t\txmlns:v8="http://v8.1c.ru/8.1/data/core"
|
||||
\t\txmlns:v8ui="http://v8.1c.ru/8.1/data/ui"
|
||||
\t\txmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
\t\txmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
\t<dataSource>
|
||||
\t\t<name>ИсточникДанных1</name>
|
||||
\t\t<dataSourceType>Local</dataSourceType>
|
||||
\t</dataSource>
|
||||
</DataCompositionSchema>'''
|
||||
|
||||
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
|
||||
write_xml_file(skd_file_path, skd_content)
|
||||
|
||||
print(f" СКД: {skd_meta_path}")
|
||||
print(f" Тело: {skd_file_path}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -26,7 +26,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МойОтчёт"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МойОтчёт/МойОтчёт.xml"
|
||||
python ".agents/skills/epf-validate/scripts/epf-validate.py" -ObjectPath "src/МойОтчёт"
|
||||
python ".agents/skills/epf-validate/scripts/epf-validate.py" -ObjectPath "src/МойОтчёт/МойОтчёт.xml"
|
||||
```
|
||||
|
||||
@@ -32,7 +32,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-add.ps1" -ObjectPath "<ObjectPath>" -FormName "<FormName>" [-Purpose "<Purpose>"] [-Synonym "<Synonym>"] [-SetDefault]
|
||||
python ".agents/skills/form-add/scripts/form-add.py" -ObjectPath "<ObjectPath>" -FormName "<FormName>" [-Purpose "<Purpose>"] [-Synonym "<Synonym>"] [-SetDefault]
|
||||
```
|
||||
|
||||
## Purpose — назначение формы
|
||||
+113
-19
@@ -1,4 +1,4 @@
|
||||
# form-add v1.7 — Add managed form to 1C config object
|
||||
# form-add v1.25 — Add managed form to 1C config object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -33,6 +33,16 @@ function Get-RootUuid([string]$xmlPath) {
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
@@ -69,10 +79,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
@@ -141,9 +154,20 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
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) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$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
|
||||
@@ -153,6 +177,13 @@ function Detect-FormatVersion([string]$dir) {
|
||||
return "2.17"
|
||||
}
|
||||
|
||||
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
|
||||
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Фаза 1: Определение типа объекта ---
|
||||
|
||||
# Resolve ObjectPath (directory → .xml)
|
||||
@@ -174,7 +205,26 @@ if (-not (Test-Path $ObjectPath)) {
|
||||
|
||||
$objectXmlFull = Resolve-Path $ObjectPath
|
||||
Assert-EditAllowed $objectXmlFull.Path 'editable'
|
||||
$script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent)
|
||||
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
|
||||
$script:formatVersion = $null
|
||||
$objHead = [System.IO.File]::ReadAllText($objectXmlFull.Path, [System.Text.Encoding]::UTF8)
|
||||
$objHead = $objHead.Substring(0, [Math]::Min(2000, $objHead.Length))
|
||||
if ($objHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { $script:formatVersion = $Matches[1] }
|
||||
if (-not $script:formatVersion) { $script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent) }
|
||||
|
||||
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
|
||||
# интерполируют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
$script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
$script:formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
if ((Get-FormatRank $script:formatVersion) -ge 221) {
|
||||
$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
$script:formNsDecl = $script:formNsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
@@ -195,7 +245,7 @@ $supportedTypes = @(
|
||||
"Document", "Catalog", "DataProcessor", "Report",
|
||||
"ExternalDataProcessor", "ExternalReport",
|
||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||
"ExchangePlan", "BusinessProcess", "Task"
|
||||
"ExchangePlan", "BusinessProcess", "Task", "DocumentJournal"
|
||||
)
|
||||
|
||||
$objectType = $null
|
||||
@@ -297,9 +347,16 @@ if ($objectType -in $processorLikeTypes) {
|
||||
$extPresentationLine = "`n`t`t`t<ExtendedPresentation/>"
|
||||
}
|
||||
|
||||
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
|
||||
$useInIfcLine = ""
|
||||
if ((Get-FormatRank $script:formatVersion) -ge 221) {
|
||||
$useInIfcLine = "`n`t`t`t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>"
|
||||
}
|
||||
|
||||
$formMetaXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$($script:formatVersion)">
|
||||
<MetaDataObject $($script:xmlnsDecl) version="$($script:formatVersion)">
|
||||
<Form uuid="$formUuid">
|
||||
<Properties>
|
||||
<Name>$FormName</Name>
|
||||
@@ -315,20 +372,29 @@ $formMetaXml = @"
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
|
||||
</UsePurposes>$extPresentationLine
|
||||
</UsePurposes>$useInIfcLine$extPresentationLine
|
||||
</Properties>
|
||||
</Form>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
[System.IO.File]::WriteAllText($formMetaPath, $formMetaXml, $encBom)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
#
|
||||
# Модуль .bsl сюда НЕ идёт — он пишется отдельно.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $formMetaPath $formMetaXml $encBom
|
||||
|
||||
# --- 3b. Form.xml ---
|
||||
|
||||
$formXmlPath = Join-Path $formExtDir "Form.xml"
|
||||
|
||||
$formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
|
||||
if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
# Динамический список
|
||||
# MainTable: тип.имя
|
||||
@@ -336,7 +402,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $formNsDecl version="$($script:formatVersion)">
|
||||
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
@@ -361,7 +427,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $formNsDecl version="$($script:formatVersion)">
|
||||
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
@@ -408,7 +474,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $formNsDecl version="$($script:formatVersion)">
|
||||
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
@@ -428,7 +494,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
if (Test-Path $formXmlPath) {
|
||||
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
|
||||
} else {
|
||||
[System.IO.File]::WriteAllText($formXmlPath, $formXml, $encBom)
|
||||
Write-XmlFile $formXmlPath $formXml $encBom
|
||||
}
|
||||
|
||||
# --- 3c. Module.bsl ---
|
||||
@@ -460,6 +526,11 @@ $moduleBsl = @"
|
||||
if (Test-Path $modulePath) {
|
||||
Write-Host "[SKIP] Module.bsl already exists: $modulePath — not overwriting"
|
||||
} else {
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||
# самого скрипта, а он в репозитории хранится с LF.
|
||||
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $encBom)
|
||||
}
|
||||
|
||||
@@ -471,7 +542,10 @@ if (-not $childObjects) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Добавить <Form>$FormName</Form>
|
||||
# Добавить <Form>$FormName</Form> — идемпотентно (не дублировать уже зарегистрированную)
|
||||
$alreadyRegistered = [bool]$childObjects.SelectSingleNode("md:Form[text()='$FormName']", $nsMgr)
|
||||
|
||||
if (-not $alreadyRegistered) {
|
||||
$formElem = $xmlDoc.CreateElement("Form", "http://v8.1c.ru/8.3/MDClasses")
|
||||
$formElem.InnerText = $FormName
|
||||
|
||||
@@ -525,6 +599,7 @@ if ($insertBefore) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- SetDefault ---
|
||||
|
||||
@@ -565,12 +640,27 @@ if ($SetDefault -or $isFirstFormForPurpose) {
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
|
||||
$stream = New-Object System.IO.FileStream($objectXmlFull.Path, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Close()
|
||||
$stream.Close()
|
||||
$writer.Flush(); $writer.Close()
|
||||
|
||||
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||
$memStream.Close()
|
||||
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $objectXmlFull.Path) -and ([System.IO.File]::ReadAllText($objectXmlFull.Path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($objectXmlFull.Path, $xmlText, $encBom)
|
||||
|
||||
# --- Фаза 5: Вывод ---
|
||||
|
||||
@@ -590,7 +680,11 @@ Write-Host " Metadata: $objDirName\$objBaseName\Forms\$FormName.xml"
|
||||
Write-Host " Form: $objDirName\$objBaseName\Forms\$FormName\Ext\Form.xml"
|
||||
Write-Host " Module: $objDirName\$objBaseName\Forms\$FormName\Ext\Form\Module.bsl"
|
||||
Write-Host ""
|
||||
Write-Host "Registered: <Form>$FormName</Form> in ChildObjects"
|
||||
if ($alreadyRegistered) {
|
||||
Write-Host "Already registered: <Form>$FormName</Form> in ChildObjects (skipped duplicate)"
|
||||
} else {
|
||||
Write-Host "Registered: <Form>$FormName</Form> in ChildObjects"
|
||||
}
|
||||
if ($defaultUpdated) {
|
||||
Write-Host "${defaultPropName}: $defaultValue"
|
||||
}
|
||||
+230
-90
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-add v1.7 — Add managed form to 1C config object
|
||||
# form-add v1.25 — Add managed form to 1C config object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -11,6 +11,28 @@ import uuid
|
||||
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
@@ -32,6 +54,18 @@ def _sg_root_uuid(xml_path):
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
@@ -71,6 +105,9 @@ def _sg_get_edit_mode(cfg_dir):
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
@@ -78,6 +115,8 @@ def assert_edit_allowed(target_path, require):
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
@@ -179,6 +218,16 @@ NSMAP = {
|
||||
|
||||
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:
|
||||
@@ -193,21 +242,78 @@ def detect_format_version(d):
|
||||
return "2.17"
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree to file with UTF-8 BOM."""
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||||
if not xml_bytes.endswith(b"\n"):
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def _detect_xml_style(path):
|
||||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||
try:
|
||||
raw = open(path, "rb").read()
|
||||
except OSError:
|
||||
return None
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
body = raw[3:] if bom else raw
|
||||
crlf = b"\r\n" in body
|
||||
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||
final_nl = body.endswith(b"\n")
|
||||
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", 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:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def write_text_with_bom(path, text):
|
||||
"""Write text to file with UTF-8 BOM."""
|
||||
with open(path, "w", encoding="utf-8-sig") as f:
|
||||
f.write(text)
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
|
||||
Модуль .bsl сюда НЕ идёт — он пишется отдельно.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -219,7 +325,7 @@ def main():
|
||||
parser.add_argument("-Synonym", default=None)
|
||||
parser.add_argument("-Purpose", default="Object")
|
||||
parser.add_argument("-SetDefault", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_path = args.ObjectPath
|
||||
form_name = args.FormName
|
||||
@@ -246,7 +352,64 @@ def main():
|
||||
|
||||
object_xml_full = os.path.abspath(object_path)
|
||||
assert_edit_allowed(object_xml_full, "editable")
|
||||
format_version = detect_format_version(os.path.dirname(object_xml_full))
|
||||
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
|
||||
format_version = None
|
||||
with open(object_xml_full, "r", encoding="utf-8-sig") as f:
|
||||
obj_head = f.read(2000)
|
||||
m_ver = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', obj_head)
|
||||
if m_ver:
|
||||
format_version = m_ver.group(1)
|
||||
if not format_version:
|
||||
format_version = detect_format_version(os.path.dirname(object_xml_full))
|
||||
|
||||
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
|
||||
# подставляют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
form_ns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
|
||||
' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
if format_rank(format_version) >= 221:
|
||||
pal = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
xmlns_decl = xmlns_decl.replace(' xmlns:style=', pal)
|
||||
form_ns_decl = form_ns_decl.replace(' xmlns:style=', pal)
|
||||
|
||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||
tree = etree.parse(object_xml_full, parser_xml)
|
||||
@@ -256,7 +419,7 @@ def main():
|
||||
"Document", "Catalog", "DataProcessor", "Report",
|
||||
"ExternalDataProcessor", "ExternalReport",
|
||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||
"ExchangePlan", "BusinessProcess", "Task",
|
||||
"ExchangePlan", "BusinessProcess", "Task", "DocumentJournal",
|
||||
]
|
||||
|
||||
object_type = None
|
||||
@@ -335,24 +498,7 @@ def main():
|
||||
|
||||
form_meta_xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
f' version="{format_version}">\n'
|
||||
f'<MetaDataObject {xmlns_decl} version="{format_version}">\n'
|
||||
f'\t<Form uuid="{form_uuid}">\n'
|
||||
'\t\t<Properties>\n'
|
||||
f'\t\t\t<Name>{form_name}</Name>\n'
|
||||
@@ -369,37 +515,22 @@ def main():
|
||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>\n'
|
||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>\n'
|
||||
'\t\t\t</UsePurposes>\n'
|
||||
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
|
||||
+ ('\t\t\t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>\n'
|
||||
if format_rank(format_version) >= 221 else '')
|
||||
+ ('\t\t\t<ExtendedPresentation/>\n' if object_type in processor_like_types else '')
|
||||
+ '\t\t</Properties>\n'
|
||||
'\t</Form>\n'
|
||||
'</MetaDataObject>'
|
||||
)
|
||||
|
||||
write_text_with_bom(form_meta_path, form_meta_xml)
|
||||
write_xml_file(form_meta_path, form_meta_xml)
|
||||
|
||||
# --- 3b. Form.xml ---
|
||||
|
||||
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
|
||||
|
||||
form_ns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
|
||||
' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
if purpose in ("List", "Choice"):
|
||||
# Dynamic list
|
||||
main_table = f"{object_type}.{object_name}"
|
||||
@@ -498,7 +629,7 @@ def main():
|
||||
if os.path.exists(form_xml_path):
|
||||
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
|
||||
else:
|
||||
write_text_with_bom(form_xml_path, form_xml)
|
||||
write_xml_file(form_xml_path, form_xml)
|
||||
|
||||
# --- 3c. Module.bsl ---
|
||||
|
||||
@@ -529,7 +660,10 @@ def main():
|
||||
if os.path.exists(module_path):
|
||||
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
|
||||
else:
|
||||
write_text_with_bom(module_path, module_bsl)
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
# --- Phase 4: Register in parent object ---
|
||||
|
||||
@@ -539,47 +673,50 @@ def main():
|
||||
print(f"Не найден элемент ChildObjects в {object_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Add <Form>$FormName</Form>
|
||||
form_elem = etree.Element(f"{{{ns}}}Form")
|
||||
form_elem.text = form_name
|
||||
# Add <Form>$FormName</Form> — idempotent (do not duplicate already-registered form)
|
||||
already_registered = child_objects.find(f"md:Form[.='{form_name}']", NSMAP) is not None
|
||||
|
||||
# Find first <Template> to insert before it
|
||||
first_template = child_objects.find("md:Template", NSMAP)
|
||||
# Find first <TabularSection> to insert before it (if no Template)
|
||||
first_tabular = child_objects.find("md:TabularSection", NSMAP)
|
||||
if not already_registered:
|
||||
form_elem = etree.Element(f"{{{ns}}}Form")
|
||||
form_elem.text = form_name
|
||||
|
||||
# Determine insertion point: before Template, before TabularSection, or at end
|
||||
insert_before = None
|
||||
if first_template is not None:
|
||||
insert_before = first_template
|
||||
elif first_tabular is not None:
|
||||
insert_before = first_tabular
|
||||
# Find first <Template> to insert before it
|
||||
first_template = child_objects.find("md:Template", NSMAP)
|
||||
# Find first <TabularSection> to insert before it (if no Template)
|
||||
first_tabular = child_objects.find("md:TabularSection", NSMAP)
|
||||
|
||||
if insert_before is not None:
|
||||
# Insert before the found element
|
||||
idx = list(child_objects).index(insert_before)
|
||||
child_objects.insert(idx, form_elem)
|
||||
# Whitespace: form_elem gets "\n\t\t\t" as tail (indent before insert_before)
|
||||
form_elem.tail = "\n\t\t\t"
|
||||
else:
|
||||
# Add to end of ChildObjects
|
||||
children = list(child_objects)
|
||||
if len(children) == 0 and (child_objects.text is None or child_objects.text.strip() == ""):
|
||||
# Empty ChildObjects (self-closing)
|
||||
child_objects.text = "\n\t\t\t"
|
||||
child_objects.append(form_elem)
|
||||
form_elem.tail = "\n\t\t"
|
||||
# Determine insertion point: before Template, before TabularSection, or at end
|
||||
insert_before = None
|
||||
if first_template is not None:
|
||||
insert_before = first_template
|
||||
elif first_tabular is not None:
|
||||
insert_before = first_tabular
|
||||
|
||||
if insert_before is not None:
|
||||
# Insert before the found element
|
||||
idx = list(child_objects).index(insert_before)
|
||||
child_objects.insert(idx, form_elem)
|
||||
# Whitespace: form_elem gets "\n\t\t\t" as tail (indent before insert_before)
|
||||
form_elem.tail = "\n\t\t\t"
|
||||
else:
|
||||
if len(children) > 0:
|
||||
last_child = children[-1]
|
||||
old_tail = last_child.tail
|
||||
last_child.tail = "\n\t\t\t"
|
||||
child_objects.append(form_elem)
|
||||
form_elem.tail = old_tail if old_tail else "\n\t\t"
|
||||
else:
|
||||
child_objects.text = (child_objects.text or "") + "\n\t\t\t"
|
||||
# Add to end of ChildObjects
|
||||
children = list(child_objects)
|
||||
if len(children) == 0 and (child_objects.text is None or child_objects.text.strip() == ""):
|
||||
# Empty ChildObjects (self-closing)
|
||||
child_objects.text = "\n\t\t\t"
|
||||
child_objects.append(form_elem)
|
||||
form_elem.tail = "\n\t\t"
|
||||
else:
|
||||
if len(children) > 0:
|
||||
last_child = children[-1]
|
||||
old_tail = last_child.tail
|
||||
last_child.tail = "\n\t\t\t"
|
||||
child_objects.append(form_elem)
|
||||
form_elem.tail = old_tail if old_tail else "\n\t\t"
|
||||
else:
|
||||
child_objects.text = (child_objects.text or "") + "\n\t\t\t"
|
||||
child_objects.append(form_elem)
|
||||
form_elem.tail = "\n\t\t"
|
||||
|
||||
# --- SetDefault ---
|
||||
|
||||
@@ -624,7 +761,10 @@ def main():
|
||||
print(f" Form: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form.xml")
|
||||
print(f" Module: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form\\Module.bsl")
|
||||
print()
|
||||
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
||||
if already_registered:
|
||||
print(f"Already registered: <Form>{form_name}</Form> in ChildObjects (skipped duplicate)")
|
||||
else:
|
||||
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
||||
if default_updated:
|
||||
print(f"{default_prop_name}: {default_value}")
|
||||
print()
|
||||
@@ -29,10 +29,10 @@ allowed-tools:
|
||||
|
||||
```powershell
|
||||
# Режим JSON DSL
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -JsonPath "<json>" -OutputPath "<Form.xml>"
|
||||
python ".agents/skills/form-compile/scripts/form-compile.py" -JsonPath "<json>" -OutputPath "<Form.xml>"
|
||||
|
||||
# Режим from-object (объект и purpose выводятся из OutputPath; Document и Catalog)
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -FromObject -OutputPath "<.../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml>"
|
||||
python ".agents/skills/form-compile/scripts/form-compile.py" -FromObject -OutputPath "<.../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml>"
|
||||
```
|
||||
|
||||
## JSON DSL — справка
|
||||
@@ -187,6 +187,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
|
||||
| `showTitle: true` | Показывать заголовок группы |
|
||||
| `united: false` | Левый край полей ввода выравнивается только в пределах этой группы (по умолчанию `true` — сквозное выравнивание по самому длинному заголовку, в т.ч. с соседними группами) |
|
||||
| `collapsed: true` | Для `behavior: "collapsible"` / `"popup"` — группа создаётся свёрнутой |
|
||||
| `controlRepresentation` | Отображение управления свёрткой (`behavior: "collapsible"`): `"TitleHyperlink"` (гиперссылка заголовка, по умолчанию) / `"Picture"` (картинка) |
|
||||
| `representation` | `"none"`, `"normal"`, `"weak"`, `"strong"` |
|
||||
| `children: [...]` | Вложенные элементы |
|
||||
|
||||
@@ -549,8 +550,8 @@ PictureField, привязанный к булеву/числу, рисует и
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Компиляция**: `/form-compile` генерирует `Form.xml` и автоматически регистрирует `<Form>` в `ChildObjects` родительского объекта (если OutputPath следует конвенции `.../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml`).
|
||||
2. **Метаданные формы** (`ФормаСписка.xml`) и `Module.bsl` создаёт `/form-add`. Если `/form-add` ещё не вызывался — вызови после `/form-compile`. Он не перезаписывает существующий Form.xml.
|
||||
1. **Каркас**: `/form-add` создаёт метаданные формы (`ФормаСписка.xml`), `Module.bsl` и регистрирует форму у объекта.
|
||||
2. **Компиляция**: `/form-compile` наполняет `Form.xml` элементами.
|
||||
3. **Проверка**: `/form-validate`, `/form-info`.
|
||||
|
||||
## Верификация
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user