diff --git a/.claude/skills/db-create/scripts/db-create.ps1 b/.claude/skills/db-create/scripts/db-create.ps1 index 384d696c..36dc448c 100644 --- a/.claude/skills/db-create/scripts/db-create.ps1 +++ b/.claude/skills/db-create/scripts/db-create.ps1 @@ -201,6 +201,29 @@ function Format-ArgsForDisplay { return ,$res } +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' +$UseTemplate = ConvertTo-CleanPath $UseTemplate '-UseTemplate' + # --- Resolve V8Path --- function Find-ProjectV8Path { $dir = (Get-Location).Path @@ -245,32 +268,75 @@ if (-not (Test-Path $V8Path)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + function Test-FileIbCreated { # File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD. @@ -321,7 +387,7 @@ try { $arguments += "--data=$tempDir" $arguments += $extraArgs Write-Host "Running: ibcmd $((Format-ArgsForDisplay $arguments $engine) -join ' ')" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode $ibMissing = ($exitCode -eq 0) -and -not (Test-FileIbCreated $InfoBasePath) @@ -333,7 +399,7 @@ try { } else { Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red } - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } @@ -341,6 +407,8 @@ try { # --- Build arguments --- $arguments = @("CREATEINFOBASE") + # Quotes go INSIDE the token (File="path"): 1C's own parser wants them there, quoting + # the whole token instead breaks a path with spaces. Hence -PreQuoted on the launch. if ($InfoBaseServer -and $InfoBaseRef) { $arguments += "Srvr=`"$InfoBaseServer`";Ref=`"$InfoBaseRef`"" } else { @@ -369,8 +437,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $((Format-ArgsForDisplay $arguments $engine) -join ' ')" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- # Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success. @@ -397,6 +465,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/db-create/scripts/db-create.py b/.claude/skills/db-create/scripts/db-create.py index 7c162778..77bf2b06 100644 --- a/.claude/skills/db-create/scripts/db-create.py +++ b/.claude/skills/db-create/scripts/db-create.py @@ -250,6 +250,18 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -260,7 +272,67 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") def main(): @@ -285,6 +357,10 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + args.UseTemplate = clean_path(args.UseTemplate, "-UseTemplate") + v8path = resolve_v8path(args.V8Path) engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" @@ -325,7 +401,7 @@ def main(): ib_data = tempfile.mkdtemp(prefix="ibcmd_data_") atexit.register(shutil.rmtree, ib_data, ignore_errors=True) arguments.append(f"--data={ib_data}") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) print(f"Running: ibcmd {' '.join(format_args_for_display(arguments, engine))}") result = run_ibcmd([v8path] + arguments, warn_no_user=False) exit_code = result.returncode @@ -342,10 +418,7 @@ def main(): ) else: print(f"Error creating information base (code: {exit_code})", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) + print_platform_output(result) sys.exit(exit_code) # --- Temp dir --- @@ -356,37 +429,33 @@ def main(): # --- Build arguments --- arguments = ["CREATEINFOBASE"] + # Quotes go INSIDE the token (File="path"): that is where 1C's parser expects them. + # Quoting the whole token instead breaks a path with spaces — on both OSes. if args.InfoBaseServer and args.InfoBaseRef: - # No embedded quotes: subprocess quotes the whole token; 1C's argv parser - # strips outer quotes. Inner quotes get escaped by list2cmdline and break parsing. - arguments.append(f'Srvr={args.InfoBaseServer};Ref={args.InfoBaseRef}') + arguments.append(f'Srvr="{args.InfoBaseServer}";Ref="{args.InfoBaseRef}"') else: - arguments.append(f'File={args.InfoBasePath}') + arguments.append(f'File="{args.InfoBasePath}"') # --- Template --- if args.UseTemplate: - arguments.extend(["/UseTemplate", args.UseTemplate]) + arguments.extend(["/UseTemplate", f'"{args.UseTemplate}"']) # --- Add to list --- if args.AddToList: if args.ListName: - arguments.extend(["/AddToList", args.ListName]) + arguments.extend(["/AddToList", f'"{args.ListName}"']) else: arguments.append("/AddToList") # --- Output --- out_file = os.path.join(temp_dir, "create_log.txt") - arguments.extend(["/Out", out_file]) + arguments.extend(["/Out", f'"{out_file}"']) arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {' '.join(format_args_for_display(arguments, engine))}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -420,6 +489,7 @@ def main(): print("--- End ---") except Exception: pass + print_platform_output(result) sys.exit(exit_code) diff --git a/.claude/skills/db-dump-cf/scripts/db-dump-cf.ps1 b/.claude/skills/db-dump-cf/scripts/db-dump-cf.ps1 index b27182b2..b0b61107 100644 --- a/.claude/skills/db-dump-cf/scripts/db-dump-cf.ps1 +++ b/.claude/skills/db-dump-cf/scripts/db-dump-cf.ps1 @@ -1,4 +1,4 @@ -# db-dump-cf v1.11 — Dump 1C configuration to CF file +# db-dump-cf v1.12 — Dump 1C configuration to CF file # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -217,6 +217,42 @@ function Format-ArgsForDisplay { return ,$res } +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' +$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile' + +function Assert-InfoBaseExists { + # These skills work on a ready infobase. Saying so up front beats the platform's + # "Неверные или отсутствующие параметры соединения" after a launch. + param([string]$Path) + if (-not $Path) { return } + if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) { + Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red + exit 1 + } +} + +Assert-InfoBaseExists $InfoBasePath + # --- Resolve V8Path --- function Find-ProjectV8Path { $dir = (Get-Location).Path @@ -261,32 +297,75 @@ if (-not (Test-Path $V8Path)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + function Test-OutputNonEmpty { # Postcondition: the platform must have produced a non-empty output file. @@ -337,7 +416,7 @@ try { $arguments += "--data=$tempDir" $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode $outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile) @@ -349,7 +428,7 @@ try { } else { Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red } - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } @@ -383,8 +462,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- # Postcondition: exit 0 without a non-empty output file is a false success. @@ -406,6 +485,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/db-dump-cf/scripts/db-dump-cf.py b/.claude/skills/db-dump-cf/scripts/db-dump-cf.py index a4e1c566..ab2d2699 100644 --- a/.claude/skills/db-dump-cf/scripts/db-dump-cf.py +++ b/.claude/skills/db-dump-cf/scripts/db-dump-cf.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# db-dump-cf v1.11 — Dump 1C configuration to CF file +# db-dump-cf v1.12 — Dump 1C configuration to CF file # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r def output_nonempty(path): @@ -295,6 +377,11 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + args.OutputFile = clean_path(args.OutputFile, "-OutputFile") + v8path = resolve_v8path(args.V8Path) engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" @@ -353,10 +440,6 @@ def main(): print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr) else: print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(exit_code) # --- Temp dir --- @@ -368,36 +451,32 @@ def main(): arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]) + arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']) else: - arguments.extend(["/F", args.InfoBasePath]) + arguments.extend(["/F", f'"{args.InfoBasePath}"']) if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') - arguments.extend(["/DumpCfg", args.OutputFile]) + arguments.extend(["/DumpCfg", f'"{args.OutputFile}"']) # --- Extensions --- if args.Extension: - arguments.extend(["-Extension", args.Extension]) + arguments.extend(["-Extension", f'"{args.Extension}"']) elif args.AllExtensions: arguments.append("-AllExtensions") # --- Output --- out_file = os.path.join(temp_dir, "dump_cf_log.txt") - arguments.extend(["/Out", out_file]) + arguments.extend(["/Out", f'"{out_file}"']) arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -423,6 +502,7 @@ def main(): except Exception: pass + print_platform_output(result) sys.exit(exit_code) finally: diff --git a/.claude/skills/db-dump-dt/scripts/db-dump-dt.ps1 b/.claude/skills/db-dump-dt/scripts/db-dump-dt.ps1 index 6672c048..7a4670c8 100644 --- a/.claude/skills/db-dump-dt/scripts/db-dump-dt.ps1 +++ b/.claude/skills/db-dump-dt/scripts/db-dump-dt.ps1 @@ -1,4 +1,4 @@ -# db-dump-dt v1.10 — Dump 1C information base to DT file +# db-dump-dt v1.11 — Dump 1C information base to DT file # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -201,6 +201,42 @@ function Format-ArgsForDisplay { return ,$res } +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' +$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile' + +function Assert-InfoBaseExists { + # These skills work on a ready infobase. Saying so up front beats the platform's + # "Неверные или отсутствующие параметры соединения" after a launch. + param([string]$Path) + if (-not $Path) { return } + if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) { + Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red + exit 1 + } +} + +Assert-InfoBaseExists $InfoBasePath + # --- Resolve V8Path --- function Find-ProjectV8Path { $dir = (Get-Location).Path @@ -245,32 +281,75 @@ if (-not (Test-Path $V8Path)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + function Test-OutputNonEmpty { # Postcondition: the platform must have produced a non-empty output file. @@ -318,7 +397,7 @@ try { $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode $outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile) @@ -330,7 +409,7 @@ try { } else { Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red } - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } @@ -357,8 +436,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- # Postcondition: exit 0 without a non-empty output file is a false success. @@ -380,6 +459,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/db-dump-dt/scripts/db-dump-dt.py b/.claude/skills/db-dump-dt/scripts/db-dump-dt.py index df6ba9a1..de60d4d5 100644 --- a/.claude/skills/db-dump-dt/scripts/db-dump-dt.py +++ b/.claude/skills/db-dump-dt/scripts/db-dump-dt.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# db-dump-dt v1.10 — Dump 1C information base to DT file +# db-dump-dt v1.11 — Dump 1C information base to DT file # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r def output_nonempty(path): @@ -293,6 +375,11 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + args.OutputFile = clean_path(args.OutputFile, "-OutputFile") + v8path = resolve_v8path(args.V8Path) engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" @@ -346,10 +433,6 @@ def main(): print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr) else: print(f"Error dumping information base (code: {exit_code})", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(exit_code) # --- Temp dir --- @@ -361,30 +444,26 @@ def main(): arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]) + arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']) else: - arguments.extend(["/F", args.InfoBasePath]) + arguments.extend(["/F", f'"{args.InfoBasePath}"']) if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') - arguments.extend(["/DumpIB", args.OutputFile]) + arguments.extend(["/DumpIB", f'"{args.OutputFile}"']) # --- Output --- out_file = os.path.join(temp_dir, "dump_dt_log.txt") - arguments.extend(["/Out", out_file]) + arguments.extend(["/Out", f'"{out_file}"']) arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -410,6 +489,7 @@ def main(): except Exception: pass + print_platform_output(result) sys.exit(exit_code) finally: diff --git a/.claude/skills/db-dump-xml/scripts/db-dump-xml.ps1 b/.claude/skills/db-dump-xml/scripts/db-dump-xml.ps1 index 13c2a4ce..5070cfbe 100644 --- a/.claude/skills/db-dump-xml/scripts/db-dump-xml.ps1 +++ b/.claude/skills/db-dump-xml/scripts/db-dump-xml.ps1 @@ -1,4 +1,4 @@ -# db-dump-xml v1.13 — Dump 1C configuration to XML files +# db-dump-xml v1.14 — Dump 1C configuration to XML files # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -240,6 +240,42 @@ function Format-ArgsForDisplay { return ,$res } +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' +$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir' + +function Assert-InfoBaseExists { + # These skills work on a ready infobase. Saying so up front beats the platform's + # "Неверные или отсутствующие параметры соединения" after a launch. + param([string]$Path) + if (-not $Path) { return } + if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) { + Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red + exit 1 + } +} + +Assert-InfoBaseExists $InfoBasePath + # --- Resolve V8Path --- function Find-ProjectV8Path { $dir = (Get-Location).Path @@ -284,32 +320,75 @@ if (-not (Test-Path $V8Path)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + function Test-DirNonEmpty { # Postcondition: the platform must have written files into the output directory. @@ -378,7 +457,7 @@ try { $arguments += "--data=$tempDir" $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode $outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir) @@ -390,7 +469,7 @@ try { } else { Write-Host "Error exporting configuration (code: $exitCode)" -ForegroundColor Red } - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } @@ -452,8 +531,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- # Postcondition: exit 0 with an empty output directory is a false success. @@ -476,6 +555,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/db-dump-xml/scripts/db-dump-xml.py b/.claude/skills/db-dump-xml/scripts/db-dump-xml.py index a407e7be..f8da552d 100644 --- a/.claude/skills/db-dump-xml/scripts/db-dump-xml.py +++ b/.claude/skills/db-dump-xml/scripts/db-dump-xml.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# db-dump-xml v1.13 — Dump 1C configuration to XML files +# db-dump-xml v1.14 — Dump 1C configuration to XML files # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r def dir_nonempty(path): @@ -308,6 +390,11 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir") + # --- Resolve V8Path --- v8path = resolve_v8path(args.V8Path) engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" @@ -384,10 +471,6 @@ def main(): print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr) else: print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(exit_code) # --- Temp dir --- @@ -399,16 +482,16 @@ def main(): arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"] + arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'] else: - arguments += ["/F", args.InfoBasePath] + arguments += ["/F", f'"{args.InfoBasePath}"'] if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') - arguments += ["/DumpConfigToFiles", args.ConfigDir] + arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"'] arguments += ["-Format", args.Format] if args.Mode == "Full": @@ -425,7 +508,7 @@ def main(): with open(list_file, "w", encoding="utf-8-sig") as f: f.write("\n".join(object_list)) - arguments += ["-listFile", list_file] + arguments += ["-listFile", f'"{list_file}"'] print(f"Objects to dump: {len(object_list)}") for obj in object_list: print(f" {obj}") @@ -435,23 +518,19 @@ def main(): # --- Extensions --- if args.Extension: - arguments += ["-Extension", args.Extension] + arguments += ["-Extension", f'"{args.Extension}"'] elif args.AllExtensions: arguments.append("-AllExtensions") # --- Output --- out_file = os.path.join(temp_dir, "dump_log.txt") - arguments += ["/Out", out_file] + arguments += ["/Out", f'"{out_file}"'] arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -478,6 +557,7 @@ def main(): except Exception: pass + print_platform_output(result) sys.exit(exit_code) finally: diff --git a/.claude/skills/db-load-cf/scripts/db-load-cf.ps1 b/.claude/skills/db-load-cf/scripts/db-load-cf.ps1 index 0870d4b1..f549fd68 100644 --- a/.claude/skills/db-load-cf/scripts/db-load-cf.ps1 +++ b/.claude/skills/db-load-cf/scripts/db-load-cf.ps1 @@ -1,4 +1,4 @@ -# db-load-cf v1.12 — Load 1C configuration from CF file +# db-load-cf v1.13 — Load 1C configuration from CF file # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -234,6 +234,42 @@ function Format-ArgsForDisplay { return ,$res } +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' +$InputFile = ConvertTo-CleanPath $InputFile '-InputFile' + +function Assert-InfoBaseExists { + # These skills work on a ready infobase. Saying so up front beats the platform's + # "Неверные или отсутствующие параметры соединения" after a launch. + param([string]$Path) + if (-not $Path) { return } + if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) { + Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red + exit 1 + } +} + +Assert-InfoBaseExists $InfoBasePath + # --- Resolve V8Path --- function Find-ProjectV8Path { $dir = (Get-Location).Path @@ -278,32 +314,75 @@ if (-not (Test-Path $V8Path)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + $engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" } @@ -347,7 +426,7 @@ try { $arguments += "--data=$tempDir" $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode if ($exitCode -eq 0) { @@ -355,7 +434,7 @@ try { } else { Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red } - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } @@ -389,8 +468,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- if ($exitCode -eq 0) { @@ -407,6 +486,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/db-load-cf/scripts/db-load-cf.py b/.claude/skills/db-load-cf/scripts/db-load-cf.py index 67ab2dea..4059f8cd 100644 --- a/.claude/skills/db-load-cf/scripts/db-load-cf.py +++ b/.claude/skills/db-load-cf/scripts/db-load-cf.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# db-load-cf v1.12 — Load 1C configuration from CF file +# db-load-cf v1.13 — Load 1C configuration from CF file # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + 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): @@ -313,6 +395,11 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + args.InputFile = clean_path(args.InputFile, "-InputFile") + v8path = resolve_v8path(args.V8Path) engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" @@ -365,10 +452,6 @@ def main(): print(f"Configuration loaded successfully from: {args.InputFile}") else: print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(result.returncode) # --- Temp dir --- @@ -380,36 +463,32 @@ def main(): arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]) + arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']) else: - arguments.extend(["/F", args.InfoBasePath]) + arguments.extend(["/F", f'"{args.InfoBasePath}"']) if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') - arguments.extend(["/LoadCfg", args.InputFile]) + arguments.extend(["/LoadCfg", f'"{args.InputFile}"']) # --- Extensions --- if args.Extension: - arguments.extend(["-Extension", args.Extension]) + arguments.extend(["-Extension", f'"{args.Extension}"']) elif args.AllExtensions: arguments.append("-AllExtensions") # --- Output --- out_file = os.path.join(temp_dir, "load_cf_log.txt") - arguments.extend(["/Out", out_file]) + arguments.extend(["/Out", f'"{out_file}"']) arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -429,6 +508,7 @@ def main(): except Exception: pass + print_platform_output(result) sys.exit(exit_code) finally: diff --git a/.claude/skills/db-load-dt/scripts/db-load-dt.ps1 b/.claude/skills/db-load-dt/scripts/db-load-dt.ps1 index 352f032d..6ae411e7 100644 --- a/.claude/skills/db-load-dt/scripts/db-load-dt.ps1 +++ b/.claude/skills/db-load-dt/scripts/db-load-dt.ps1 @@ -1,4 +1,4 @@ -# db-load-dt v1.11 — Load 1C information base from DT file +# db-load-dt v1.12 — Load 1C information base from DT file # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -231,6 +231,42 @@ function Format-ArgsForDisplay { return ,$res } +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' +$InputFile = ConvertTo-CleanPath $InputFile '-InputFile' + +function Assert-InfoBaseExists { + # These skills work on a ready infobase. Saying so up front beats the platform's + # "Неверные или отсутствующие параметры соединения" after a launch. + param([string]$Path) + if (-not $Path) { return } + if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) { + Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red + exit 1 + } +} + +Assert-InfoBaseExists $InfoBasePath + # --- Resolve V8Path --- function Find-ProjectV8Path { $dir = (Get-Location).Path @@ -275,32 +311,75 @@ if (-not (Test-Path $V8Path)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + $engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" } @@ -342,7 +421,7 @@ try { $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode if ($exitCode -eq 0) { @@ -350,7 +429,7 @@ try { } else { Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red } - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } @@ -379,8 +458,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- if ($exitCode -eq 0) { @@ -397,6 +476,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/db-load-dt/scripts/db-load-dt.py b/.claude/skills/db-load-dt/scripts/db-load-dt.py index 4b84789d..52032b55 100644 --- a/.claude/skills/db-load-dt/scripts/db-load-dt.py +++ b/.claude/skills/db-load-dt/scripts/db-load-dt.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# db-load-dt v1.11 — Load 1C information base from DT file +# db-load-dt v1.12 — Load 1C information base from DT file # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + 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): @@ -313,6 +395,11 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + args.InputFile = clean_path(args.InputFile, "-InputFile") + v8path = resolve_v8path(args.V8Path) engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" @@ -362,10 +449,6 @@ def main(): print(f"Information base restored successfully from: {args.InputFile}") else: print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(result.returncode) # --- Temp dir --- @@ -377,34 +460,30 @@ def main(): arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]) + arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']) else: - arguments.extend(["/F", args.InfoBasePath]) + arguments.extend(["/F", f'"{args.InfoBasePath}"']) if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') if args.UnlockCode: - arguments.append(f"/UC{args.UnlockCode}") + arguments.append(f'/UC"{args.UnlockCode}"') - arguments.extend(["/RestoreIB", args.InputFile]) + arguments.extend(["/RestoreIB", f'"{args.InputFile}"']) if args.JobsCount > 0: arguments.extend(["-JobsCount", str(args.JobsCount)]) # --- Output --- out_file = os.path.join(temp_dir, "load_dt_log.txt") - arguments.extend(["/Out", out_file]) + arguments.extend(["/Out", f'"{out_file}"']) arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -424,6 +503,7 @@ def main(): except Exception: pass + print_platform_output(result) sys.exit(exit_code) finally: diff --git a/.claude/skills/db-load-git/scripts/db-load-git.ps1 b/.claude/skills/db-load-git/scripts/db-load-git.ps1 index 99aa3b30..ad0b7991 100644 --- a/.claude/skills/db-load-git/scripts/db-load-git.ps1 +++ b/.claude/skills/db-load-git/scripts/db-load-git.ps1 @@ -1,4 +1,4 @@ -# db-load-git v1.17 — Load Git changes into 1C database +# db-load-git v1.18 — Load Git changes into 1C database # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -325,32 +325,75 @@ if (-not $DryRun) { # --- Detect engine + validate connection (skip if DryRun) --- $engine = "1cv8" if (-not $DryRun) { -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + $engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" } if ($engine -eq "ibcmd") { @@ -536,16 +579,16 @@ try { $arguments += "--data=$tempDir" $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode if ($exitCode -ne 0) { Write-Host "Error loading changes (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } Write-Host "Changes loaded successfully ($($configFiles.Count) files)" -ForegroundColor Green - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output if ($UpdateDB) { $applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force") if ($UserName) { $applyArgs += "--user=$UserName" } @@ -553,7 +596,7 @@ try { $applyArgs += "--data=$tempDir" $applyArgs += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $applyArgs + $__ib = Invoke-PlatformProcess $V8Path $applyArgs $applyOut = $__ib.Output $exitCode = $__ib.ExitCode if ($exitCode -eq 0) { @@ -561,7 +604,7 @@ try { } else { Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red } - if ($applyOut) { Write-Host ($applyOut | Out-String) } + Write-PlatformOutput $applyOut } exit $exitCode } @@ -613,8 +656,8 @@ try { Write-Host "Executing partial configuration load..." Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- Write-Host "" @@ -632,6 +675,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/db-load-git/scripts/db-load-git.py b/.claude/skills/db-load-git/scripts/db-load-git.py index d9a6956a..fcc64f83 100644 --- a/.claude/skills/db-load-git/scripts/db-load-git.py +++ b/.claude/skills/db-load-git/scripts/db-load-git.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# db-load-git v1.17 — Load Git changes into 1C database +# db-load-git v1.18 — Load Git changes into 1C database # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r def get_object_xml_from_subfile(relative_path): @@ -350,6 +432,11 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir") + # --- Resolve V8Path (skip if DryRun) --- v8path = None if not args.DryRun: @@ -520,14 +607,8 @@ def main(): result = run_ibcmd([v8path] + arguments, bool(args.UserName)) if result.returncode != 0: print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(result.returncode) print(f"Changes loaded successfully ({len(config_files)} files)") - if result.stdout: - print(result.stdout) exit_code = 0 if args.UpdateDB: apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"] @@ -544,10 +625,7 @@ def main(): print("Database configuration updated successfully") else: print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) - if ar.stdout: - print(ar.stdout) - if ar.stderr: - print(ar.stderr, file=sys.stderr) + print_platform_output(ar) sys.exit(exit_code) # --- Write list file (UTF-8 with BOM) --- @@ -559,24 +637,24 @@ def main(): arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"] + arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'] else: - arguments += ["/F", args.InfoBasePath] + arguments += ["/F", f'"{args.InfoBasePath}"'] if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') - arguments += ["/LoadConfigFromFiles", args.ConfigDir] - arguments += ["-listFile", list_file] + arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"'] + arguments += ["-listFile", f'"{list_file}"'] arguments += ["-Format", args.Format] arguments.append("-partial") arguments.append("-updateConfigDumpInfo") # --- Extensions --- if args.Extension: - arguments += ["-Extension", args.Extension] + arguments += ["-Extension", f'"{args.Extension}"'] elif args.AllExtensions: arguments.append("-AllExtensions") @@ -586,20 +664,16 @@ def main(): # --- Output --- out_file = os.path.join(temp_dir, "load_log.txt") - arguments += ["/Out", out_file] + arguments += ["/Out", f'"{out_file}"'] arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print("") print("Executing partial configuration load...") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -620,6 +694,7 @@ def main(): except Exception: pass + print_platform_output(result) sys.exit(exit_code) finally: diff --git a/.claude/skills/db-load-xml/scripts/db-load-xml.ps1 b/.claude/skills/db-load-xml/scripts/db-load-xml.ps1 index ea6bf4f1..a5515b6d 100644 --- a/.claude/skills/db-load-xml/scripts/db-load-xml.ps1 +++ b/.claude/skills/db-load-xml/scripts/db-load-xml.ps1 @@ -1,4 +1,4 @@ -# db-load-xml v1.18 — Load 1C configuration from XML files +# db-load-xml v1.19 — Load 1C configuration from XML files # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -266,6 +266,43 @@ function Format-ArgsForDisplay { return ,$res } +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' +$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir' +$ListFile = ConvertTo-CleanPath $ListFile '-ListFile' + +function Assert-InfoBaseExists { + # These skills work on a ready infobase. Saying so up front beats the platform's + # "Неверные или отсутствующие параметры соединения" after a launch. + param([string]$Path) + if (-not $Path) { return } + if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) { + Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red + exit 1 + } +} + +Assert-InfoBaseExists $InfoBasePath + # --- Resolve V8Path --- function Find-ProjectV8Path { $dir = (Get-Location).Path @@ -310,32 +347,75 @@ if (-not (Test-Path $V8Path)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + $engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" } @@ -408,16 +488,16 @@ try { $arguments += "--data=$tempDir" $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode if ($exitCode -ne 0) { Write-Host "Error loading configuration from files (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output if ($UpdateDB) { $applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force") @@ -426,7 +506,7 @@ try { $applyArgs += "--data=$tempDir" $applyArgs += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $applyArgs + $__ib = Invoke-PlatformProcess $V8Path $applyArgs $applyOut = $__ib.Output $exitCode = $__ib.ExitCode if ($exitCode -eq 0) { @@ -434,7 +514,7 @@ try { } else { Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red } - if ($applyOut) { Write-Host ($applyOut | Out-String) } + Write-PlatformOutput $applyOut } exit $exitCode } @@ -517,8 +597,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Read log --- $logContent = $null @@ -565,6 +645,7 @@ try { Write-Host $logContent Write-Host "--- End ---" } + Write-PlatformOutput $__v8.Output if ($silentFailures.Count -gt 0) { $msg = "[warning] log contains $($silentFailures.Count) rejection(s) — platform loaded config but dropped properties/refs" diff --git a/.claude/skills/db-load-xml/scripts/db-load-xml.py b/.claude/skills/db-load-xml/scripts/db-load-xml.py index 36d97e61..111bc858 100644 --- a/.claude/skills/db-load-xml/scripts/db-load-xml.py +++ b/.claude/skills/db-load-xml/scripts/db-load-xml.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# db-load-xml v1.18 — Load 1C configuration from XML files +# db-load-xml v1.19 — Load 1C configuration from XML files # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + 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): @@ -333,6 +415,12 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir") + args.ListFile = clean_path(args.ListFile, "-ListFile") + # --- Resolve V8Path --- v8path = resolve_v8path(args.V8Path) @@ -412,14 +500,8 @@ def main(): result = run_ibcmd([v8path] + arguments, bool(args.UserName)) if result.returncode != 0: print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(result.returncode) print(f"Configuration loaded successfully from: {args.ConfigDir}") - if result.stdout: - print(result.stdout) exit_code = 0 if args.UpdateDB: apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"] @@ -436,10 +518,7 @@ def main(): print("Database configuration updated successfully") else: print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) - if ar.stdout: - print(ar.stdout) - if ar.stderr: - print(ar.stderr, file=sys.stderr) + print_platform_output(ar) sys.exit(exit_code) # --- Temp dir --- @@ -451,16 +530,16 @@ def main(): arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"] + arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'] else: - arguments += ["/F", args.InfoBasePath] + arguments += ["/F", f'"{args.InfoBasePath}"'] if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') - arguments += ["/LoadConfigFromFiles", args.ConfigDir] + arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"'] if args.Mode == "Full": print("Executing full configuration load...") @@ -496,7 +575,7 @@ def main(): for fl in file_list: print(f" {fl}") - arguments += ["-listFile", generated_list_file] + arguments += ["-listFile", f'"{generated_list_file}"'] arguments.append("-partial") arguments.append("-updateConfigDumpInfo") @@ -504,7 +583,7 @@ def main(): # --- Extensions --- if args.Extension: - arguments += ["-Extension", args.Extension] + arguments += ["-Extension", f'"{args.Extension}"'] elif args.AllExtensions: arguments.append("-AllExtensions") @@ -514,17 +593,13 @@ def main(): # --- Output --- out_file = os.path.join(temp_dir, "load_log.txt") - arguments += ["/Out", out_file] + arguments += ["/Out", f'"{out_file}"'] arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Read log --- @@ -570,6 +645,7 @@ def main(): print(log_content) print("--- End ---") + print_platform_output(result) if silent_failures: suffix = "" if args.StrictLog else " (pass -StrictLog to treat as error)" print( diff --git a/.claude/skills/db-update/scripts/db-update.ps1 b/.claude/skills/db-update/scripts/db-update.ps1 index 646c47c6..6eb52344 100644 --- a/.claude/skills/db-update/scripts/db-update.ps1 +++ b/.claude/skills/db-update/scripts/db-update.ps1 @@ -1,4 +1,4 @@ -# db-update v1.12 — Update 1C database configuration +# db-update v1.13 — Update 1C database configuration # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -247,6 +247,41 @@ function Format-ArgsForDisplay { 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 @@ -291,32 +326,75 @@ if (-not (Test-Path $V8Path)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + $engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" } @@ -355,7 +433,7 @@ try { $arguments += "--data=$tempDir" $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode if ($exitCode -eq 0) { @@ -363,7 +441,7 @@ try { } else { Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red } - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } @@ -408,8 +486,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- if ($exitCode -eq 0) { @@ -426,6 +504,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/db-update/scripts/db-update.py b/.claude/skills/db-update/scripts/db-update.py index ff83cbe2..8ff02429 100644 --- a/.claude/skills/db-update/scripts/db-update.py +++ b/.claude/skills/db-update/scripts/db-update.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# db-update v1.12 — Update 1C database configuration +# db-update v1.13 — Update 1C database configuration # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + 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): @@ -315,6 +397,10 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + v8path = resolve_v8path(args.V8Path) engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" @@ -366,10 +452,6 @@ def main(): print("Database configuration updated successfully") else: print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(result.returncode) # --- Temp dir --- @@ -381,14 +463,14 @@ def main(): arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]) + arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']) else: - arguments.extend(["/F", args.InfoBasePath]) + arguments.extend(["/F", f'"{args.InfoBasePath}"']) if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') arguments.append("/UpdateDBCfg") @@ -402,23 +484,19 @@ def main(): # --- Extensions --- if args.Extension: - arguments.extend(["-Extension", args.Extension]) + arguments.extend(["-Extension", f'"{args.Extension}"']) elif args.AllExtensions: arguments.append("-AllExtensions") # --- Output --- out_file = os.path.join(temp_dir, "update_log.txt") - arguments.extend(["/Out", out_file]) + arguments.extend(["/Out", f'"{out_file}"']) arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -438,6 +516,7 @@ def main(): except Exception: pass + print_platform_output(result) sys.exit(exit_code) finally: diff --git a/.claude/skills/epf-build/scripts/epf-build.ps1 b/.claude/skills/epf-build/scripts/epf-build.ps1 index c72e70be..7f79cc5d 100644 --- a/.claude/skills/epf-build/scripts/epf-build.ps1 +++ b/.claude/skills/epf-build/scripts/epf-build.ps1 @@ -1,4 +1,4 @@ -# epf-build v1.11 — Build external data processor or report (EPF/ERF) from XML sources +# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -211,6 +211,43 @@ function Format-ArgsForDisplay { return ,$res } +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' +$SourceFile = ConvertTo-CleanPath $SourceFile '-SourceFile' +$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile' + +function Assert-InfoBaseExists { + # These skills work on a ready infobase. Saying so up front beats the platform's + # "Неверные или отсутствующие параметры соединения" after a launch. + param([string]$Path) + if (-not $Path) { return } + if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) { + Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red + exit 1 + } +} + +Assert-InfoBaseExists $InfoBasePath + # --- Resolve V8Path --- function Find-ProjectV8Path { $dir = (Get-Location).Path @@ -255,32 +292,75 @@ if (-not (Test-Path $V8Path)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + function Test-OutputNonEmpty { # Postcondition: the platform must have produced a non-empty output file. @@ -354,7 +434,7 @@ try { $arguments += "--data=$tempDir" $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode $outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile) @@ -366,7 +446,7 @@ try { } else { Write-Host "Error building external data processor/report (code: $exitCode)" -ForegroundColor Red } - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } @@ -393,8 +473,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- # Postcondition: exit 0 without a non-empty output file is a false success. @@ -416,6 +496,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/epf-build/scripts/epf-build.py b/.claude/skills/epf-build/scripts/epf-build.py index c61b366a..e62af3c5 100644 --- a/.claude/skills/epf-build/scripts/epf-build.py +++ b/.claude/skills/epf-build/scripts/epf-build.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# epf-build v1.11 — Build external data processor or report (EPF/ERF) from XML sources +# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r def output_nonempty(path): @@ -294,6 +376,12 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + args.SourceFile = clean_path(args.SourceFile, "-SourceFile") + args.OutputFile = clean_path(args.OutputFile, "-OutputFile") + # --- Resolve V8Path --- v8path = resolve_v8path(args.V8Path) engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" @@ -375,40 +463,32 @@ def main(): print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr) else: print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(exit_code) # --- Build arguments --- arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"] + arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'] else: - arguments += ["/F", args.InfoBasePath] + arguments += ["/F", f'"{args.InfoBasePath}"'] if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') - arguments += ["/LoadExternalDataProcessorOrReportFromFiles", args.SourceFile, args.OutputFile] + arguments += ["/LoadExternalDataProcessorOrReportFromFiles", f'"{args.SourceFile}"', f'"{args.OutputFile}"'] # --- Output --- out_file = os.path.join(temp_dir, "build_log.txt") - arguments += ["/Out", out_file] + arguments += ["/Out", f'"{out_file}"'] arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -434,6 +514,7 @@ def main(): except Exception: pass + print_platform_output(result) sys.exit(exit_code) finally: diff --git a/.claude/skills/epf-build/scripts/stub-db-create.ps1 b/.claude/skills/epf-build/scripts/stub-db-create.ps1 index 367f2b93..fb0760e7 100644 --- a/.claude/skills/epf-build/scripts/stub-db-create.ps1 +++ b/.claude/skills/epf-build/scripts/stub-db-create.ps1 @@ -1,4 +1,4 @@ -# stub-db-create v1.5 — Create temp 1C infobase with metadata stubs for EPF/ERF build +# stub-db-create v1.6 — Create temp 1C infobase with metadata stubs for EPF/ERF build # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -1380,32 +1380,75 @@ $propsXml $childObjLine } # --- 5a. Stub via ibcmd (one call: create [--import --apply]) --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + $stubEngine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" } @@ -1428,12 +1471,12 @@ if ($stubEngine -eq "ibcmd") { if ($hasRefTypes) { $ibArgs += "--import=$(Join-Path $TempBasePath 'cfg')", "--apply", "--force" } $ibArgs += "--data=$ibData" $ibArgs += $extraArgs - $__ib = Invoke-IbcmdProcess $V8Path $ibArgs + $__ib = Invoke-PlatformProcess $V8Path $ibArgs $ibOut = $__ib.Output $ibRc = $__ib.ExitCode Remove-Item -Path $ibData -Recurse -Force -ErrorAction SilentlyContinue if ($ibRc -ne 0) { - if ($ibOut) { Write-Host ($ibOut | Out-String) } + Write-PlatformOutput $ibOut Write-Error "Failed to create stub infobase (code: $ibRc)" exit 1 } @@ -1446,8 +1489,9 @@ if ($stubEngine -eq "ibcmd") { # --- 5. Create infobase --- Write-Host "Creating infobase: $TempBasePath" $createArgs = "CREATEINFOBASE File=`"$TempBasePath`" /DisableStartupDialogs" + $extraArgString -$proc = Start-Process -FilePath $V8Path -ArgumentList $createArgs -NoNewWindow -Wait -PassThru +$proc = Invoke-PlatformProcess $V8Path @($createArgs) -PreQuoted if ($proc.ExitCode -ne 0) { + Write-PlatformOutput $proc.Output Write-Error "Failed to create infobase (code: $($proc.ExitCode))" exit 1 } @@ -1459,9 +1503,10 @@ if ($hasRefTypes) { Write-Host "Loading configuration from files..." $loadLog = Join-Path $env:TEMP "stub_load_log.txt" $loadArgs = "DESIGNER /F`"$TempBasePath`" /LoadConfigFromFiles `"$cfgDir`" /Out `"$loadLog`" /DisableStartupDialogs" + $extraArgString - $proc = Start-Process -FilePath $V8Path -ArgumentList $loadArgs -NoNewWindow -Wait -PassThru + $proc = Invoke-PlatformProcess $V8Path @($loadArgs) -PreQuoted if ($proc.ExitCode -ne 0) { if (Test-Path $loadLog) { Get-Content $loadLog -Raw -ErrorAction SilentlyContinue | Write-Host } + Write-PlatformOutput $proc.Output Write-Error "Failed to load config (code: $($proc.ExitCode))" exit 1 } @@ -1470,9 +1515,10 @@ if ($hasRefTypes) { Write-Host "Updating database configuration..." $updateLog = Join-Path $env:TEMP "stub_update_log.txt" $updateArgs = "DESIGNER /F`"$TempBasePath`" /UpdateDBCfg /Out `"$updateLog`" /DisableStartupDialogs" + $extraArgString - $proc = Start-Process -FilePath $V8Path -ArgumentList $updateArgs -NoNewWindow -Wait -PassThru + $proc = Invoke-PlatformProcess $V8Path @($updateArgs) -PreQuoted if ($proc.ExitCode -ne 0) { if (Test-Path $updateLog) { Get-Content $updateLog -Raw -ErrorAction SilentlyContinue | Write-Host } + Write-PlatformOutput $proc.Output Write-Error "Failed to update DB config (code: $($proc.ExitCode))" exit 1 } diff --git a/.claude/skills/epf-build/scripts/stub-db-create.py b/.claude/skills/epf-build/scripts/stub-db-create.py index c744aef5..b8a0392c 100644 --- a/.claude/skills/epf-build/scripts/stub-db-create.py +++ b/.claude/skills/epf-build/scripts/stub-db-create.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# stub-db-create v1.5 — Create temp 1C infobase with metadata stubs for EPF/ERF build +# stub-db-create v1.6 — Create temp 1C infobase with metadata stubs for EPF/ERF build # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -20,6 +20,75 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -30,7 +99,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r # --- Additional platform arguments --- @@ -1251,11 +1323,10 @@ def main(): # Create infobase print(f'Creating infobase: {temp_base}') - result = subprocess.run( - [args.V8Path, 'CREATEINFOBASE', f'File={temp_base}', '/DisableStartupDialogs'] + extra_args, - capture_output=True, text=True, - ) + result = run_v8(args.V8Path, ['CREATEINFOBASE', f'File="{temp_base}"', '/DisableStartupDialogs'] + + [quote_if_needed(a) for a in extra_args]) if result.returncode != 0: + print_platform_output(result) print(f'Failed to create infobase (code: {result.returncode})', file=sys.stderr) sys.exit(1) @@ -1263,21 +1334,18 @@ def main(): cfg_dir = os.path.join(temp_base, 'cfg') # LoadConfigFromFiles print('Loading configuration from files...') - result = subprocess.run( - [args.V8Path, 'DESIGNER', f'/F{temp_base}', '/LoadConfigFromFiles', cfg_dir, '/DisableStartupDialogs'] + extra_args, - capture_output=True, text=True, - ) + result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/LoadConfigFromFiles', f'"{cfg_dir}"', + '/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args]) if result.returncode != 0: + print_platform_output(result) print(f'Failed to load config (code: {result.returncode})', file=sys.stderr) sys.exit(1) # UpdateDBCfg print('Updating database configuration...') update_log = os.path.join(tempfile.gettempdir(), 'stub_update_log.txt') - result = subprocess.run( - [args.V8Path, 'DESIGNER', f'/F{temp_base}', '/UpdateDBCfg', '/Out', update_log, '/DisableStartupDialogs'] + extra_args, - capture_output=True, text=True, - ) + result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/UpdateDBCfg', '/Out', f'"{update_log}"', + '/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args]) if result.returncode != 0: if os.path.isfile(update_log): try: @@ -1285,6 +1353,7 @@ def main(): print(f.read()) except Exception: pass + print_platform_output(result) print(f'Failed to update DB config (code: {result.returncode})', file=sys.stderr) sys.exit(1) diff --git a/.claude/skills/epf-dump/scripts/epf-dump.ps1 b/.claude/skills/epf-dump/scripts/epf-dump.ps1 index 64d59a19..472d711b 100644 --- a/.claude/skills/epf-dump/scripts/epf-dump.ps1 +++ b/.claude/skills/epf-dump/scripts/epf-dump.ps1 @@ -1,4 +1,4 @@ -# epf-dump v1.10 — Dump external data processor or report (EPF/ERF) to XML sources +# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # NB: *nix-раскладку платформы (/opt/1cv8//1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. <# @@ -211,6 +211,43 @@ function Format-ArgsForDisplay { return ,$res } +function ConvertTo-CleanPath { + # Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + # surrounding quotes that survived shell parsing, a trailing separator. A quote left + # inside afterwards cannot be part of a real path — reject it by name instead of letting + # 1C answer with its opaque "Неверные или отсутствующие параметры соединения". + param([string]$Value, [string]$ParamName) + if (-not $Value) { return $Value } + $v = $Value.Trim() + if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) { + $v = $v.Substring(1, $v.Length - 2).Trim() + } + if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) } + if ($v.Contains('"')) { + Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red + exit 1 + } + return $v +} + +$V8Path = ConvertTo-CleanPath $V8Path '-V8Path' +$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath' +$InputFile = ConvertTo-CleanPath $InputFile '-InputFile' +$OutputDir = ConvertTo-CleanPath $OutputDir '-OutputDir' + +function Assert-InfoBaseExists { + # These skills work on a ready infobase. Saying so up front beats the platform's + # "Неверные или отсутствующие параметры соединения" after a launch. + param([string]$Path) + if (-not $Path) { return } + if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) { + Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red + exit 1 + } +} + +Assert-InfoBaseExists $InfoBasePath + # --- Resolve V8Path --- function Find-ProjectV8Path { $dir = (Get-Location).Path @@ -262,32 +299,75 @@ if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) { } # --- Detect engine (ibcmd vs 1cv8) by exe name --- -function Invoke-IbcmdProcess { - # Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt - # fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's - # native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process. - param([string]$Exe, [string[]]$IbArgs) +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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' ' + $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 - try { - $psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866) - $psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866) - } catch {} $p = [System.Diagnostics.Process]::Start($psi) $p.StandardInput.Close() - $out = $p.StandardOutput.ReadToEnd() - $err = $p.StandardError.ReadToEnd() + # stderr is drained in parallel: reading the streams one after another deadlocks + # as soon as the other one fills its pipe buffer. + $errMs = New-Object System.IO.MemoryStream + $errTask = $p.StandardError.BaseStream.CopyToAsync($errMs) + $outMs = New-Object System.IO.MemoryStream + $p.StandardOutput.BaseStream.CopyTo($outMs) + $errTask.Wait() $p.WaitForExit() + $out = ConvertFrom-PlatformBytes $outMs.ToArray() + $err = ConvertFrom-PlatformBytes $errMs.ToArray() if ($err) { $out += $err } return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode } } +function Write-PlatformOutput { + # Print what the platform wrote to the console as its own labelled block. Silence stays + # silent: in batch mode 1cv8 reports through /Out and prints nothing here. + param([string]$Text) + if (-not $Text) { return } + $t = $Text.TrimEnd() + if (-not $t) { return } + $limit = 65536 + if ($t.Length -gt $limit) { + $t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit) + } + Write-Host "--- Вывод платформы ---" + Write-Host $t + Write-Host "--- End ---" +} + function Test-DirNonEmpty { # Postcondition: the platform must have written files into the output directory. @@ -343,7 +423,7 @@ try { $arguments += "--data=$tempDir" $arguments += $extraArgs Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $__ib = Invoke-IbcmdProcess $V8Path $arguments + $__ib = Invoke-PlatformProcess $V8Path $arguments $output = $__ib.Output $exitCode = $__ib.ExitCode $outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir) @@ -355,7 +435,7 @@ try { } else { Write-Host "Error dumping external data processor/report (code: $exitCode)" -ForegroundColor Red } - if ($output) { Write-Host ($output | Out-String) } + Write-PlatformOutput $output exit $exitCode } @@ -383,8 +463,8 @@ try { # --- Execute --- Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" - $process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru - $exitCode = $process.ExitCode + $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted + $exitCode = $__v8.ExitCode # --- Result --- # Postcondition: exit 0 with an empty output directory is a false success. @@ -406,6 +486,7 @@ try { Write-Host "--- End ---" } } + Write-PlatformOutput $__v8.Output exit $exitCode diff --git a/.claude/skills/epf-dump/scripts/epf-dump.py b/.claude/skills/epf-dump/scripts/epf-dump.py index 547ca358..55de0ce5 100644 --- a/.claude/skills/epf-dump/scripts/epf-dump.py +++ b/.claude/skills/epf-dump/scripts/epf-dump.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# epf-dump v1.10 — Dump external data processor or report (EPF/ERF) to XML sources +# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -243,6 +243,85 @@ IBCMD_NOUSER_HINT = ( ) +def decode_platform_bytes(data): + """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit + OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale + code page (what text=True uses) mangles both.""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("cp866", errors="replace") + + +def assert_infobase_exists(path): + """These skills work on a ready infobase. Saying so up front beats the platform's + "Неверные или отсутствующие параметры соединения" after a launch.""" + if not path: + return + if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): + print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) + sys.exit(1) + + +def clean_path(value, param=""): + """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, + surrounding quotes that survived shell parsing, a trailing separator. A quote left + inside afterwards cannot be part of a real path — reject it by name instead of letting + 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" + if not value: + return value + v = value.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1].strip() + if len(v) > 3 and v[-1] in "\\/": + v = v[:-1] + if '"' in v: + print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) + sys.exit(1) + return v + + +def quote_if_needed(token): + """Extra arguments come from the caller unquoted; the 1cv8 command line is joined + verbatim, so a token with a space needs quotes of its own.""" + if token and (" " in token or "\t" in token) and '"' not in token: + return f'"{token}"' + return token + + +def run_v8(v8path, arguments): + """Run 1cv8 in batch mode and capture its console output. + + The arguments carry their own quotes inside the value (File="C:\\a b") — that is where + 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would + escape those quotes, so there the command line is handed over ready-made. + """ + if os.name == "nt": + cmd = '"' + v8path + '" ' + " ".join(arguments) + else: + cmd = [v8path] + arguments + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r + + +def print_platform_output(result): + """Print what the platform wrote to the console as its own labelled block. Silence stays + silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" + text = ((result.stdout or "") + (result.stderr or "")).rstrip() + if not text: + return + limit = 65536 + if len(text) > limit: + text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] + print("--- Вывод платформы ---") + print(text) + print("--- End ---") + + def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. @@ -253,7 +332,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True): if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() - return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace") + r = subprocess.run(cmd, input=b"", capture_output=True) + r.stdout = decode_platform_bytes(r.stdout) + r.stderr = decode_platform_bytes(r.stderr) + return r def dir_nonempty(path): @@ -300,6 +382,12 @@ def main(): argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = parser.parse_args(argv) + args.V8Path = clean_path(args.V8Path, "-V8Path") + args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") + assert_infobase_exists(args.InfoBasePath) + args.InputFile = clean_path(args.InputFile, "-InputFile") + args.OutputDir = clean_path(args.OutputDir, "-OutputDir") + # --- Resolve V8Path --- v8path = resolve_v8path(args.V8Path) engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" @@ -366,41 +454,33 @@ def main(): print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr) else: print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) sys.exit(exit_code) # --- Build arguments --- arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: - arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"] + arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'] else: - arguments += ["/F", args.InfoBasePath] + arguments += ["/F", f'"{args.InfoBasePath}"'] if args.UserName: - arguments.append(f"/N{args.UserName}") + arguments.append(f'/N"{args.UserName}"') if args.Password: - arguments.append(f"/P{args.Password}") + arguments.append(f'/P"{args.Password}"') - arguments += ["/DumpExternalDataProcessorOrReportToFiles", args.OutputDir, args.InputFile] + arguments += ["/DumpExternalDataProcessorOrReportToFiles", f'"{args.OutputDir}"', f'"{args.InputFile}"'] arguments += ["-Format", args.Format] # --- Output --- out_file = os.path.join(temp_dir, "dump_log.txt") - arguments += ["/Out", out_file] + arguments += ["/Out", f'"{out_file}"'] arguments.append("/DisableStartupDialogs") - arguments.extend(extra_args) + arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") - result = subprocess.run( - [v8path] + arguments, - capture_output=True, - text=True, - ) + result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- @@ -426,6 +506,7 @@ def main(): except Exception: pass + print_platform_output(result) sys.exit(exit_code) finally: