From a0f2c4988aa48bac286edcf7a4d7409678f63725 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Fri, 4 Sep 2026 16:31:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(db-cfe-admin):=20=D0=BD=D0=B0=D0=B2=D1=8B?= =?UTF-8?q?=D0=BA=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Расширение можно было положить в базу, но нельзя было посмотреть, что там лежит, в каком оно состоянии, применяется ли, и убрать лишнее — всё это делалось руками через 1cv8 и ibcmd. Четыре команды: list (состав и свойства подключения), check (применимость и синтаксический контроль), set-properties (безопасный режим, активность, защита от опасных действий, область действия, профиль, РИБ), delete. Конструкция опирается на замеры платформы (8.3.24.1691 и 8.3.27.1859): - /CheckModules не нужен: /CheckConfig с теми же контекстными флагами даёт дословно тот же вывод и код возврата, но умеет вдобавок конфигурационные проверки. Одна команда платформы вместо двух, при запросе modules+config — один запуск; - обе проверки БЕЗ флагов контекста рапортуют «ошибок не обнаружено» с кодом 0 на заведомо сломанном модуле, поэтому набор контекстов всегда явный; - применимость и синтаксис друг друга не заменяют (первая слепа к синтаксису, второй — к дрейфу контроля), отсюда умолчание apply,modules; - коды возврата разные: применимость 1, /CheckConfig 101; - /DeleteCfg -Extension "" возвращает 0, рапортует успех и удаляет ПЕРВОЕ расширение из списка, поэтому пустое имя отбивается до вызова платформы, а отсутствие имени никогда не значит «все»; - список и удаление делает Конфигуратор (работает всегда и на серверной базе), свойства — ibcmd, которого в установке платформы может не быть: тогда колонки помечены прочерком с названной причиной, а set-properties отказывает внятно. Значения флагов словесные (on/off), а не +/-: значение "-" через powershell.exe -File парсер съедает молча — проверено, у соседнего db-update -Dynamic "-" по этой причине не работает вовсе. delete и set-properties проверяют результат перечитыванием состояния, а не кодом возврата платформы. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QoAJmoNbgWKobA7JGgN5S3 --- .claude/skills/db-cfe-admin/SKILL.md | 119 ++ .../db-cfe-admin/scripts/db-cfe-admin.ps1 | 992 ++++++++++++++++ .../db-cfe-admin/scripts/db-cfe-admin.py | 1001 +++++++++++++++++ README.md | 6 +- docs/db-guide.md | 1 + tests/skills/cases/db-cfe-admin/_skill.json | 5 + .../cases/db-cfe-admin/check-apply-fails.json | 29 + .../cases/db-cfe-admin/check-clean.json | 26 + .../db-cfe-admin/check-modules-fails.json | 28 + .../check-multiple-extensions.json | 27 + .../command-check-all-extensions.json | 21 + .../db-cfe-admin/command-check-config.json | 26 + .../db-cfe-admin/command-check-default.json | 27 + .../cases/db-cfe-admin/command-list.json | 19 + .../delete-not-actually-deleted.json | 24 + .../db-cfe-admin/delete-postcondition.json | 21 + .../cases/db-cfe-admin/error-all-on-list.json | 20 + .../error-context-without-modules.json | 23 + .../error-delete-without-target.json | 19 + .../cases/db-cfe-admin/error-empty-name.json | 21 + .../db-cfe-admin/error-name-and-all.json | 22 + .../cases/db-cfe-admin/error-no-command.json | 17 + .../db-cfe-admin/error-set-without-ibcmd.json | 26 + .../db-cfe-admin/error-set-without-props.json | 21 + .../db-cfe-admin/error-unknown-check.json | 21 + .../db-cfe-admin/error-unknown-command.json | 19 + .../db-cfe-admin/error-unknown-context.json | 21 + .../db-cfe-admin/list-with-properties.json | 39 + .../db-cfe-admin/list-without-ibcmd.json | 25 + .../registered-base-repository.json | 28 + .../cases/db-cfe-admin/secrets-masked.json | 27 + tests/skills/check-inline-drift.mjs | 26 +- 32 files changed, 2733 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/db-cfe-admin/SKILL.md create mode 100644 .claude/skills/db-cfe-admin/scripts/db-cfe-admin.ps1 create mode 100644 .claude/skills/db-cfe-admin/scripts/db-cfe-admin.py create mode 100644 tests/skills/cases/db-cfe-admin/_skill.json create mode 100644 tests/skills/cases/db-cfe-admin/check-apply-fails.json create mode 100644 tests/skills/cases/db-cfe-admin/check-clean.json create mode 100644 tests/skills/cases/db-cfe-admin/check-modules-fails.json create mode 100644 tests/skills/cases/db-cfe-admin/check-multiple-extensions.json create mode 100644 tests/skills/cases/db-cfe-admin/command-check-all-extensions.json create mode 100644 tests/skills/cases/db-cfe-admin/command-check-config.json create mode 100644 tests/skills/cases/db-cfe-admin/command-check-default.json create mode 100644 tests/skills/cases/db-cfe-admin/command-list.json create mode 100644 tests/skills/cases/db-cfe-admin/delete-not-actually-deleted.json create mode 100644 tests/skills/cases/db-cfe-admin/delete-postcondition.json create mode 100644 tests/skills/cases/db-cfe-admin/error-all-on-list.json create mode 100644 tests/skills/cases/db-cfe-admin/error-context-without-modules.json create mode 100644 tests/skills/cases/db-cfe-admin/error-delete-without-target.json create mode 100644 tests/skills/cases/db-cfe-admin/error-empty-name.json create mode 100644 tests/skills/cases/db-cfe-admin/error-name-and-all.json create mode 100644 tests/skills/cases/db-cfe-admin/error-no-command.json create mode 100644 tests/skills/cases/db-cfe-admin/error-set-without-ibcmd.json create mode 100644 tests/skills/cases/db-cfe-admin/error-set-without-props.json create mode 100644 tests/skills/cases/db-cfe-admin/error-unknown-check.json create mode 100644 tests/skills/cases/db-cfe-admin/error-unknown-command.json create mode 100644 tests/skills/cases/db-cfe-admin/error-unknown-context.json create mode 100644 tests/skills/cases/db-cfe-admin/list-with-properties.json create mode 100644 tests/skills/cases/db-cfe-admin/list-without-ibcmd.json create mode 100644 tests/skills/cases/db-cfe-admin/registered-base-repository.json create mode 100644 tests/skills/cases/db-cfe-admin/secrets-masked.json diff --git a/.claude/skills/db-cfe-admin/SKILL.md b/.claude/skills/db-cfe-admin/SKILL.md new file mode 100644 index 000000000..721942e68 --- /dev/null +++ b/.claude/skills/db-cfe-admin/SKILL.md @@ -0,0 +1,119 @@ +--- +name: db-cfe-admin +description: Управление расширениями конфигурации в информационной базе 1С. Используй когда нужно узнать какие расширения подключены к базе, выполнить проверку применимости или синтаксическую проверку, изменить безопасный режим или активность, удалить расширение из базы +argument-hint: [database] [-Name <Имя>] +allowed-tools: + - Bash + - Read + - Glob + - AskUserQuestion +--- + +# /db-cfe-admin — Управление расширениями конфигурации + +Расширения **на стороне базы**: состав и свойства подключения, проверки, удаление. +Про исходники расширения — другие навыки, см. «Смежное». + +## Usage + +``` +/db-cfe-admin list [database] +/db-cfe-admin check [database] [-Name Расш1] +/db-cfe-admin set-properties [database] -Name Расш1 -SafeMode off +/db-cfe-admin delete [database] -Name Расш1 +``` + +## Параметры подключения + +Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу: +1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую +2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json` +3. Если не указал — сопоставь текущую ветку Git с `databases[].branches` +4. Если ветка не совпала — используй `default` + +Если `v8path` не задан — скрипт сам попытается определить платформу. + +## Команда + +```powershell +powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-cfe-admin.ps1" -Command <команда> <параметры> +``` + +### Общие параметры + +| Параметр | Обязательный | Описание | +|----------|:------------:|----------| +| `-Command <команда>` | да | `list` / `check` / `set-properties` / `delete` | +| `-V8Path <путь>` | нет | Каталог bin платформы или полный путь к `1cv8.exe` / `ibcmd.exe` | +| `-InfoBasePath <путь>` | * | Файловая база | +| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) | +| `-InfoBaseRef <имя>` | * | Имя базы на сервере | +| `-UserName <имя>` | нет | Пользователь базы | +| `-Password <пароль>` | нет | Пароль пользователя | +| `-Name <имя>` | усл. | Расширение. Обязателен для `set-properties`; в `delete` — вместо `-All`. Без него `list` и `check` работают по всем расширениям | +| `-All` | усл. | Только для `delete`: удалить все расширения. Вместо `-Name`, а не вместе с ним | +| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы `1cv8.exe` через запятую | +| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` | + +> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef` + +### Параметры `check` + +| Параметр | Описание | +|----------|----------| +| `-Checks <список>` | `apply` — применимость расширения, `modules` — синтаксическая проверка, `config` — проверки конфигурации (целостность, ссылки, неиспользуемые процедуры и обработчики). Через запятую, по умолчанию `apply,modules` | +| `-Context <список>` | Контексты синтаксической проверки: `ThinClient`, `WebClient`, `Server`, `ExternalConnection`, `ThickClientManagedApplication`, `ThickClientOrdinaryApplication`, `MobileClient` и др. Через запятую, по умолчанию `ThinClient,Server` | + +Применимость и синтаксис проверяют разное и друг друга не заменяют. Проверяется только +расширение: ошибки самой конфигурации сюда не попадают. + +### Свойства `set-properties` + +| Параметр | Описание | +|----------|----------| +| `-SafeMode ` | Безопасный режим | +| `-Active ` | Активность расширения | +| `-UnsafeActionProtection ` | Защита от опасных действий | +| `-UsedInDistributedInfobase ` | Использование в распределённой ИБ | +| `-Scope <область>` | `infobase` / `data-separation` | +| `-SecurityProfile <имя>` | Профиль безопасности | + +Передавай только то, что меняешь: не указанное свойство остаётся как было. Расширение, впервые +попавшее в базу загрузкой, создаётся с включённым безопасным режимом, а в нём расширение модуля не +применяется. + +Свойствами управляет `ibcmd` — он есть не в каждой установке платформы и работает с файловой базой; +остальные команды работают всегда. + +## Смежное + +| Задача | Навык | +|--------|-------| +| Создать расширение, заимствовать объекты, перехватить метод | `/cfe-init`, `/cfe-borrow`, `/cfe-patch-method`, `/cfe-validate` | +| Загрузить исходники расширения в базу | `/db-load-xml -Extension` (из коммита Git — `/db-load-git`) | +| Загрузить готовый `.cfe` | `/db-load-cf -Extension` | +| Выгрузить расширение из базы | `/db-dump-xml -Extension`, `/db-dump-cf -Extension` | +| Обновить конфигурацию базы после загрузки | `/db-update -Extension` | +| Проверить дрейф контролируемых методов по исходникам | `/cfe-patch-method -Check` | + +## Примеры + +```powershell +# Что подключено к базе +... -Command list -InfoBasePath "C:\Bases\MyDB" + +# Проверить расширение +... -Command check -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" + +# Синтаксическая проверка в контексте веб-клиента +... -Command check -InfoBasePath "C:\Bases\MyDB" -Checks modules -Context WebClient,Server + +# Снять безопасный режим +... -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" -SafeMode off + +# Отключить, не удаляя +... -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" -Active off + +# Убрать расширение из базы +... -Command delete -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" +``` diff --git a/.claude/skills/db-cfe-admin/scripts/db-cfe-admin.ps1 b/.claude/skills/db-cfe-admin/scripts/db-cfe-admin.ps1 new file mode 100644 index 000000000..11306376e --- /dev/null +++ b/.claude/skills/db-cfe-admin/scripts/db-cfe-admin.ps1 @@ -0,0 +1,992 @@ +# db-cfe-admin v1.0 — Configuration extensions in a 1C infobase: list, check, properties, delete +# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +# NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. +<# +.SYNOPSIS + Расширения конфигурации в информационной базе 1С + +.DESCRIPTION + list — что за расширения в базе и в каком они состоянии + check — применимость и синтаксический контроль + set-properties — активность, безопасный режим, защита от опасных действий и прочие свойства + delete — удаление расширения из базы + +.PARAMETER Command + list | check | set-properties | delete + +.EXAMPLE + .\db-cfe-admin.ps1 -Command list -InfoBasePath "C:\Bases\MyDB" + +.EXAMPLE + .\db-cfe-admin.ps1 -Command check -InfoBasePath "C:\Bases\MyDB" -Name "МоёРасширение" + +.EXAMPLE + .\db-cfe-admin.ps1 -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "МоёРасширение" -SafeMode "-" +#> + +[CmdletBinding(PositionalBinding=$false)] +param( + # Не Mandatory: обязательный параметр PowerShell запрашивает интерактивно, а в пакетном + # запуске это зависание. Пустое значение проверяем сами. + [Parameter(Mandatory=$false)] + [string]$Command, + + [Parameter(Mandatory=$false)] + [string]$V8Path, + + [Parameter(Mandatory=$false)] + [string]$InfoBasePath, + + [Parameter(Mandatory=$false)] + [string]$InfoBaseServer, + + [Parameter(Mandatory=$false)] + [string]$InfoBaseRef, + + [Parameter(Mandatory=$false)] + [string]$UserName, + + [Parameter(Mandatory=$false)] + [string]$Password, + + [Parameter(Mandatory=$false)] + [string]$Name, + + [Parameter(Mandatory=$false)] + [switch]$All, + + [Parameter(Mandatory=$false)] + [string]$Checks, + + [Parameter(Mandatory=$false)] + [string]$Context, + + # Тристабильные флаги: on включить, off выключить, не указан — не трогать. + # Значение "-" через powershell.exe -File парсер съедает молча (проверено), поэтому + # каноническая форма словесная; "+"/"-" принимаются, но в инструкции не значатся. + [Parameter(Mandatory=$false)] + [ValidateSet("on", "off", "yes", "no", "+", "-")] + [string]$SafeMode, + + [Parameter(Mandatory=$false)] + [ValidateSet("on", "off", "yes", "no", "+", "-")] + [string]$Active, + + [Parameter(Mandatory=$false)] + [ValidateSet("on", "off", "yes", "no", "+", "-")] + [string]$UnsafeActionProtection, + + [Parameter(Mandatory=$false)] + [ValidateSet("on", "off", "yes", "no", "+", "-")] + [string]$UsedInDistributedInfobase, + + [Parameter(Mandatory=$false)] + [ValidateSet("infobase", "data-separation")] + [string]$Scope, + + [Parameter(Mandatory=$false)] + [string]$SecurityProfile, + + [Parameter(Mandatory=$false)] + [string]$RepositoryPath, + + [Parameter(Mandatory=$false)] + [string]$RepositoryUser, + + [Parameter(Mandatory=$false)] + [string]$RepositoryPassword, + + [Parameter(Mandatory=$false)] + [string[]]$AdditionalV8Arguments = @(), + + [Parameter(Mandatory=$false)] + [string[]]$AdditionalIbcmdArguments = @() +) + +$OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# Общий блок группы db-*: реквизиты хранилища, дополнительные аргументы, запуск платформы. +# Копии держит одинаковыми tests/skills/check-inline-drift.mjs — правку вносить в навык-эталон. +$Extension = $Name + +# --- Реквизиты хранилища из .v8-project.json --- +# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[] +# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc). +function Find-V8Project([string]$startDir) { + $d = $startDir + for ($i = 0; $i -lt 20 -and $d; $i++) { + $pj = Join-Path $d ".v8-project.json" + if (Test-Path $pj) { return $pj } + $parent = [System.IO.Path]::GetDirectoryName($d) + if ($parent -eq $d) { break } + $d = $parent + } + return $null +} +function Test-SamePath { + param([string]$A, [string]$B) + if (-not $A -or -not $B) { return $false } + try { + $na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/') + $nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/') + return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase) + } catch { return $false } +} + +function Find-ProjectDatabase { + # Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена. + $pf = Find-V8Project (Get-Location).Path + if (-not $pf) { return $null } + try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null } + if (-not $proj.databases) { return $null } + foreach ($db in $proj.databases) { + if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db } + if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) { + if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and + $db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db } + } + } + return $null +} + +function Resolve-RepositorySettings { + # Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра. + $dbRec = Find-ProjectDatabase + $rec = $null + if ($dbRec) { + if ($Extension) { + # У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой + # /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>". + if ($dbRec.extensions) { + foreach ($ext in $dbRec.extensions) { + if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) { + $rec = $ext.repository + break + } + } + } + } else { + $rec = $dbRec.repository + } + } + $path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null } + $user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null } + # Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение. + $pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null } + return @{ + Path = if ($path) { $path.Trim().Trim('"') } else { $null } + User = $user + Password = $pwd + FromRegistry = [bool]($rec -and $rec.path) + DbRecord = $dbRec + } +} + +function Get-RepositoryArgs { + # Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P. + param([hashtable]$Repo) + $a = @() + if (-not $Repo -or -not $Repo.Path) { return $a } + $a += "/ConfigurationRepositoryF`"$($Repo.Path)`"" + if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" } + if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" } + return $a +} + +function Protect-Secrets { + # Redact literal secret values from a display string (String.Replace is literal, not regex). + param([string]$Text, [string[]]$Secrets) + foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } } + return $Text +} + +function Get-ExitAnnotation { + # Annotate an abnormal process exit code so a crash isn't reported as a bare number. + # A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or + # half-updated — surface that instead of a plain code. (Windows exception codes only; + # POSIX signals are handled in the .py port.) + param([int]$Code) + $win = @{ + -1073741819 = "0xC0000005 (access violation)" + -1073741515 = "0xC0000135 (missing DLL)" + -1073740791 = "0xC0000409 (stack overrun)" + } + if ($win.ContainsKey($Code)) { + return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying" + } + return "" +} + +# --- Additional platform arguments --- +$script:V8OwnedKeys = @( + 'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG', + '/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs', + '/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC', + '/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg', + '/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg', + '/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles' +) +$script:IbcmdOwnedKeys = @( + '--db-path', '--data', '--out', '--file', '--load', '--restore', + '--import', '--export', '--apply', '--force', '--create-database', + '--user', '--password' +) +$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP') +$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd') + +function Test-ArgKeyMatch { + # A token matches a key when it equals the key, or starts with it and the next + # character is not a letter — catches glued /N"user" and --password=x, while + # keeping /ClearCache distinct from /C. + param([string]$Token, [string]$Key) + if ($Token.Length -lt $Key.Length) { return $false } + if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false } + if ($Token.Length -eq $Key.Length) { return $true } + return -not [char]::IsLetter($Token[$Key.Length]) +} + +function Get-ProjectExtraArgs { + # v8args / ibcmdargs from .v8-project.json — same upward walk as v8path. + param([string]$Name) + $dir = (Get-Location).Path + while ($dir) { + $pf = Join-Path $dir ".v8-project.json" + if (Test-Path $pf) { + try { + $j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json + if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) } + } catch {} + return @() + } + $parent = Split-Path $dir -Parent + if (-not $parent -or $parent -eq $dir) { break } + $dir = $parent + } + return @() +} + +function Assert-ExtraArgs { + # The platform accepts only one batch operation, and a duplicate connection or + # output key fails with an opaque 1C error — reject what the skill owns itself. + param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints) + $paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' } + $owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys } + foreach ($tok in $ExtraArgs) { + if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') { + Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red + exit 1 + } + foreach ($k in $owned) { + if (Test-ArgKeyMatch $tok $k) { + $hint = '' + if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" } + Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red + exit 1 + } + } + } +} + +function Resolve-ExtraArgs { + # Pick the argument list for the selected engine and validate it. An explicitly passed + # parameter for the other engine is an error; the same keys coming from .v8-project.json + # simply do not apply — a project may describe both engines. + param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints) + # powershell.exe -File — how skills are invoked — cannot bind an array parameter: + # space-separated values spill into positional ones, a comma-joined list arrives as a + # single token. So accept the repo's list convention (comma-separated) and split here; + # a native array call keeps working. A value containing a comma is not supported. + $V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' }) + $IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' }) + if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) { + Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red + exit 1 + } + if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) { + Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red + exit 1 + } + if ($Engine -eq 'ibcmd') { + $extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra) + } else { + $extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra) + } + if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints } + # Plain return, no comma trick: the caller re-collects with @(...), and ,@() there + # would nest the array — the tokens would then be glued into one argument. + return $extra +} + +function Format-ArgsForDisplay { + # Redact values of secret-prone keys in glued, =-joined and separate forms. + # Matching here is a plain prefix (no letter rule): over-masking costs nothing, + # a leaked password does. + param([string[]]$ArgList, [string]$Engine) + $keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys } + $res = @() + $maskNext = $false + foreach ($tok in $ArgList) { + if ($maskNext) { $res += '***'; $maskNext = $false; continue } + $hit = $null + foreach ($k in $keys) { + if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break } + } + if (-not $hit) { $res += $tok; continue } + if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true } + elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') } + else { $res += ($hit + '***') } + } + return ,$res +} + +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' + +function Assert-InfoBaseExists { + # These skills work on a ready infobase. Saying so up front beats the platform's + # "Неверные или отсутствующие параметры соединения" after a launch. + param([string]$Path) + if (-not $Path) { return } + if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) { + Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red + exit 1 + } +} + +Assert-InfoBaseExists $InfoBasePath + +# --- Resolve V8Path --- +function Find-ProjectV8Path { + $dir = (Get-Location).Path + while ($dir) { + $pf = Join-Path $dir ".v8-project.json" + if (Test-Path $pf) { + try { + $j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json + if ($j.v8path) { return [string]$j.v8path } + } catch {} + return $null + } + $parent = Split-Path $dir -Parent + if (-not $parent -or $parent -eq $dir) { break } + $dir = $parent + } + return $null +} + +if (-not $V8Path) { + $V8Path = Find-ProjectV8Path +} +if (-not $V8Path) { + $found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue | + Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending | + Select-Object -First 1 + if ($found) { + $V8Path = $found.FullName + Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow + } else { + Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red + exit 1 + } +} +if (Test-Path $V8Path -PathType Container) { + $V8Path = Join-Path $V8Path "1cv8.exe" +} + +if (-not (Test-Path $V8Path)) { + Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red + exit 1 +} + +# --- Detect engine (ibcmd vs 1cv8) by exe name --- +function ConvertFrom-PlatformBytes { + # ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + # OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing + # one of them outright mangles Cyrillic. + param([byte[]]$Bytes) + if (-not $Bytes -or $Bytes.Length -eq 0) { return '' } + try { + $strict = New-Object System.Text.UTF8Encoding($false, $true) + return $strict.GetString($Bytes) + } catch { + return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes) + } +} + +function Invoke-PlatformProcess { + # Run the platform non-interactively and capture its console output. A closed stdin pipe + # (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's + # text out of our stream until we print it labelled (and out of the wrong encoding). + # Returns @{ Output; ExitCode }. + # + # Quoting differs by engine, so the caller says which it built: + # ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here; + # 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"), + # which is where 1C's own parser expects them; quoting again breaks the value. + param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted) + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $Exe + $psi.Arguments = if ($PreQuoted) { + $ProcArgs -join ' ' + } else { + ($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + } + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $p = [System.Diagnostics.Process]::Start($psi) + $p.StandardInput.Close() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() + $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() + if ($err) { $out += $err } + return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } +} + +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + +# --- Утилиты платформы: нужны обе, выбор по команде --- +# -V8Path указывает на каталог bin либо на любой из двух исполняемых файлов; второй берётся соседом. +$binDir = Split-Path $V8Path -Parent +$exeLeaf = Split-Path $V8Path -Leaf +# Расширение файла сохраняем: на Windows это .exe, на *nix его нет, в тестах — .cmd/.sh. +$exeSuffix = [System.IO.Path]::GetExtension($V8Path) +if ($exeLeaf -match '^ibcmd') { + $ibcmdExe = $V8Path + $v8Exe = Join-Path $binDir ("1cv8" + $exeSuffix) +} else { + $v8Exe = $V8Path + $ibcmdExe = Join-Path $binDir ("ibcmd" + $exeSuffix) +} +$hasV8 = Test-Path $v8Exe +$hasIbcmd = Test-Path $ibcmdExe + +# --- Разбор и проверка команды --- +$knownCommands = @('list', 'check', 'set-properties', 'delete') +$cmd = if ($Command) { $Command.Trim().ToLower() } else { '' } +if (-not $cmd) { + Write-Host "Error: specify a command: $($knownCommands -join ' | ')" -ForegroundColor Red + exit 1 +} +if ($knownCommands -notcontains $cmd) { + Write-Host "Error: unknown command '$Command' (expected: $($knownCommands -join ' | '))" -ForegroundColor Red + exit 1 +} + +# Пустое имя платформа трактует разрушительно: /DeleteCfg -Extension "" удаляет первое расширение +# из списка и рапортует успех. Поэтому пустое значение не доходит до платформы ни в одной команде. +if ($PSBoundParameters.ContainsKey('Name') -and [string]::IsNullOrWhiteSpace($Name)) { + Write-Host "Error: -Name is empty; omit it to address all extensions, or pass a name" -ForegroundColor Red + exit 1 +} +$hasName = -not [string]::IsNullOrWhiteSpace($Name) +if ($hasName) { $Name = $Name.Trim() } + +if ($All -and $cmd -ne 'delete') { + Write-Host "Error: -All applies to delete only (list and check address all extensions when -Name is omitted)" -ForegroundColor Red + exit 1 +} +if ($cmd -eq 'delete') { + if ($hasName -and $All) { + Write-Host "Error: -Name and -All are mutually exclusive - pass one or the other" -ForegroundColor Red + exit 1 + } + if (-not $hasName -and -not $All) { + Write-Host "Error: specify -Name or -All (an omitted name never means all)" -ForegroundColor Red + exit 1 + } +} +if ($cmd -eq 'set-properties' -and -not $hasName) { + Write-Host "Error: set-properties needs -Name " -ForegroundColor Red + exit 1 +} + +# --- Проверки (-Checks) и контексты (-Context) --- +$knownChecks = @('apply', 'modules', 'config') +$checkList = @() +if ($Checks) { + $checkList = @($Checks -split ',' | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }) + foreach ($c in $checkList) { + if ($knownChecks -notcontains $c) { + Write-Host "Error: unknown check '$c' (expected: $($knownChecks -join ', '))" -ForegroundColor Red + exit 1 + } + } +} +if ($checkList.Count -eq 0) { $checkList = @('apply', 'modules') } + +$knownContexts = @('ThinClient', 'WebClient', 'MobileClient', 'MobileClientStandalone', 'MobileAppClient', + 'Server', 'MobileAppServer', 'ExternalConnection', 'ExternalConnectionServer', + 'ThickClientManagedApplication', 'ThickClientServerManagedApplication', + 'ThickClientOrdinaryApplication', 'ThickClientServerOrdinaryApplication') +$contextList = @() +if ($Context) { + foreach ($c in @($Context -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { + $match = $knownContexts | Where-Object { $_.Equals($c, [System.StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1 + if (-not $match) { + Write-Host "Error: unknown context '$c' (expected: $($knownContexts -join ', '))" -ForegroundColor Red + exit 1 + } + $contextList += $match + } +} +if ($Context -and $checkList.Count -gt 0 -and $checkList -notcontains 'modules') { + Write-Host "Error: -Context applies to the syntax check - add 'modules' to -Checks" -ForegroundColor Red + exit 1 +} +if ($contextList.Count -eq 0) { $contextList = @('ThinClient', 'Server') } + +# --- Свойства для set-properties --- +$script:propRu = @{ + 'safe-mode' = 'безопасный режим' + 'active' = 'активно' + 'unsafe-action-protection' = 'защита от опасных действий' + 'used-in-distributed-infobase' = 'используется в РИБ' + 'scope' = 'область действия' + 'security-profile-name' = 'профиль безопасности' + 'purpose' = 'назначение' + 'version' = 'версия' +} +function Get-PropRu { + param([string]$Key) + if ($script:propRu.ContainsKey($Key)) { return $script:propRu[$Key] } + return $Key +} + +function Convert-FlagValue { + param([string]$Value) + if (@('on', 'yes', '+') -contains $Value.ToLower()) { return 'yes' } + return 'no' +} + +$propFlags = [ordered]@{} +if ($SafeMode) { $propFlags['safe-mode'] = (Convert-FlagValue $SafeMode) } +if ($Active) { $propFlags['active'] = (Convert-FlagValue $Active) } +if ($UnsafeActionProtection) { $propFlags['unsafe-action-protection'] = (Convert-FlagValue $UnsafeActionProtection) } +if ($UsedInDistributedInfobase) { $propFlags['used-in-distributed-infobase'] = (Convert-FlagValue $UsedInDistributedInfobase) } +if ($Scope) { $propFlags['scope'] = $Scope } +if ($PSBoundParameters.ContainsKey('SecurityProfile')) { $propFlags['security-profile-name'] = $SecurityProfile } +if ($cmd -eq 'set-properties' -and $propFlags.Count -eq 0) { + Write-Host "Error: set-properties needs at least one property (-SafeMode, -Active, -UnsafeActionProtection, -UsedInDistributedInfobase, -Scope, -SecurityProfile)" -ForegroundColor Red + exit 1 +} + +# --- Соединение --- +if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) { + Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red + exit 1 +} + +# --- Дополнительные аргументы: у каждой утилиты свои --- +$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' } +$v8Extra = @(Resolve-ExtraArgs '1cv8' $AdditionalV8Arguments @() $argHints) +$ibExtra = @(Resolve-ExtraArgs 'ibcmd' @() $AdditionalIbcmdArguments $argHints) +if ($AdditionalIbcmdArguments.Count -gt 0 -and @('check', 'delete') -contains $cmd) { + Write-Host "Error: -AdditionalIbcmdArguments does not apply to '$cmd' - it runs the Designer only" -ForegroundColor Red + exit 1 +} + +$script:repoSettings = Resolve-RepositorySettings +$baseLabel = if ($InfoBasePath) { $InfoBasePath } else { "$InfoBaseServer/$InfoBaseRef" } + +# --- Запуск Конфигуратора: соединение, реквизиты хранилища и /Out навык держит сам --- +function Invoke-Designer { + param([string[]]$OpArgs) + if (-not $hasV8) { + Write-Host "Error: 1C executable not found at $v8Exe" -ForegroundColor Red + exit 1 + } + $tempDir = Join-Path $env:TEMP "db_cfe_admin_$(Get-Random)" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + try { + $arguments = @("DESIGNER") + if ($InfoBaseServer -and $InfoBaseRef) { + $arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`"" + } else { + $arguments += "/F", "`"$InfoBasePath`"" + } + if ($UserName) { $arguments += "/N`"$UserName`"" } + if ($Password) { $arguments += "/P`"$Password`"" } + # База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов. + $arguments += Get-RepositoryArgs $script:repoSettings + $arguments += $OpArgs + $outFile = Join-Path $tempDir "out.txt" + $arguments += "/Out", "`"$outFile`"" + $arguments += "/DisableStartupDialogs" + $arguments += $v8Extra + Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments '1cv8') -join ' ') @($Password, $UserName, $script:repoSettings.Password))" + $res = Invoke-PlatformProcess $v8Exe $arguments -PreQuoted + $log = '' + if (Test-Path $outFile) { + $raw = Get-Content $outFile -Raw -ErrorAction SilentlyContinue + if ($raw) { $log = $raw.Trim() } + } + return @{ + ExitCode = $res.ExitCode + Log = $log + Output = $res.Output + } + } finally { + if (Test-Path $tempDir) { Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +function Invoke-Ibcmd { + param([string[]]$OpArgs) + $arguments = @($OpArgs) + $arguments += "--db-path=$InfoBasePath" + if ($UserName) { $arguments += "--user=$UserName" } + if ($Password) { $arguments += "--password=$Password" } + $arguments += $ibExtra + Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments 'ibcmd') -join ' ') @($Password, $UserName))" + $res = Invoke-PlatformProcess $ibcmdExe $arguments + return @{ ExitCode = $res.ExitCode; Output = $res.Output } +} + +function Write-PlatformFailure { + # Единый разбор неуспеха: что запускали, чем ответила платформа. + param($Result, [string]$What) + Write-Host "Error: $What (code: $($Result.ExitCode))$(Get-ExitAnnotation $Result.ExitCode)" -ForegroundColor Red + if ($Result.Log) { + Write-Host "--- Log ---" + Write-Host $Result.Log + Write-Host "--- End ---" + } + Write-PlatformOutput $Result.Output +} + +# --- Свойства расширений: только ibcmd, и только для файловой базы --- +function Get-PropertiesUnavailableReason { + if (-not $InfoBasePath) { return "свойства читает ibcmd, а он подключается к файловой базе (--db-path)" } + if (-not $hasIbcmd) { return "рядом с 1cv8 нет ibcmd ($ibcmdExe) - эта установка платформы его не содержит" } + return $null +} + +function ConvertFrom-IbcmdRecords { + # Вывод ibcmd: строки «ключ : значение», записи разделены пустой строкой. + param([string]$Text) + $records = @() + $cur = [ordered]@{} + foreach ($line in ($Text -split "`r?`n")) { + if ([string]::IsNullOrWhiteSpace($line)) { + if ($cur.Count -gt 0) { $records += ,$cur; $cur = [ordered]@{} } + continue + } + $idx = $line.IndexOf(':') + if ($idx -lt 0) { continue } + $key = $line.Substring(0, $idx).Trim() + $val = $line.Substring($idx + 1).Trim().Trim('"') + if ($key) { $cur[$key] = $val } + } + if ($cur.Count -gt 0) { $records += ,$cur } + return $records +} + +function Get-ExtensionProperties { + # Хеш «имя расширения» -> запись свойств. Пустой, если ibcmd недоступен. + if (Get-PropertiesUnavailableReason) { return @{} } + $r = Invoke-Ibcmd @('infobase', 'config', 'extension', 'list') + if ($r.ExitCode -ne 0) { return @{} } + $map = @{} + foreach ($rec in (ConvertFrom-IbcmdRecords $r.Output)) { + if ($rec['name']) { $map[[string]$rec['name']] = $rec } + } + return $map +} + +function Get-ExtensionNames { + # Имена расширений базы - Конфигуратором, чтобы работало и без ibcmd, и на серверной базе. + $r = Invoke-Designer @('/DumpDBCfgList', '-AllExtensions') + if ($r.ExitCode -ne 0) { + Write-PlatformFailure $r "cannot list extensions" + exit 1 + } + return @($r.Log -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +} + +# --- Человекочитаемые значения свойств --- +$script:flagRu = @{ 'yes' = 'да'; 'no' = 'нет' } +$script:scopeRu = @{ 'infobase' = 'Информационная база'; 'data-separation' = 'Область данных' } +$script:purposeRu = @{ 'customization' = 'Адаптация'; 'add-on' = 'Дополнение'; 'patch' = 'Исправление' } + +function Format-PropValue { + param([string]$Key, $Value) + if ($null -eq $Value -or $Value -eq '') { return '' } + $v = [string]$Value + if ($Key -eq 'scope' -and $script:scopeRu.ContainsKey($v)) { return $script:scopeRu[$v] } + if ($Key -eq 'purpose' -and $script:purposeRu.ContainsKey($v)) { return $script:purposeRu[$v] } + if ($script:flagRu.ContainsKey($v)) { return $script:flagRu[$v] } + return $v +} + +function Get-PropCell { + # Значение свойства для таблицы: «—», когда свойства вообще не читались. + param($Record, [string]$Key) + if (-not $Record) { return '—' } + if ($Record[$Key]) { return (Format-PropValue $Key $Record[$Key]) } + return '' +} + +function Write-Table { + param([string[]]$Headers, $Rows) + $widths = @() + for ($i = 0; $i -lt $Headers.Count; $i++) { + $w = $Headers[$i].Length + foreach ($row in $Rows) { if (([string]$row[$i]).Length -gt $w) { $w = ([string]$row[$i]).Length } } + $widths += $w + } + $line = ' ' + for ($i = 0; $i -lt $Headers.Count; $i++) { $line += $Headers[$i].PadRight($widths[$i] + 2) } + Write-Host $line.TrimEnd() + foreach ($row in $Rows) { + $l = ' ' + for ($i = 0; $i -lt $Headers.Count; $i++) { $l += ([string]$row[$i]).PadRight($widths[$i] + 2) } + Write-Host $l.TrimEnd() + } +} + +# ============================================================================ +# Команды +# ============================================================================ + +if ($cmd -eq 'list') { + $names = @(Get-ExtensionNames) + if ($hasName) { + $names = @($names | Where-Object { $_.Equals($Name, [System.StringComparison]::OrdinalIgnoreCase) }) + if ($names.Count -eq 0) { + Write-Host "[РАСШИРЕНИЯ] $baseLabel (0)" + Write-Host " расширение '$Name' в базе не найдено" + exit 1 + } + } + Write-Host "[РАСШИРЕНИЯ] $baseLabel ($($names.Count))" + if ($names.Count -eq 0) { + Write-Host " расширений нет" + exit 0 + } + $props = Get-ExtensionProperties + $reason = Get-PropertiesUnavailableReason + $rows = @() + foreach ($n in $names) { + $rec = $props[$n] + $rows += ,@($n, + (Get-PropCell $rec 'purpose'), + (Get-PropCell $rec 'active'), + (Get-PropCell $rec 'safe-mode'), + (Get-PropCell $rec 'unsafe-action-protection'), + (Get-PropCell $rec 'used-in-distributed-infobase'), + (Get-PropCell $rec 'scope')) + } + Write-Table @('Имя', 'Назначение', 'Активно', 'Безопасный режим', 'Защита', 'РИБ', 'Область') $rows + if ($reason) { Write-Host " свойства недоступны: $reason" } + exit 0 +} + +if ($cmd -eq 'check') { + # Список расширений заранее не запрашиваем: платформа сама отвечает «расширение не найдено», + # а лишний запуск конфигуратора стоит дороже, чем разница в формулировке. + $target = if ($hasName) { $Name } else { $null } + if ($target) { + Write-Host "[ПРОВЕРКА] $baseLabel · $target" + } else { + Write-Host "[ПРОВЕРКА] $baseLabel · все расширения" + } + + $failed = 0 + $done = 0 + $rows = @() + + if ($checkList -contains 'apply') { + $opArgs = @('/CheckCanApplyConfigurationExtensions') + if ($target) { $opArgs += '-Extension', "`"$target`"" } + $r = Invoke-Designer $opArgs + $done++ + $logLines = @($r.Log -split "`r?`n" | Where-Object { $_.Trim() -ne '' }) + if ($r.ExitCode -eq 0) { + $rows += ,@{ Label = 'применимость'; Status = 'ОК'; Note = ''; Lines = @() } + } elseif ($r.ExitCode -eq 1) { + $failed++ + $rows += ,@{ Label = 'применимость'; Status = 'ОШИБКА'; Note = ''; Lines = $logLines } + } else { + $failed++ + $rows += ,@{ Label = 'применимость'; Status = 'СБОЙ'; Note = "код $($r.ExitCode)$(Get-ExitAnnotation $r.ExitCode)"; Lines = $logLines } + } + } + + # modules и config - одна и та же команда платформы с разным набором флагов, поэтому при + # запросе обеих делается ОДИН запуск. Без флагов контекста платформа рапортует «ошибок не + # обнаружено» на заведомо сломанном модуле - набор всегда явный. + $wantModules = $checkList -contains 'modules' + $wantConfig = $checkList -contains 'config' + if ($wantModules -or $wantConfig) { + $opArgs = @('/CheckConfig') + if ($wantModules) { foreach ($c in $contextList) { $opArgs += "-$c" } } + if ($wantConfig) { + $opArgs += '-ConfigLogIntegrity', '-IncorrectReferences', '-UnreferenceProcedures', '-HandlersExistence', '-EmptyHandlers' + } + if ($target) { $opArgs += '-Extension', "`"$target`"" } else { $opArgs += '-AllExtensions' } + $r = Invoke-Designer $opArgs + $label = if ($wantModules -and $wantConfig) { 'модули и конфигурация' } elseif ($wantModules) { 'модули' } else { 'конфигурация' } + $done++ + $logLines = @($r.Log -split "`r?`n" | Where-Object { $_.Trim() -ne '' }) + if ($r.ExitCode -eq 0) { + $note = if ($wantModules) { "($($contextList -join ', '))" } else { '' } + $rows += ,@{ Label = $label; Status = 'ОК'; Note = $note; Lines = @() } + } elseif ($r.ExitCode -eq 1 -or $r.ExitCode -eq 101) { + $failed++ + $rows += ,@{ Label = $label; Status = 'ОШИБКА'; Note = ''; Lines = $logLines } + } else { + $failed++ + $rows += ,@{ Label = $label; Status = 'СБОЙ'; Note = "код $($r.ExitCode)$(Get-ExitAnnotation $r.ExitCode)"; Lines = $logLines } + } + } + + # Сообщения платформы печатаются построчно под своей проверкой: они называют расширение и + # место ошибки, и при нескольких расширениях склейка в одну строку нечитаема. + $w = 0 + foreach ($row in $rows) { if ($row.Label.Length -gt $w) { $w = $row.Label.Length } } + foreach ($row in $rows) { + $l = ' ' + $row.Label.PadRight($w + 2) + $row.Status.PadRight(9) + if ($row.Note) { $l += $row.Note } + Write-Host $l.TrimEnd() + foreach ($line in $row.Lines) { Write-Host (" " + $line.Trim()) } + } + if ($failed -gt 0) { + Write-Host "Итог: провалено $failed из $done" + exit 1 + } + Write-Host "Итог: пройдено $done из $done" + exit 0 +} + +if ($cmd -eq 'set-properties') { + $reason = Get-PropertiesUnavailableReason + if ($reason) { + Write-Host "Error: cannot set properties - $reason" -ForegroundColor Red + exit 1 + } + $before = Get-ExtensionProperties + if (-not $before.ContainsKey($Name)) { + Write-Host "Error: extension '$Name' not found in the infobase" -ForegroundColor Red + exit 1 + } + $opArgs = @('infobase', 'config', 'extension', 'update', "--name=$Name") + foreach ($k in $propFlags.Keys) { $opArgs += "--$k=$($propFlags[$k])" } + $r = Invoke-Ibcmd $opArgs + if ($r.ExitCode -ne 0) { + Write-Host "Error: cannot set properties (code: $($r.ExitCode))$(Get-ExitAnnotation $r.ExitCode)" -ForegroundColor Red + Write-PlatformOutput $r.Output + exit 1 + } + # Постусловие: состояние перечитывается, а не берётся из кода возврата. + $after = Get-ExtensionProperties + if (-not $after.ContainsKey($Name)) { + Write-Host "Error: extension '$Name' disappeared after the update" -ForegroundColor Red + exit 1 + } + Write-Host "[СВОЙСТВА] $baseLabel · $Name" + $changed = 0 + $stale = @() + foreach ($k in $propFlags.Keys) { + $was = Format-PropValue $k $before[$Name][$k] + $now = Format-PropValue $k $after[$Name][$k] + $want = Format-PropValue $k $propFlags[$k] + if ($was -ne $now) { + Write-Host (' ' + (Get-PropRu $k).PadRight(30) + "$was → $now") + $changed++ + } elseif ($now -ne $want) { + $stale += "$(Get-PropRu $k): просили '$want', в базе осталось '$now'" + } + } + if ($stale.Count -gt 0) { + foreach ($s in $stale) { Write-Host " $s" -ForegroundColor Yellow } + Write-Host "Итог: изменено $changed, не применено $($stale.Count)" + exit 1 + } + if ($changed -eq 0) { Write-Host " свойства уже в этом состоянии" } + Write-Host "Итог: изменено $changed" + exit 0 +} + +if ($cmd -eq 'delete') { + $names = @(Get-ExtensionNames) + if ($names.Count -eq 0) { + Write-Host "[УДАЛЕНИЕ] $baseLabel" + Write-Host " расширений нет - удалять нечего" + exit 0 + } + $targets = @() + if ($hasName) { + $match = @($names | Where-Object { $_.Equals($Name, [System.StringComparison]::OrdinalIgnoreCase) }) + if ($match.Count -eq 0) { + Write-Host "Error: extension '$Name' not found in the infobase" -ForegroundColor Red + exit 1 + } + $targets = $match + } else { + $targets = $names + } + Write-Host "[УДАЛЕНИЕ] $baseLabel (будет удалено: $($targets.Count))" + foreach ($t in $targets) { + # Имя непустое по построению: пустое отбито разбором параметров, список получен от платформы. + $r = Invoke-Designer @('/DeleteCfg', '-Extension', "`"$t`"") + if ($r.ExitCode -ne 0) { + Write-PlatformFailure $r "cannot delete extension '$t'" + exit 1 + } + Write-Host " удалено: $t" + } + # Постусловие: список перечитывается - код возврата платформы сам по себе ничего не доказывает. + $rest = @(Get-ExtensionNames) + foreach ($t in $targets) { + if ($rest | Where-Object { $_.Equals($t, [System.StringComparison]::OrdinalIgnoreCase) }) { + Write-Host "Error: platform reported success, but '$t' is still in the infobase" -ForegroundColor Red + exit 1 + } + } + Write-Host "Итог: удалено $($targets.Count), осталось $($rest.Count)" + exit 0 +} diff --git a/.claude/skills/db-cfe-admin/scripts/db-cfe-admin.py b/.claude/skills/db-cfe-admin/scripts/db-cfe-admin.py new file mode 100644 index 000000000..77348fc5a --- /dev/null +++ b/.claude/skills/db-cfe-admin/scripts/db-cfe-admin.py @@ -0,0 +1,1001 @@ +#!/usr/bin/env python3 +# db-cfe-admin v1.0 — Configuration extensions in a 1C infobase: list, check, properties, delete +# 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 + +# Общий блок группы db-*: реквизиты хранилища, дополнительные аргументы, запуск платформы. +# Копии держит одинаковыми tests/skills/check-inline-drift.mjs — правку вносить в навык-эталон. + +def ci_parse_args(parser, argv=None): + """parse_args по правилам PS: имена параметров и значения choices регистронезависимы.""" + argv = list(sys.argv[1:] if argv is None else argv) + names = {s.lower(): s for a in parser._actions for s in a.option_strings} + for i, tok in enumerate(argv): + if tok.startswith('-') and tok.lower() in names: + argv[i] = names[tok.lower()] + # choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр + choice_map = {} + for a in parser._actions: + if a.choices: + for s in a.option_strings: + choice_map[s] = {str(c).lower(): c for c in a.choices} + for i in range(len(argv) - 1): + m = choice_map.get(argv[i]) + if m and argv[i + 1].lower() in m: + argv[i + 1] = m[argv[i + 1].lower()] + return parser.parse_args(argv) + + + +def _find_project_v8path(): + """Walk up from CWD to find .v8-project.json and read its v8path.""" + d = os.getcwd() + while True: + pf = os.path.join(d, ".v8-project.json") + if os.path.isfile(pf): + try: + with open(pf, encoding="utf-8-sig") as f: + data = json.load(f) + v = data.get("v8path") + if v: + return v + except Exception: + pass + return None + parent = os.path.dirname(d) + if parent == d: + return None + d = parent + + +# --- Additional platform arguments --- +V8_OWNED_KEYS = [ + "DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG", + "/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs", + "/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC", + "/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg", + "/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg", + "/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles", +] +IBCMD_OWNED_KEYS = [ + "--db-path", "--data", "--out", "--file", "--load", "--restore", + "--import", "--export", "--apply", "--force", "--create-database", + "--user", "--password", +] +V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"] +IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] + + +# --- Реквизиты хранилища из .v8-project.json --- +# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[] +# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc). +def _sg_find_v8project(start_dir): + d = start_dir + for _ in range(20): + if not d: + break + pj = os.path.join(d, ".v8-project.json") + if os.path.isfile(pj): + return pj + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + +def same_path(a, b): + if not a or not b: + return False + try: + return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower() + except Exception: + return False + + +def find_project_database(args): + """Запись базы в реестре, соответствующая переданному соединению. None, если не найдена.""" + pf = _sg_find_v8project(os.getcwd()) + if not pf: + return None + try: + with open(pf, encoding="utf-8-sig") as f: + proj = json.load(f) + except Exception: + return None + for db in proj.get("databases") or []: + if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath): + return db + if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"): + if (db["server"].lower() == args.InfoBaseServer.lower() + and db["ref"].lower() == args.InfoBaseRef.lower()): + return db + return None + + +def resolve_repository_settings(args): + """Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра.""" + db_rec = find_project_database(args) + rec = None + if db_rec: + if args.Extension: + # У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой + # /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>". + for ext in db_rec.get("extensions") or []: + if (ext.get("name") or "").lower() == args.Extension.lower(): + rec = ext.get("repository") + break + else: + rec = db_rec.get("repository") + path = args.RepositoryPath or ((rec or {}).get("path") or None) + user = args.RepositoryUser or ((rec or {}).get("user") or None) + # Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение. + pwd = args.RepositoryPassword or ((rec or {}).get("password") or None) + return { + "path": path.strip().strip('"') if path else None, + "user": user, + "password": pwd, + "from_registry": bool(rec and rec.get("path")), + } + + +def repository_args(repo): + """Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.""" + a = [] + if not repo or not repo.get("path"): + return a + a.append('/ConfigurationRepositoryF"%s"' % repo["path"]) + if repo.get("user"): + a.append('/ConfigurationRepositoryN"%s"' % repo["user"]) + if repo.get("password"): + a.append('/ConfigurationRepositoryP"%s"' % repo["password"]) + return a + + +def arg_key_match(token, key): + """Token matches a key when it equals it, or starts with it and the next character + is not a letter — catches glued /N"user" and --password=x, while keeping + /ClearCache distinct from /C.""" + if len(token) < len(key): + return False + if token[: len(key)].lower() != key.lower(): + return False + if len(token) == len(key): + return True + return not token[len(key)].isalpha() + + +def project_extra_args(name): + """v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.""" + d = os.getcwd() + while True: + pf = os.path.join(d, ".v8-project.json") + if os.path.isfile(pf): + try: + with open(pf, encoding="utf-8-sig") as f: + data = json.load(f) + v = data.get(name) + if v: + return [str(x) for x in v] + except Exception: + pass + return [] + parent = os.path.dirname(d) + if parent == d: + return [] + d = parent + + +def assert_extra_args(extra, engine, hints): + """The platform accepts only one batch operation, and a duplicate connection or + output key fails with an opaque 1C error — reject what the skill owns itself.""" + param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments" + owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS + for tok in extra: + if engine == "ibcmd" and not tok.startswith("-"): + print( + f"Error: '{tok}' is a positional token — pass values as --key=value " + f"({param} cannot extend the ibcmd command)", + ) + sys.exit(1) + for k in owned: + if arg_key_match(tok, k): + hint = f" (use {hints[k]})" if hints and k in hints else "" + print( + f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", + ) + sys.exit(1) + + +def format_args_for_display(arglist, engine): + """Redact values of secret-prone keys in glued, =-joined and separate forms. + Matching here is a plain prefix (no letter rule): over-masking costs nothing, + a leaked password does.""" + keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS + res = [] + mask_next = False + for tok in arglist: + if mask_next: + res.append("***") + mask_next = False + continue + hit = None + for k in keys: + if tok[: len(k)].lower() == k.lower(): + hit = k + break + if hit is None: + res.append(tok) + elif len(tok) == len(hit): + res.append(tok) + mask_next = True + elif tok[len(hit)] == "=": + res.append(hit + "=***") + else: + res.append(hit + "***") + return res + + +def extract_extra_args(argv, known_opts): + """argparse refuses values that start with '-' (every ibcmd key does), so pull the two + escape-hatch lists out of argv by hand: after the flag, take everything up to the next + declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra).""" + rest, v8, ibcmd = [], [], [] + i = 0 + while i < len(argv): + low = argv[i].lower() + if low in ("-additionalv8arguments", "-additionalibcmdarguments"): + target = v8 if low == "-additionalv8arguments" else ibcmd + i += 1 + while i < len(argv) and argv[i].lower() not in known_opts: + target.append(argv[i]) + i += 1 + continue + rest.append(argv[i]) + i += 1 + return rest, v8, ibcmd + + +def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints): + """Pick the argument list for the selected engine and validate it. An explicitly + passed parameter for the other engine is an error; the same keys coming from + .v8-project.json simply do not apply — a project may describe both engines. + + Comma-separated elements are split apart: PowerShell's -File cannot bind an array, + so that form is the documented one and both ports must accept it. A value containing + a comma is not supported.""" + v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p] + ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p] + if engine == "ibcmd" and v8_extra: + print( + "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " + "(use -AdditionalIbcmdArguments)", + ) + sys.exit(1) + if engine != "ibcmd" and ibcmd_extra: + print( + "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " + "(use -AdditionalV8Arguments)", + ) + sys.exit(1) + if engine == "ibcmd": + extra = project_extra_args("ibcmdargs") + list(ibcmd_extra) + else: + extra = project_extra_args("v8args") + list(v8_extra) + if extra: + assert_extra_args(extra, engine, hints) + return extra + + +def _version_dir(p): + """Version dir for both Windows (.../1cv8//bin/1cv8.exe) and *nix (.../1cv8//1cv8).""" + parent = os.path.dirname(p) + if os.path.basename(parent).lower() == "bin": + parent = os.path.dirname(parent) + return os.path.basename(parent) + + +def _version_key(p): + """Numeric sort key from version dir name.""" + return [int(x) for x in re.findall(r"\d+", _version_dir(p))] + + +def resolve_v8path(v8path): + """Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly).""" + if not v8path: + v8path = _find_project_v8path() + if not v8path: + if os.name == "nt": + candidates = ( + glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe") + + glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") + ) + else: + # PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1. + candidates = glob.glob("/opt/1cv8/*/1cv8") + if candidates: + v8path = max(candidates, key=_version_key) + print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") + else: + print("Error: 1C executable not found. Specify -V8Path") + sys.exit(1) + if os.path.isdir(v8path): + # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. + exe = "1cv8.exe" if os.name == "nt" else "1cv8" + v8path = os.path.join(v8path, exe) + if not os.path.isfile(v8path): + print(f"Error: 1C executable not found at {v8path}") + sys.exit(1) + return v8path + + +IBCMD_NOUSER_HINT = ( + "[ibcmd] No -UserName/-Password given; the infobase may require authentication. " + "On Windows ibcmd reads credentials from the console (stdin is ignored), so this " + "call may block instead of failing. If it does not return promptly, abort and " + "re-run with -UserName and -Password.\n" +) + + +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)") + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}") + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + + На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы + частью значения: путь с пробелом платформа не находит («Неопределена информационная + база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой + обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь", + File="…") не задеты: у них кавычки внутри токена, а не по краям. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + def strip_framing_quotes(a): + # Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX + # становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным + # токеном даёт «Неопределена информационная база», а склеенный + # /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»; + # без кавычек обе формы работают. + if len(a) > 1 and a[0] == '"' and a[-1] == '"': + return a[1:-1] # "значение" отдельным токеном + if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]: + i = a.index('"') + return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя + return a # File="…" не трогаем: там кавычки — + # часть синтаксиса строки соединения, + # и с ними на POSIX всё работает + cmd = [v8path] + [strip_framing_quotes(a) for a in arguments] + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + +def run_ibcmd(cmd, has_username=False, warn_no_user=True): + """Run an ibcmd command non-interactively. + + input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging. + On Windows without -UserName ibcmd reads the console directly and may still block — + that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). + """ + if warn_no_user and os.name == "nt" and not has_username: + sys.stdout.write(IBCMD_NOUSER_HINT) + sys.stderr.flush() + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def describe_exit(code): + """Annotate an abnormal process exit code so a crash isn't reported as a bare number. + Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run + instead of returning a clean error, possibly leaving the infobase locked or half-mutated.""" + if code is None: + return "" + win = { + 3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)", + 3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)", + 3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)", + } + if code in win: + return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying" + if -64 <= code < 0: + try: + import signal + name = signal.Signals(-code).name + except (ValueError, AttributeError): + name = f"signal {-code}" + return (f" — process terminated by {name} (abnormal termination, not a normal exit); " + "the infobase may be left in an inconsistent state; verify it before retrying") + return "" + + +def _redact(text, *secrets): + """Redact literal secret values (password, user) from a display string — + precise, never touches lookalike paths.""" + for s in secrets: + if s: + text = text.replace(s, "***") + return text + + +KNOWN_COMMANDS = ["list", "check", "set-properties", "delete"] +KNOWN_CHECKS = ["apply", "modules", "config"] +KNOWN_CONTEXTS = ["ThinClient", "WebClient", "MobileClient", "MobileClientStandalone", "MobileAppClient", + "Server", "MobileAppServer", "ExternalConnection", "ExternalConnectionServer", + "ThickClientManagedApplication", "ThickClientServerManagedApplication", + "ThickClientOrdinaryApplication", "ThickClientServerOrdinaryApplication"] + +PROP_RU = { + "safe-mode": "безопасный режим", + "active": "активно", + "unsafe-action-protection": "защита от опасных действий", + "used-in-distributed-infobase": "используется в РИБ", + "scope": "область действия", + "security-profile-name": "профиль безопасности", + "purpose": "назначение", + "version": "версия", +} +FLAG_RU = {"yes": "да", "no": "нет"} +SCOPE_RU = {"infobase": "Информационная база", "data-separation": "Область данных"} +PURPOSE_RU = {"customization": "Адаптация", "add-on": "Дополнение", "patch": "Исправление"} + + +def prop_ru(key): + return PROP_RU.get(key, key) + + +def flag_value(value): + return "yes" if str(value).lower() in ("on", "yes", "+") else "no" + + +def format_prop_value(key, value): + if value is None or value == "": + return "" + v = str(value) + if key == "scope" and v in SCOPE_RU: + return SCOPE_RU[v] + if key == "purpose" and v in PURPOSE_RU: + return PURPOSE_RU[v] + return FLAG_RU.get(v, v) + + +def parse_ibcmd_records(text): + """Вывод ibcmd: строки «ключ : значение», записи разделены пустой строкой.""" + records = [] + cur = {} + for line in (text or "").splitlines(): + if not line.strip(): + if cur: + records.append(cur) + cur = {} + continue + if ":" not in line: + continue + key, val = line.split(":", 1) + key = key.strip() + val = val.strip().strip('"') + if key: + cur[key] = val + if cur: + records.append(cur) + return records + + +def write_table(headers, rows): + widths = [] + for i, h in enumerate(headers): + w = len(h) + for row in rows: + w = max(w, len(str(row[i]))) + widths.append(w) + print((" " + "".join(h.ljust(widths[i] + 2) for i, h in enumerate(headers))).rstrip()) + for row in rows: + print((" " + "".join(str(c).ljust(widths[i] + 2) for i, c in enumerate(row))).rstrip()) + + +def main(): + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + parser = argparse.ArgumentParser( + description="Configuration extensions in a 1C infobase", + allow_abbrev=False, + ) + parser.add_argument("-Command", default="") + parser.add_argument("-V8Path", default="") + parser.add_argument("-InfoBasePath", default="") + parser.add_argument("-InfoBaseServer", default="") + parser.add_argument("-InfoBaseRef", default="") + parser.add_argument("-UserName", default="") + parser.add_argument("-Password", default="") + parser.add_argument("-RepositoryPath", default="") + parser.add_argument("-RepositoryUser", default="") + parser.add_argument("-RepositoryPassword", default="") + parser.add_argument("-Name", default=None) + parser.add_argument("-All", action="store_true") + parser.add_argument("-Checks", default="") + parser.add_argument("-Context", default="") + # Тристабильные флаги: on включить, off выключить, не указан — не трогать. + # Значение "-" через powershell.exe -File парсер съедает молча (проверено), поэтому + # каноническая форма словесная; "+"/"-" принимаются, но в инструкции не значатся. + for flag in ("-SafeMode", "-Active", "-UnsafeActionProtection", "-UsedInDistributedInfobase"): + parser.add_argument(flag, default="", choices=["", "on", "off", "yes", "no", "+", "-"]) + parser.add_argument("-Scope", default="", choices=["", "infobase", "data-separation"]) + parser.add_argument("-SecurityProfile", default=None) + 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) + + # --- Утилиты платформы: нужны обе, выбор по команде --- + # -V8Path указывает на каталог bin либо на любой из двух файлов; второй берётся соседом. + bin_dir = os.path.dirname(v8path) + leaf = os.path.basename(v8path) + # Расширение файла сохраняем: на Windows это .exe, на *nix его нет, в тестах — .cmd/.sh. + suffix = os.path.splitext(leaf)[1] + if leaf.lower().startswith("ibcmd"): + ibcmd_exe = v8path + v8_exe = os.path.join(bin_dir, "1cv8" + suffix) + else: + v8_exe = v8path + ibcmd_exe = os.path.join(bin_dir, "ibcmd" + suffix) + has_v8 = os.path.isfile(v8_exe) + has_ibcmd = os.path.isfile(ibcmd_exe) + + # --- Разбор и проверка команды --- + cmd = (args.Command or "").strip().lower() + if not cmd: + print("Error: specify a command: " + " | ".join(KNOWN_COMMANDS)) + sys.exit(1) + if cmd not in KNOWN_COMMANDS: + print("Error: unknown command '%s' (expected: %s)" % (args.Command, " | ".join(KNOWN_COMMANDS))) + sys.exit(1) + + # Пустое имя платформа трактует разрушительно: /DeleteCfg -Extension "" удаляет первое + # расширение из списка и рапортует успех. Пустое значение до платформы не доходит. + if args.Name is not None and not args.Name.strip(): + print("Error: -Name is empty; omit it to address all extensions, or pass a name") + sys.exit(1) + name = args.Name.strip() if args.Name else "" + has_name = bool(name) + + if args.All and cmd != "delete": + print("Error: -All applies to delete only (list and check address all extensions when -Name is omitted)") + sys.exit(1) + if cmd == "delete": + if has_name and args.All: + print("Error: -Name and -All are mutually exclusive - pass one or the other") + sys.exit(1) + if not has_name and not args.All: + print("Error: specify -Name or -All (an omitted name never means all)") + sys.exit(1) + if cmd == "set-properties" and not has_name: + print("Error: set-properties needs -Name ") + sys.exit(1) + + # --- Проверки (-Checks) и контексты (-Context) --- + check_list = [c.strip().lower() for c in args.Checks.split(",") if c.strip()] if args.Checks else [] + for c in check_list: + if c not in KNOWN_CHECKS: + print("Error: unknown check '%s' (expected: %s)" % (c, ", ".join(KNOWN_CHECKS))) + sys.exit(1) + if not check_list: + check_list = ["apply", "modules"] + + context_list = [] + if args.Context: + for c in [x.strip() for x in args.Context.split(",") if x.strip()]: + match = next((k for k in KNOWN_CONTEXTS if k.lower() == c.lower()), None) + if not match: + print("Error: unknown context '%s' (expected: %s)" % (c, ", ".join(KNOWN_CONTEXTS))) + sys.exit(1) + context_list.append(match) + if args.Context and check_list and "modules" not in check_list: + print("Error: -Context applies to the syntax check - add 'modules' to -Checks") + sys.exit(1) + if not context_list: + context_list = ["ThinClient", "Server"] + + # --- Свойства для set-properties --- + prop_flags = [] + if args.SafeMode: + prop_flags.append(("safe-mode", flag_value(args.SafeMode))) + if args.Active: + prop_flags.append(("active", flag_value(args.Active))) + if args.UnsafeActionProtection: + prop_flags.append(("unsafe-action-protection", flag_value(args.UnsafeActionProtection))) + if args.UsedInDistributedInfobase: + prop_flags.append(("used-in-distributed-infobase", flag_value(args.UsedInDistributedInfobase))) + if args.Scope: + prop_flags.append(("scope", args.Scope)) + if args.SecurityProfile is not None: + prop_flags.append(("security-profile-name", args.SecurityProfile)) + if cmd == "set-properties" and not prop_flags: + print("Error: set-properties needs at least one property (-SafeMode, -Active, " + "-UnsafeActionProtection, -UsedInDistributedInfobase, -Scope, -SecurityProfile)") + sys.exit(1) + + # --- Соединение --- + if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): + print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef") + sys.exit(1) + + # --- Дополнительные аргументы: у каждой утилиты свои --- + arg_hints = { + "/F": "-InfoBasePath", + "/S": "-InfoBaseServer + -InfoBaseRef", + "/N": "-UserName", + "/P": "-Password", + "--db-path": "-InfoBasePath", + "--user": "-UserName", + "--password": "-Password", + } + v8_extra_args = resolve_extra_args("1cv8", v8_extra, [], arg_hints) + ib_extra_args = resolve_extra_args("ibcmd", [], ibcmd_extra, arg_hints) + if ibcmd_extra and cmd in ("check", "delete"): + print("Error: -AdditionalIbcmdArguments does not apply to '%s' - it runs the Designer only" % cmd) + sys.exit(1) + + # Общий блок хранилища адресует расширение через args.Extension (у расширения своё + # хранилище) — зеркало $Extension = $Name из PS-порта. + args.Extension = name + repo = resolve_repository_settings(args) + base_label = args.InfoBasePath if args.InfoBasePath else "%s/%s" % (args.InfoBaseServer, args.InfoBaseRef) + + def invoke_designer(op_args): + """Соединение, реквизиты хранилища и /Out навык держит сам.""" + if not has_v8: + print("Error: 1C executable not found at %s" % v8_exe) + sys.exit(1) + temp_dir = tempfile.mkdtemp(prefix="db_cfe_admin_") + try: + arguments = ["DESIGNER"] + if args.InfoBaseServer and args.InfoBaseRef: + arguments += ["/S", '"%s/%s"' % (args.InfoBaseServer, args.InfoBaseRef)] + else: + arguments += ["/F", '"%s"' % args.InfoBasePath] + if args.UserName: + arguments.append('/N"%s"' % args.UserName) + if args.Password: + arguments.append('/P"%s"' % args.Password) + # База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов. + arguments += repository_args(repo) + arguments += op_args + out_file = os.path.join(temp_dir, "out.txt") + arguments += ["/Out", '"%s"' % out_file, "/DisableStartupDialogs"] + arguments += v8_extra_args + print("Running: 1cv8.exe " + _redact(" ".join(format_args_for_display(arguments, "1cv8")), + args.Password, args.UserName, repo.get("password"))) + r = run_v8(v8_exe, arguments) + log = "" + if os.path.isfile(out_file): + with open(out_file, encoding="utf-8-sig", errors="replace") as f: + log = f.read().strip() + return {"exit": r.returncode, "log": log, "result": r} + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + def invoke_ibcmd(op_args): + arguments = [ibcmd_exe] + list(op_args) + ["--db-path=%s" % args.InfoBasePath] + if args.UserName: + arguments.append("--user=%s" % args.UserName) + if args.Password: + arguments.append("--password=%s" % args.Password) + arguments += ib_extra_args + print("Running: ibcmd " + _redact(" ".join(format_args_for_display(arguments[1:], "ibcmd")), + args.Password, args.UserName)) + r = run_ibcmd(arguments, has_username=bool(args.UserName), warn_no_user=False) + return {"exit": r.returncode, "result": r} + + def platform_failure(res, what): + print("Error: %s (code: %s)%s" % (what, res["exit"], describe_exit(res["exit"]))) + if res.get("log"): + print("--- Log ---") + print(res["log"]) + print("--- End ---") + print_platform_output(res["result"]) + + def properties_unavailable_reason(): + if not args.InfoBasePath: + return "свойства читает ibcmd, а он подключается к файловой базе (--db-path)" + if not has_ibcmd: + return "рядом с 1cv8 нет ibcmd (%s) - эта установка платформы его не содержит" % ibcmd_exe + return None + + def get_extension_properties(): + """Словарь «имя расширения» -> запись свойств. Пустой, если ibcmd недоступен.""" + if properties_unavailable_reason(): + return {} + r = invoke_ibcmd(["infobase", "config", "extension", "list"]) + if r["exit"] != 0: + return {} + out = {} + for rec in parse_ibcmd_records((r["result"].stdout or "") + (r["result"].stderr or "")): + if rec.get("name"): + out[rec["name"]] = rec + return out + + def get_extension_names(): + """Имена расширений базы - Конфигуратором: работает и без ibcmd, и на серверной базе.""" + r = invoke_designer(["/DumpDBCfgList", "-AllExtensions"]) + if r["exit"] != 0: + platform_failure(r, "cannot list extensions") + sys.exit(1) + return [x.strip() for x in r["log"].splitlines() if x.strip()] + + def prop_cell(rec, key): + if rec is None: + return "—" + if rec.get(key): + return format_prop_value(key, rec[key]) + return "" + + # ======================================================================== + # Команды + # ======================================================================== + + if cmd == "list": + names = get_extension_names() + if has_name: + names = [n for n in names if n.lower() == name.lower()] + if not names: + print("[РАСШИРЕНИЯ] %s (0)" % base_label) + print(" расширение '%s' в базе не найдено" % name) + sys.exit(1) + print("[РАСШИРЕНИЯ] %s (%d)" % (base_label, len(names))) + if not names: + print(" расширений нет") + sys.exit(0) + props = get_extension_properties() + reason = properties_unavailable_reason() + rows = [] + for n in names: + rec = props.get(n) + rows.append([n, + prop_cell(rec, "purpose"), + prop_cell(rec, "active"), + prop_cell(rec, "safe-mode"), + prop_cell(rec, "unsafe-action-protection"), + prop_cell(rec, "used-in-distributed-infobase"), + prop_cell(rec, "scope")]) + write_table(["Имя", "Назначение", "Активно", "Безопасный режим", "Защита", "РИБ", "Область"], rows) + if reason: + print(" свойства недоступны: %s" % reason) + sys.exit(0) + + if cmd == "check": + # Список расширений заранее не запрашиваем: платформа сама отвечает «расширение не + # найдено», а лишний запуск конфигуратора стоит дороже разницы в формулировке. + target = name if has_name else None + if target: + print("[ПРОВЕРКА] %s · %s" % (base_label, target)) + else: + print("[ПРОВЕРКА] %s · все расширения" % base_label) + + failed = 0 + done = 0 + rows = [] + + if "apply" in check_list: + op_args = ["/CheckCanApplyConfigurationExtensions"] + if target: + op_args += ["-Extension", '"%s"' % target] + r = invoke_designer(op_args) + done += 1 + log_lines = [x for x in r["log"].splitlines() if x.strip()] + if r["exit"] == 0: + rows.append(["применимость", "ОК", "", []]) + elif r["exit"] == 1: + failed += 1 + rows.append(["применимость", "ОШИБКА", "", log_lines]) + else: + failed += 1 + rows.append(["применимость", "СБОЙ", "код %s%s" % (r["exit"], describe_exit(r["exit"])), log_lines]) + + # modules и config - одна и та же команда платформы с разным набором флагов, поэтому + # при запросе обеих делается ОДИН запуск. Без флагов контекста платформа рапортует + # «ошибок не обнаружено» на заведомо сломанном модуле - набор всегда явный. + want_modules = "modules" in check_list + want_config = "config" in check_list + if want_modules or want_config: + op_args = ["/CheckConfig"] + if want_modules: + op_args += ["-" + c for c in context_list] + if want_config: + op_args += ["-ConfigLogIntegrity", "-IncorrectReferences", "-UnreferenceProcedures", + "-HandlersExistence", "-EmptyHandlers"] + if target: + op_args += ["-Extension", '"%s"' % target] + else: + op_args.append("-AllExtensions") + r = invoke_designer(op_args) + label = "модули и конфигурация" if (want_modules and want_config) else ("модули" if want_modules else "конфигурация") + done += 1 + log_lines = [x for x in r["log"].splitlines() if x.strip()] + if r["exit"] == 0: + rows.append([label, "ОК", "(%s)" % ", ".join(context_list) if want_modules else "", []]) + elif r["exit"] in (1, 101): + failed += 1 + rows.append([label, "ОШИБКА", "", log_lines]) + else: + failed += 1 + rows.append([label, "СБОЙ", "код %s%s" % (r["exit"], describe_exit(r["exit"])), log_lines]) + + # Сообщения платформы печатаются построчно под своей проверкой: они называют расширение и + # место ошибки, и при нескольких расширениях склейка в одну строку нечитаема. + w = max(len(row[0]) for row in rows) if rows else 0 + for row in rows: + line = " " + row[0].ljust(w + 2) + row[1].ljust(9) + if row[2]: + line += row[2] + print(line.rstrip()) + for msg in row[3]: + print(" " + msg.strip()) + if failed: + print("Итог: провалено %d из %d" % (failed, done)) + sys.exit(1) + print("Итог: пройдено %d из %d" % (done, done)) + sys.exit(0) + + if cmd == "set-properties": + reason = properties_unavailable_reason() + if reason: + print("Error: cannot set properties - %s" % reason) + sys.exit(1) + before = get_extension_properties() + if name not in before: + print("Error: extension '%s' not found in the infobase" % name) + sys.exit(1) + op_args = ["infobase", "config", "extension", "update", "--name=%s" % name] + for key, val in prop_flags: + op_args.append("--%s=%s" % (key, val)) + r = invoke_ibcmd(op_args) + if r["exit"] != 0: + print("Error: cannot set properties (code: %s)%s" % (r["exit"], describe_exit(r["exit"]))) + print_platform_output(r["result"]) + sys.exit(1) + # Постусловие: состояние перечитывается, а не берётся из кода возврата. + after = get_extension_properties() + if name not in after: + print("Error: extension '%s' disappeared after the update" % name) + sys.exit(1) + print("[СВОЙСТВА] %s · %s" % (base_label, name)) + changed = 0 + stale = [] + for key, val in prop_flags: + was = format_prop_value(key, before[name].get(key, "")) + now = format_prop_value(key, after[name].get(key, "")) + want = format_prop_value(key, val) + if was != now: + print(" " + prop_ru(key).ljust(30) + "%s → %s" % (was, now)) + changed += 1 + elif now != want: + stale.append("%s: просили '%s', в базе осталось '%s'" % (prop_ru(key), want, now)) + if stale: + for s in stale: + print(" " + s) + print("Итог: изменено %d, не применено %d" % (changed, len(stale))) + sys.exit(1) + if changed == 0: + print(" свойства уже в этом состоянии") + print("Итог: изменено %d" % changed) + sys.exit(0) + + if cmd == "delete": + names = get_extension_names() + if not names: + print("[УДАЛЕНИЕ] %s" % base_label) + print(" расширений нет - удалять нечего") + sys.exit(0) + if has_name: + targets = [n for n in names if n.lower() == name.lower()] + if not targets: + print("Error: extension '%s' not found in the infobase" % name) + sys.exit(1) + else: + targets = names + print("[УДАЛЕНИЕ] %s (будет удалено: %d)" % (base_label, len(targets))) + for t in targets: + # Имя непустое по построению: пустое отбито разбором, список получен от платформы. + r = invoke_designer(["/DeleteCfg", "-Extension", '"%s"' % t]) + if r["exit"] != 0: + platform_failure(r, "cannot delete extension '%s'" % t) + sys.exit(1) + print(" удалено: %s" % t) + # Постусловие: список перечитывается - код возврата платформы ничего не доказывает. + rest = get_extension_names() + for t in targets: + if [n for n in rest if n.lower() == t.lower()]: + print("Error: platform reported success, but '%s' is still in the infobase" % t) + sys.exit(1) + print("Итог: удалено %d, осталось %d" % (len(targets), len(rest))) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/README.md b/README.md index 6e2365af4..834f61cea 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ python tools/cc-1c-skills/scripts/switch.py | Подсистемы (Subsystem) | 4 навыка `/subsystem-*` | Анализ, создание, редактирование, валидация подсистем конфигурации | [Подробнее](docs/subsystem-guide.md) | | Командный интерфейс (CI) | 2 навыка `/interface-*` | Редактирование и валидация CommandInterface.xml подсистем | [Подробнее](docs/subsystem-guide.md) | | Пакеты XDTO | 5 навыков `/xdto-*` | Анализ, создание из XML-схемы, выгрузка в схему, точечное редактирование, валидация пакетов XDTO | [Подробнее](docs/xdto-guide.md) | -| Базы данных (DB) | 9 навыков `/db-*` | Создание баз, загрузка/выгрузка конфигураций, обновление БД, загрузка из Git | [Подробнее](docs/db-guide.md) | +| Базы данных (DB) | 13 навыков `/db-*` | Создание баз, загрузка/выгрузка конфигураций, обновление БД, загрузка из Git, хранилище конфигурации, управление расширениями в базе | [Подробнее](docs/db-guide.md) | | Веб-публикация (Web) | 4 навыка `/web-*` | Публикация баз через Apache, статус, остановка, удаление публикаций | [Подробнее](docs/web-guide.md) | | Тестирование (Web) | `/web-test` | Взаимодействие с веб-клиентом 1С — навигация, формы, таблицы, отчёты, тестирование | [Подробнее](docs/web-test-guide.md) | | Запись видео (Web) | `/web-test` | Запись видеоинструкций с субтитрами, подсветкой и TTS-озвучкой | [Подробнее](docs/web-test-recording-guide.md) | @@ -233,9 +233,13 @@ python scripts/switch.py --runtime powershell # вернуть на PowerShell ├── db-load-cf/ # Загрузка конфигурации из CF ├── db-dump-xml/ # Выгрузка конфигурации в XML ├── db-load-xml/ # Загрузка конфигурации из XML +├── db-dump-dt/ # Выгрузка информационной базы в DT +├── db-load-dt/ # Загрузка информационной базы из DT ├── db-update/ # Обновление конфигурации БД ├── db-run/ # Запуск 1С:Предприятие ├── db-load-git/ # Загрузка изменений из Git +├── db-repo/ # Хранилище конфигурации +├── db-cfe-admin/ # Расширения в базе: состав, проверки, свойства, удаление ├── web-publish/ # Публикация базы через Apache ├── web-info/ # Статус Apache и публикаций ├── web-stop/ # Остановка Apache diff --git a/docs/db-guide.md b/docs/db-guide.md index 893d2fc4d..f79b48c4b 100644 --- a/docs/db-guide.md +++ b/docs/db-guide.md @@ -16,6 +16,7 @@ | `/db-run` | — | Запуск 1С:Предприятие | | `/db-load-git` | `.ps1` | Загрузка изменений из Git в базу | | `/db-repo` | `.ps1` | Хранилище конфигурации: захват, помещение, получение изменений | +| `/db-cfe-admin` | `.ps1` | Расширения в базе: список и состояние, проверка применимости и модулей, свойства подключения, удаление | ## Рабочий цикл diff --git a/tests/skills/cases/db-cfe-admin/_skill.json b/tests/skills/cases/db-cfe-admin/_skill.json new file mode 100644 index 000000000..3d023928a --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/_skill.json @@ -0,0 +1,5 @@ +{ + "script": "db-cfe-admin/scripts/db-cfe-admin", + "setup": "none", + "args": [] +} diff --git a/tests/skills/cases/db-cfe-admin/check-apply-fails.json b/tests/skills/cases/db-cfe-admin/check-apply-fails.json new file mode 100644 index 000000000..789b21f7b --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/check-apply-fails.json @@ -0,0 +1,29 @@ +{ + "name": "check: платформа отвергла расширение (код 1) — дрейф виден в отчёте, exit 1", + "fakePlatform": { + "log": "Проба: Текст метода \"Проба_Расчёт\" не соответствует методу \"Расчёт\".\r\n", + "exit": 1 + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Name", + "Проба", + "-Checks", + "apply" + ], + "expectError": true, + "expect": { + "stdoutContains": [ + "применимость", + "ОШИБКА", + "не соответствует методу", + "Итог: провалено 1 из 1" + ] + }, + "noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор вывода платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/check-clean.json b/tests/skills/cases/db-cfe-admin/check-clean.json new file mode 100644 index 000000000..f96518c19 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/check-clean.json @@ -0,0 +1,26 @@ +{ + "name": "check: обе проверки прошли — контексты названы явно, exit 0", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Name", + "Проба" + ], + "expect": { + "stdoutContains": [ + "применимость", + "модули", + "(ThinClient, Server)", + "Итог: пройдено 2 из 2" + ], + "stdoutNotContains": "ОШИБКА" + }, + "noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется сборка отчёта" +} diff --git a/tests/skills/cases/db-cfe-admin/check-modules-fails.json b/tests/skills/cases/db-cfe-admin/check-modules-fails.json new file mode 100644 index 000000000..f0de5a108 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/check-modules-fails.json @@ -0,0 +1,28 @@ +{ + "name": "check: синтаксическая ошибка модуля (код 101) считается провалом", + "fakePlatform": { + "log": "{Проба ОбщийМодуль.БатчМод.Модуль(13,23)}: Ожидается символ ')'\r\n", + "exit": 101 + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Name", + "Проба", + "-Checks", + "modules" + ], + "expectError": true, + "expect": { + "stdoutContains": [ + "модули", + "ОШИБКА", + "Ожидается символ" + ] + }, + "noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор кода возврата платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/check-multiple-extensions.json b/tests/skills/cases/db-cfe-admin/check-multiple-extensions.json new file mode 100644 index 000000000..210e3c198 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/check-multiple-extensions.json @@ -0,0 +1,27 @@ +{ + "name": "check без -Name: сообщения платформы печатаются построчно и называют своё расширение", + "fakePlatform": { + "log": "Проба: Текст метода \"Проба_Расчёт\" не соответствует методу \"Расчёт\".\r\nВторая: Список параметров метода \"Вторая_Расчёт\" не соответствует методу \"Расчёт\".\r\n", + "exit": 1 + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Checks", + "apply" + ], + "expectError": true, + "expect": { + "stdoutContains": [ + " применимость ОШИБКА", + " Проба: Текст метода \"Проба_Расчёт\" не соответствует методу \"Расчёт\".", + " Вторая: Список параметров метода \"Вторая_Расчёт\" не соответствует методу \"Расчёт\"." + ], + "stdoutNotContains": " | " + }, + "noSnapshot": "проверяется форма отчёта — навык ничего не пишет в рабочий каталог" +} diff --git a/tests/skills/cases/db-cfe-admin/command-check-all-extensions.json b/tests/skills/cases/db-cfe-admin/command-check-all-extensions.json new file mode 100644 index 000000000..d2307e59b --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/command-check-all-extensions.json @@ -0,0 +1,21 @@ +{ + "name": "Состав команды: без -Name синтаксическая проверка идёт по всем расширениям", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Checks", + "modules" + ], + "expect": { + "stdoutContains": "/CheckConfig -ThinClient -Server -AllExtensions", + "stdoutNotContains": "-Extension" + }, + "noSnapshot": "проверяется командная строка, переданная платформе" +} diff --git a/tests/skills/cases/db-cfe-admin/command-check-config.json b/tests/skills/cases/db-cfe-admin/command-check-config.json new file mode 100644 index 000000000..d9b132a01 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/command-check-config.json @@ -0,0 +1,26 @@ +{ + "name": "Состав команды: -Checks config добавляет конфигурационные флаги и не тянет контексты", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Name", + "Проба", + "-Checks", + "config" + ], + "expect": { + "stdoutContains": "/CheckConfig -ConfigLogIntegrity -IncorrectReferences -UnreferenceProcedures -HandlersExistence -EmptyHandlers -Extension \"Проба\"", + "stdoutNotContains": [ + "-ThinClient", + "-Server" + ] + }, + "noSnapshot": "проверяется командная строка, переданная платформе" +} diff --git a/tests/skills/cases/db-cfe-admin/command-check-default.json b/tests/skills/cases/db-cfe-admin/command-check-default.json new file mode 100644 index 000000000..b44cf1f0b --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/command-check-default.json @@ -0,0 +1,27 @@ +{ + "name": "Состав команды: по умолчанию проверяются применимость и модули, контексты заданы явно", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Name", + "Проба" + ], + "expect": { + "stdoutContains": [ + "/CheckCanApplyConfigurationExtensions -Extension \"Проба\"", + "/CheckConfig -ThinClient -Server -Extension \"Проба\"" + ], + "stdoutNotContains": [ + "-ConfigLogIntegrity", + "-AllExtensions" + ] + }, + "noSnapshot": "проверяется командная строка, переданная платформе" +} diff --git a/tests/skills/cases/db-cfe-admin/command-list.json b/tests/skills/cases/db-cfe-admin/command-list.json new file mode 100644 index 000000000..924d25729 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/command-list.json @@ -0,0 +1,19 @@ +{ + "name": "Состав команды: список расширений снимается /DumpDBCfgList", + "fakePlatform": { + "log": "Альфа\r\n" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "list" + ], + "expect": { + "stdoutContains": "DESIGNER /F \"", + "stdoutNotContains": "-Extension" + }, + "noSnapshot": "проверяется командная строка, переданная платформе" +} diff --git a/tests/skills/cases/db-cfe-admin/delete-not-actually-deleted.json b/tests/skills/cases/db-cfe-admin/delete-not-actually-deleted.json new file mode 100644 index 000000000..8d77dc89d --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/delete-not-actually-deleted.json @@ -0,0 +1,24 @@ +{ + "name": "delete: платформа отчиталась успехом, но расширение осталось — навык это ловит", + "fakePlatform": { + "log": "Альфа\r\n" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "delete", + "-Name", + "Альфа" + ], + "expectError": true, + "expect": { + "stdoutContains": [ + "/DeleteCfg -Extension \"Альфа\"", + "platform reported success, but 'Альфа' is still in the infobase" + ] + }, + "noSnapshot": "проверяется постусловие удаления" +} diff --git a/tests/skills/cases/db-cfe-admin/delete-postcondition.json b/tests/skills/cases/db-cfe-admin/delete-postcondition.json new file mode 100644 index 000000000..2d8e33a10 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/delete-postcondition.json @@ -0,0 +1,21 @@ +{ + "name": "delete: после удаления список перечитывается, и остаток попадает в итог", + "fakePlatform": { + "log": "Альфа\r\n" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "delete", + "-Name", + "Бета" + ], + "expectError": true, + "expect": { + "stdoutContains": "Error: extension 'Бета' not found in the infobase" + }, + "noSnapshot": "негативный кейс — до записи дело не доходит" +} diff --git a/tests/skills/cases/db-cfe-admin/error-all-on-list.json b/tests/skills/cases/db-cfe-admin/error-all-on-list.json new file mode 100644 index 000000000..3b300b3f1 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-all-on-list.json @@ -0,0 +1,20 @@ +{ + "name": "-All применим только к delete", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "list", + "-All" + ], + "expectError": true, + "expect": { + "stdoutContains": "-All applies to delete only" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-context-without-modules.json b/tests/skills/cases/db-cfe-admin/error-context-without-modules.json new file mode 100644 index 000000000..80dab3d08 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-context-without-modules.json @@ -0,0 +1,23 @@ +{ + "name": "-Context без синтаксической проверки — отказ, а не тихо проигнорированный параметр", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Checks", + "config", + "-Context", + "Server" + ], + "expectError": true, + "expect": { + "stdoutContains": "-Context applies to the syntax check" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-delete-without-target.json b/tests/skills/cases/db-cfe-admin/error-delete-without-target.json new file mode 100644 index 000000000..5ac554cdc --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-delete-without-target.json @@ -0,0 +1,19 @@ +{ + "name": "delete без -Name и без -All — отказ", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "delete" + ], + "expectError": true, + "expect": { + "stdoutContains": "an omitted name never means all" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-empty-name.json b/tests/skills/cases/db-cfe-admin/error-empty-name.json new file mode 100644 index 000000000..be840da4b --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-empty-name.json @@ -0,0 +1,21 @@ +{ + "name": "пустое -Name не значит «все»: платформа на пустом имени удаляет первое расширение", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "delete", + "-Name", + "" + ], + "expectError": true, + "expect": { + "stdoutContains": "Error: -Name is empty" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-name-and-all.json b/tests/skills/cases/db-cfe-admin/error-name-and-all.json new file mode 100644 index 000000000..b2afc2204 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-name-and-all.json @@ -0,0 +1,22 @@ +{ + "name": "-Name и -All вместе — отказ", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "delete", + "-Name", + "Альфа", + "-All" + ], + "expectError": true, + "expect": { + "stdoutContains": "mutually exclusive" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-no-command.json b/tests/skills/cases/db-cfe-admin/error-no-command.json new file mode 100644 index 000000000..1882e29f2 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-no-command.json @@ -0,0 +1,17 @@ +{ + "name": "команда не указана — отказ", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib" + ], + "expectError": true, + "expect": { + "stdoutContains": "specify a command" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-set-without-ibcmd.json b/tests/skills/cases/db-cfe-admin/error-set-without-ibcmd.json new file mode 100644 index 000000000..406a2692b --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-set-without-ibcmd.json @@ -0,0 +1,26 @@ +{ + "name": "set-properties без ibcmd — внятный отказ, а не тихая деградация", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "set-properties", + "-Name", + "Альфа", + "-SafeMode", + "off" + ], + "expectError": true, + "expect": { + "stdoutContains": [ + "cannot set properties", + "ibcmd" + ] + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-set-without-props.json b/tests/skills/cases/db-cfe-admin/error-set-without-props.json new file mode 100644 index 000000000..b594735f3 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-set-without-props.json @@ -0,0 +1,21 @@ +{ + "name": "set-properties без единого свойства — отказ, а не молчаливый no-op", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "set-properties", + "-Name", + "Альфа" + ], + "expectError": true, + "expect": { + "stdoutContains": "needs at least one property" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-unknown-check.json b/tests/skills/cases/db-cfe-admin/error-unknown-check.json new file mode 100644 index 000000000..02b5a84c6 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-unknown-check.json @@ -0,0 +1,21 @@ +{ + "name": "неизвестная проверка — отказ со списком допустимых", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Checks", + "apply,wat" + ], + "expectError": true, + "expect": { + "stdoutContains": "unknown check" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-unknown-command.json b/tests/skills/cases/db-cfe-admin/error-unknown-command.json new file mode 100644 index 000000000..cd39c44aa --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-unknown-command.json @@ -0,0 +1,19 @@ +{ + "name": "неизвестная команда — отказ со списком допустимых", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "wat" + ], + "expectError": true, + "expect": { + "stdoutContains": "unknown command" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/error-unknown-context.json b/tests/skills/cases/db-cfe-admin/error-unknown-context.json new file mode 100644 index 000000000..099f26160 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/error-unknown-context.json @@ -0,0 +1,21 @@ +{ + "name": "неизвестный контекст — отказ со списком допустимых", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Context", + "Мобильный" + ], + "expectError": true, + "expect": { + "stdoutContains": "unknown context" + }, + "noSnapshot": "негативный кейс — навык отказывает до вызова платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/list-with-properties.json b/tests/skills/cases/db-cfe-admin/list-with-properties.json new file mode 100644 index 000000000..8fe11dc6e --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/list-with-properties.json @@ -0,0 +1,39 @@ +{ + "name": "list: свойства подхватываются из ibcmd, флаги печатаются по-русски", + "fakePlatform": { + "log": "Альфа\r\n" + }, + "preRun": [ + { + "writeFile": { + "path": "ibcmd.cmd", + "content": "@echo off\r\necho name : \"Альфа\"\r\necho version : \r\necho active : yes\r\necho purpose : customization\r\necho safe-mode : yes\r\necho unsafe-action-protection : no\r\necho used-in-distributed-infobase : no\r\necho scope : infobase\r\nexit /b 0\r\n", + "executable": true + } + }, + { + "writeFile": { + "path": "ibcmd.sh", + "content": "#!/bin/sh\ncat <<'EOF'\nname : \"Альфа\"\nversion : \nactive : yes\npurpose : customization\nsafe-mode : yes\nunsafe-action-protection : no\nused-in-distributed-infobase : no\nscope : infobase\nEOF\nexit 0\n", + "executable": true + } + } + ], + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "list" + ], + "expect": { + "stdoutContains": [ + "Альфа", + "Адаптация", + "Информационная база" + ], + "stdoutNotContains": "свойства недоступны" + }, + "noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор вывода ibcmd" +} diff --git a/tests/skills/cases/db-cfe-admin/list-without-ibcmd.json b/tests/skills/cases/db-cfe-admin/list-without-ibcmd.json new file mode 100644 index 000000000..b92f59b02 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/list-without-ibcmd.json @@ -0,0 +1,25 @@ +{ + "name": "list: без ibcmd имена читаются Конфигуратором, колонки свойств помечены прочерком", + "fakePlatform": { + "log": "Альфа\r\nБета\r\n" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "list" + ], + "expect": { + "stdoutContains": [ + "[РАСШИРЕНИЯ]", + "(2)", + "Альфа", + "Бета", + "—", + "свойства недоступны" + ] + }, + "noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется разбор вывода платформы" +} diff --git a/tests/skills/cases/db-cfe-admin/registered-base-repository.json b/tests/skills/cases/db-cfe-admin/registered-base-repository.json new file mode 100644 index 000000000..604e100b0 --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/registered-base-repository.json @@ -0,0 +1,28 @@ +{ + "name": "База из реестра: путь разрешения реквизитов хранилища проходится обоими портами", + "fakePlatform": { + "log": "Альфа\r\n" + }, + "cwd": "workDir", + "preRun": [ + { + "writeFile": { + "path": ".v8-project.json", + "content": "{\n \"databases\": [\n {\n \"id\": \"probe\",\n \"type\": \"file\",\n \"path\": \"ib\"\n }\n ]\n}" + } + } + ], + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "list" + ], + "expect": { + "stdoutContains": "Альфа", + "stdoutNotContains": "Traceback" + }, + "noSnapshot": "навык ничего не пишет в рабочий каталог — проверяется путь с реестром баз" +} diff --git a/tests/skills/cases/db-cfe-admin/secrets-masked.json b/tests/skills/cases/db-cfe-admin/secrets-masked.json new file mode 100644 index 000000000..32452df0d --- /dev/null +++ b/tests/skills/cases/db-cfe-admin/secrets-masked.json @@ -0,0 +1,27 @@ +{ + "name": "Пароль в печатаемой команде замаскирован", + "fakePlatform": { + "log": "" + }, + "args_extra": [ + "-V8Path", + "{fakePlatform}", + "-InfoBasePath", + "{workDir}/ib", + "-Command", + "check", + "-Name", + "Проба", + "-Checks", + "apply", + "-UserName", + "Админ", + "-Password", + "СуперСекрет" + ], + "expect": { + "stdoutContains": "/P***", + "stdoutNotContains": "СуперСекрет" + }, + "noSnapshot": "проверяется маскировка в печатаемой команде" +} diff --git a/tests/skills/check-inline-drift.mjs b/tests/skills/check-inline-drift.mjs index d270367e9..57fc17f39 100644 --- a/tests/skills/check-inline-drift.mjs +++ b/tests/skills/check-inline-drift.mjs @@ -71,7 +71,7 @@ const FAMILIES = [ // вверх. Группа db-* использует её же, чтобы найти запись базы и взять реквизиты // хранилища — задача одна, поэтому семья общая, а не вторая с тем же телом. { id: 'full', authority: 'cf-edit', - consumers: ['cfe-borrow', 'db-dump-xml', 'db-load-git', 'db-load-xml', 'db-repo', 'db-update', + consumers: ['cfe-borrow', 'db-cfe-admin', 'db-dump-xml', 'db-load-git', 'db-load-xml', 'db-repo', 'db-update', 'form-add', 'form-compile', 'form-edit', 'help-add', 'interface-edit', 'meta-compile', 'meta-edit', 'meta-remove', 'mxl-compile', 'role-compile', 'skd-compile', 'skd-edit', 'subsystem-compile', 'subsystem-edit', 'template-add', 'xdto-compile', 'xdto-edit'] }, @@ -255,7 +255,7 @@ const FAMILIES = [ variants: [ { id: 'base', authority: 'db-create', consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git', - 'db-load-xml', 'db-run', 'db-update', 'epf-build', 'epf-dump'] }, + 'db-load-xml', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'db-cfe-admin'] }, { id: 'v8-only', authority: 'db-repo', consumers: [], why: 'хранилище конфигурации ibcmd не поддерживает вовсе — нет такого режима, поэтому ветки ibcmd нет; вместо неё проверка усечённых ключей /ConfigurationRepository*, которые платформа не считает ошибкой, а запускает конфигуратор интерактивно' }, ], @@ -266,7 +266,7 @@ const FAMILIES = [ // db-run запускает Предприятие и не ждёт процесс — общей обвязки запуска не использует. { id: 'base', authority: 'db-create', consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git', - 'db-load-xml', 'db-repo', 'db-update', 'epf-build', 'epf-dump'] }, + 'db-load-xml', 'db-repo', 'db-update', 'epf-build', 'epf-dump', 'db-cfe-admin'] }, ], }, { @@ -274,7 +274,7 @@ const FAMILIES = [ variants: [ { id: 'base', authority: 'db-create', consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git', - 'db-load-xml', 'db-repo', 'db-update', 'epf-build', 'epf-dump'] }, + 'db-load-xml', 'db-repo', 'db-update', 'epf-build', 'epf-dump', 'db-cfe-admin'] }, ], }, { @@ -282,7 +282,7 @@ const FAMILIES = [ variants: [ { id: 'base', authority: 'db-dump-cf', consumers: ['db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git', - 'db-load-xml', 'db-repo', 'db-run', 'db-update', 'epf-build', 'epf-dump'] }, + 'db-load-xml', 'db-repo', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'db-cfe-admin'] }, ], }, { @@ -290,7 +290,7 @@ const FAMILIES = [ variants: [ { id: 'base', authority: 'db-create', consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git', - 'db-load-xml', 'db-repo', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'web-publish'] }, + 'db-load-xml', 'db-repo', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'web-publish', 'db-cfe-admin'] }, ], }, { @@ -298,7 +298,7 @@ const FAMILIES = [ variants: [ { id: 'base', authority: 'db-create', consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git', - 'db-load-xml', 'db-repo', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'web-publish'] }, + 'db-load-xml', 'db-repo', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'web-publish', 'db-cfe-admin'] }, ], }, { @@ -306,7 +306,7 @@ const FAMILIES = [ variants: [ { id: 'base', authority: 'db-create', consumers: ['db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git', - 'db-load-xml', 'db-repo', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'web-publish'] }, + 'db-load-xml', 'db-repo', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'web-publish', 'db-cfe-admin'] }, ], }, @@ -321,19 +321,19 @@ const FAMILIES = [ // конфигурацией базы. { name: 'repository: same_path', py: 'same_path', ps1: 'Test-SamePath', - variants: [{ id: 'base', authority: 'db-repo', consumers: ['db-dump-xml', 'db-load-git', 'db-load-xml', 'db-update'] }], + variants: [{ id: 'base', authority: 'db-repo', consumers: ['db-dump-xml', 'db-load-git', 'db-load-xml', 'db-update', 'db-cfe-admin'] }], }, { name: 'repository: find_project_database', py: 'find_project_database', ps1: 'Find-ProjectDatabase', - variants: [{ id: 'base', authority: 'db-repo', consumers: ['db-dump-xml', 'db-load-git', 'db-load-xml', 'db-update'] }], + variants: [{ id: 'base', authority: 'db-repo', consumers: ['db-dump-xml', 'db-load-git', 'db-load-xml', 'db-update', 'db-cfe-admin'] }], }, { name: 'repository: resolve_settings', py: 'resolve_repository_settings', ps1: 'Resolve-RepositorySettings', - variants: [{ id: 'base', authority: 'db-repo', consumers: ['db-dump-xml', 'db-load-git', 'db-load-xml', 'db-update'] }], + variants: [{ id: 'base', authority: 'db-repo', consumers: ['db-dump-xml', 'db-load-git', 'db-load-xml', 'db-update', 'db-cfe-admin'] }], }, { name: 'repository: args', py: 'repository_args', ps1: 'Get-RepositoryArgs', - variants: [{ id: 'base', authority: 'db-repo', consumers: ['db-dump-xml', 'db-load-git', 'db-load-xml', 'db-update'] }], + variants: [{ id: 'base', authority: 'db-repo', consumers: ['db-dump-xml', 'db-load-git', 'db-load-xml', 'db-update', 'db-cfe-admin'] }], }, // ─── Значения свойств-перечислений ─────────────────────────────────────── @@ -371,7 +371,7 @@ const FAMILIES = [ { id: 'base', authority: 'meta-compile', consumers: [ 'cf-edit', 'cf-info', 'cf-init', 'cf-validate', 'cfe-borrow', 'cfe-diff', 'cfe-init', - 'cfe-patch-method', 'cfe-validate', 'db-create', 'db-dump-cf', 'db-dump-dt', 'db-dump-xml', + 'cfe-patch-method', 'cfe-validate', 'db-cfe-admin', 'db-create', 'db-dump-cf', 'db-dump-dt', 'db-dump-xml', 'db-load-cf', 'db-load-dt', 'db-load-git', 'db-load-xml', 'db-repo', 'db-run', 'db-update', 'epf-build', 'epf-dump', 'epf-init', 'epf-validate', 'erf-init', 'form-add', 'form-compile', 'form-decompile', 'form-edit', 'form-info', 'form-remove', 'form-validate', 'help-add',