#!/usr/bin/env python3 # db-dump-xml v1.14 — Dump 1C configuration to XML files # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import atexit import glob import json import os import random import re import shutil import subprocess import sys import tempfile def _find_project_v8path(): """Walk up from CWD to find .v8-project.json and read its v8path.""" d = os.getcwd() while True: pf = os.path.join(d, ".v8-project.json") if os.path.isfile(pf): try: with open(pf, encoding="utf-8-sig") as f: data = json.load(f) v = data.get("v8path") if v: return v except Exception: pass return None parent = os.path.dirname(d) if parent == d: return None d = parent # --- Additional platform arguments --- V8_OWNED_KEYS = [ "DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG", "/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs", "/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC", "/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg", "/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg", "/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles", ] IBCMD_OWNED_KEYS = [ "--db-path", "--data", "--out", "--file", "--load", "--restore", "--import", "--export", "--apply", "--force", "--create-database", "--user", "--password", ] V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"] IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] def arg_key_match(token, key): """Token matches a key when it equals it, or starts with it and the next character is not a letter — catches glued /N"user" and --password=x, while keeping /ClearCache distinct from /C.""" if len(token) < len(key): return False if token[: len(key)].lower() != key.lower(): return False if len(token) == len(key): return True return not token[len(key)].isalpha() def project_extra_args(name): """v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.""" d = os.getcwd() while True: pf = os.path.join(d, ".v8-project.json") if os.path.isfile(pf): try: with open(pf, encoding="utf-8-sig") as f: data = json.load(f) v = data.get(name) if v: return [str(x) for x in v] except Exception: pass return [] parent = os.path.dirname(d) if parent == d: return [] d = parent def assert_extra_args(extra, engine, hints): """The platform accepts only one batch operation, and a duplicate connection or output key fails with an opaque 1C error — reject what the skill owns itself.""" param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments" owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS for tok in extra: if engine == "ibcmd" and not tok.startswith("-"): print( f"Error: '{tok}' is a positional token — pass values as --key=value " f"({param} cannot extend the ibcmd command)", file=sys.stderr, ) sys.exit(1) for k in owned: if arg_key_match(tok, k): hint = f" (use {hints[k]})" if hints and k in hints else "" print( f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", file=sys.stderr, ) sys.exit(1) def format_args_for_display(arglist, engine): """Redact values of secret-prone keys in glued, =-joined and separate forms. Matching here is a plain prefix (no letter rule): over-masking costs nothing, a leaked password does.""" keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS res = [] mask_next = False for tok in arglist: if mask_next: res.append("***") mask_next = False continue hit = None for k in keys: if tok[: len(k)].lower() == k.lower(): hit = k break if hit is None: res.append(tok) elif len(tok) == len(hit): res.append(tok) mask_next = True elif tok[len(hit)] == "=": res.append(hit + "=***") else: res.append(hit + "***") return res def extract_extra_args(argv, known_opts): """argparse refuses values that start with '-' (every ibcmd key does), so pull the two escape-hatch lists out of argv by hand: after the flag, take everything up to the next declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra).""" rest, v8, ibcmd = [], [], [] i = 0 while i < len(argv): low = argv[i].lower() if low in ("-additionalv8arguments", "-additionalibcmdarguments"): target = v8 if low == "-additionalv8arguments" else ibcmd i += 1 while i < len(argv) and argv[i].lower() not in known_opts: target.append(argv[i]) i += 1 continue rest.append(argv[i]) i += 1 return rest, v8, ibcmd def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints): """Pick the argument list for the selected engine and validate it. An explicitly passed parameter for the other engine is an error; the same keys coming from .v8-project.json simply do not apply — a project may describe both engines. Comma-separated elements are split apart: PowerShell's -File cannot bind an array, so that form is the documented one and both ports must accept it. A value containing a comma is not supported.""" v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p] ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p] if engine == "ibcmd" and v8_extra: print( "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "(use -AdditionalIbcmdArguments)", file=sys.stderr, ) sys.exit(1) if engine != "ibcmd" and ibcmd_extra: print( "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "(use -AdditionalV8Arguments)", file=sys.stderr, ) sys.exit(1) if engine == "ibcmd": extra = project_extra_args("ibcmdargs") + list(ibcmd_extra) else: extra = project_extra_args("v8args") + list(v8_extra) if extra: assert_extra_args(extra, engine, hints) return extra def _version_dir(p): """Version dir for both Windows (.../1cv8//bin/1cv8.exe) and *nix (.../1cv8//1cv8).""" parent = os.path.dirname(p) if os.path.basename(parent).lower() == "bin": parent = os.path.dirname(parent) return os.path.basename(parent) def _version_key(p): """Numeric sort key from version dir name.""" return [int(x) for x in re.findall(r"\d+", _version_dir(p))] def resolve_v8path(v8path): """Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly).""" if not v8path: v8path = _find_project_v8path() if not v8path: if os.name == "nt": candidates = ( glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe") + glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") ) else: # PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1. candidates = glob.glob("/opt/1cv8/*/1cv8") if candidates: v8path = max(candidates, key=_version_key) print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") else: print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) sys.exit(1) if os.path.isdir(v8path): # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. exe = "1cv8.exe" if os.name == "nt" else "1cv8" v8path = os.path.join(v8path, exe) if not os.path.isfile(v8path): print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) sys.exit(1) return v8path IBCMD_NOUSER_HINT = ( "[ibcmd] No -UserName/-Password given; the infobase may require authentication. " "On Windows ibcmd reads credentials from the console (stdin is ignored), so this " "call may block instead of failing. If it does not return promptly, abort and " "re-run with -UserName and -Password.\n" ) def decode_platform_bytes(data): """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale code page (what text=True uses) mangles both.""" if not data: return "" try: return data.decode("utf-8") except UnicodeDecodeError: return data.decode("cp866", errors="replace") def assert_infobase_exists(path): """These skills work on a ready infobase. Saying so up front beats the platform's "Неверные или отсутствующие параметры соединения" after a launch.""" if not path: return if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) sys.exit(1) def clean_path(value, param=""): """Forgive what is unambiguous in a path the caller passed: surrounding whitespace, surrounding quotes that survived shell parsing, a trailing separator. A quote left inside afterwards cannot be part of a real path — reject it by name instead of letting 1C answer with its opaque "Неверные или отсутствующие параметры соединения".""" if not value: return value v = value.strip() if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": v = v[1:-1].strip() if len(v) > 3 and v[-1] in "\\/": v = v[:-1] if '"' in v: print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) sys.exit(1) return v def quote_if_needed(token): """Extra arguments come from the caller unquoted; the 1cv8 command line is joined verbatim, so a token with a space needs quotes of its own.""" if token and (" " in token or "\t" in token) and '"' not in token: return f'"{token}"' return token def run_v8(v8path, arguments): """Run 1cv8 in batch mode and capture its console output. The arguments carry their own quotes inside the value (File="C:\\a b") — that is where 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would escape those quotes, so there the command line is handed over ready-made. """ if os.name == "nt": cmd = '"' + v8path + '" ' + " ".join(arguments) else: cmd = [v8path] + arguments r = subprocess.run(cmd, input=b"", capture_output=True) r.stdout = decode_platform_bytes(r.stdout) r.stderr = decode_platform_bytes(r.stderr) return r def print_platform_output(result): """Print what the platform wrote to the console as its own labelled block. Silence stays silent: in batch mode 1cv8 reports through /Out and prints nothing here.""" text = ((result.stdout or "") + (result.stderr or "")).rstrip() if not text: return limit = 65536 if len(text) > limit: text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:] print("--- Вывод платформы ---") print(text) print("--- End ---") def run_ibcmd(cmd, has_username=False, warn_no_user=True): """Run an ibcmd command non-interactively. input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging. On Windows without -UserName ibcmd reads the console directly and may still block — that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). """ if warn_no_user and os.name == "nt" and not has_username: sys.stderr.write(IBCMD_NOUSER_HINT) sys.stderr.flush() r = subprocess.run(cmd, input=b"", capture_output=True) r.stdout = decode_platform_bytes(r.stdout) r.stderr = decode_platform_bytes(r.stderr) return r def dir_nonempty(path): """Postcondition: the platform must have written files into the output directory. Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.""" return os.path.isdir(path) and any(os.scandir(path)) def _redact(text, *secrets): """Redact literal secret values (password, user) from a display string — precise, never touches lookalike paths.""" for s in secrets: if s: text = text.replace(s, "***") return text def main(): sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser( description="Dump 1C configuration to XML files", allow_abbrev=False, ) parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory") parser.add_argument("-InfoBasePath", default="", help="Path to file infobase") parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)") parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server") parser.add_argument("-UserName", default="", help="1C user name") parser.add_argument("-Password", default="", help="1C user password") parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump") parser.add_argument( "-Mode", default="Changes", choices=["Full", "Changes", "Partial", "UpdateInfo"], help="Dump mode (default: Changes)", ) parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)") parser.add_argument("-Extension", default="", help="Extension name to dump") parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions") parser.add_argument( "-Format", default="Hierarchical", choices=["Hierarchical", "Plain"], help="Dump format (default: Hierarchical)", ) parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[], help="Extra 1cv8 arguments, e.g. /UseHwLicenses+") parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[], help="Extra ibcmd arguments in --key=value form") known_opts = {s.lower() for a in parser._actions for s in a.option_strings} argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) args = 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" # --- Resolve additional arguments for the selected engine --- arg_hints = { "/F": "-InfoBasePath", "/S": "-InfoBaseServer + -InfoBaseRef", "/N": "-UserName", "/P": "-Password", "--db-path": "-InfoBasePath", "--user": "-UserName", "--password": "-Password", } extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints) # --- Validate connection --- if engine == "ibcmd": if not args.InfoBasePath: print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) sys.exit(1) elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) sys.exit(1) # --- Validate Partial mode --- if args.Mode == "Partial" and not args.Objects: print("Error: -Objects required for Partial mode", file=sys.stderr) sys.exit(1) # --- Create output dir if needed --- if not os.path.exists(args.ConfigDir): os.makedirs(args.ConfigDir, exist_ok=True) print(f"Created output directory: {args.ConfigDir}") # --- ibcmd branch (file infobase only; hierarchical Full/Changes) --- if engine == "ibcmd": if args.Format == "Plain": print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) sys.exit(1) if args.AllExtensions: arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"] elif args.Mode == "UpdateInfo": print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8", file=sys.stderr) sys.exit(1) elif args.Mode == "Partial": obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()] arguments = ["infobase", "config", "export", "objects"] + obj_list arguments += [f"--out={args.ConfigDir}", f"--db-path={args.InfoBasePath}"] if args.Extension: arguments.append(f"--extension={args.Extension}") else: arguments = ["infobase", "config", "export", f"--db-path={args.InfoBasePath}"] if args.Extension: arguments.append(f"--extension={args.Extension}") arguments.append(args.ConfigDir) ib_data = tempfile.mkdtemp(prefix="ibcmd_data_") atexit.register(shutil.rmtree, ib_data, ignore_errors=True) if args.UserName: arguments.append(f"--user={args.UserName}") if args.Password: arguments.append(f"--password={args.Password}") arguments.append(f"--data={ib_data}") arguments.extend(extra_args) print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") result = run_ibcmd([v8path] + arguments, bool(args.UserName)) exit_code = result.returncode out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir) if out_missing: exit_code = 1 if exit_code == 0: print(f"Configuration exported successfully to: {args.ConfigDir}") elif out_missing: print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr) else: print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr) sys.exit(exit_code) # --- Temp dir --- temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_xml_{random.randint(0, 999999)}") os.makedirs(temp_dir, exist_ok=True) try: # --- Build arguments --- arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'] else: arguments += ["/F", f'"{args.InfoBasePath}"'] if args.UserName: arguments.append(f'/N"{args.UserName}"') if args.Password: arguments.append(f'/P"{args.Password}"') arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"'] arguments += ["-Format", args.Format] if args.Mode == "Full": print("Executing full configuration dump...") elif args.Mode == "Changes": print("Executing incremental configuration dump...") arguments.append("-update") arguments.append("-force") elif args.Mode == "Partial": print("Executing partial configuration dump...") object_list = [obj.strip() for obj in args.Objects.split(",") if obj.strip()] list_file = os.path.join(temp_dir, "dump_list.txt") with open(list_file, "w", encoding="utf-8-sig") as f: f.write("\n".join(object_list)) arguments += ["-listFile", f'"{list_file}"'] print(f"Objects to dump: {len(object_list)}") for obj in object_list: print(f" {obj}") elif args.Mode == "UpdateInfo": print("Updating ConfigDumpInfo.xml...") arguments.append("-configDumpInfoOnly") # --- Extensions --- if args.Extension: arguments += ["-Extension", f'"{args.Extension}"'] elif args.AllExtensions: arguments.append("-AllExtensions") # --- Output --- out_file = os.path.join(temp_dir, "dump_log.txt") arguments += ["/Out", f'"{out_file}"'] arguments.append("/DisableStartupDialogs") arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- # Postcondition: exit 0 with an empty output directory is a false success. out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir) if out_missing: exit_code = 1 if exit_code == 0: print("Dump completed successfully") print(f"Configuration dumped to: {args.ConfigDir}") elif out_missing: print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped", file=sys.stderr) else: print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr) if os.path.isfile(out_file): try: with open(out_file, "r", encoding="utf-8-sig") as f: log_content = f.read() if log_content: print("--- Log ---") print(log_content) print("--- End ---") except Exception: pass print_platform_output(result) sys.exit(exit_code) finally: if os.path.exists(temp_dir): shutil.rmtree(temp_dir, ignore_errors=True) if __name__ == "__main__": main()