#!/usr/bin/env python3 # db-load-git v1.18 — Load Git changes into 1C database # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import atexit import glob import json import os import random import re import shutil import subprocess import sys import tempfile 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 get_object_xml_from_subfile(relative_path): """Map sub-file path (BSL, HTML, etc.) to object XML path.""" parts = re.split(r"[\\/]", relative_path) if len(parts) >= 2: return f"{parts[0]}/{parts[1]}.xml" return None def run_git(config_dir, git_args): """Run a git command in config_dir and return output lines on success.""" result = subprocess.run( ["git", "-c", "core.quotePath=false"] + git_args, capture_output=True, text=True, encoding="utf-8", cwd=config_dir, ) if result.returncode == 0: return [line for line in result.stdout.splitlines() if line.strip()] return [] def describe_exit(code): """Annotate an abnormal process exit code so a crash isn't reported as a bare number. Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run instead of returning a clean error, possibly leaving the infobase locked or half-mutated.""" if code is None: return "" win = { 3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)", 3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)", 3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)", } if code in win: return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying" if -64 <= code < 0: try: import signal name = signal.Signals(-code).name except (ValueError, AttributeError): name = f"signal {-code}" return (f" — process terminated by {name} (abnormal termination, not a normal exit); " "the infobase may be left in an inconsistent state; verify it before retrying") return "" def _redact(text, *secrets): """Redact literal secret values (password, user) from a display string — precise, never touches lookalike paths.""" for s in secrets: if s: text = text.replace(s, "***") return text def main(): sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser( description="Load Git changes into 1C database", allow_abbrev=False, ) parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory") parser.add_argument("-InfoBasePath", default="", help="Path to file infobase") parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)") parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server") parser.add_argument("-UserName", default="", help="1C user name") parser.add_argument("-Password", default="", help="1C user password") parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration (git repo)") parser.add_argument( "-Source", default="All", choices=["All", "Staged", "Unstaged", "Commit"], help="Change source (default: All)", ) parser.add_argument("-CommitRange", default="", help="Commit range (for Source=Commit), e.g. HEAD~3..HEAD") parser.add_argument("-Extension", default="", help="Extension name to load") parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions") parser.add_argument( "-Format", default="Hierarchical", choices=["Hierarchical", "Plain"], help="File format (default: Hierarchical)", ) parser.add_argument("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)") parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load") 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 (skip if DryRun) --- v8path = None if not args.DryRun: v8path = resolve_v8path(args.V8Path) # --- Detect engine + validate connection (skip if DryRun) --- engine = "1cv8" if not args.DryRun: engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" if engine == "ibcmd": if not args.InfoBasePath: print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) sys.exit(1) elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) sys.exit(1) # --- Resolve additional arguments for the selected engine --- arg_hints = { "/F": "-InfoBasePath", "/S": "-InfoBaseServer + -InfoBaseRef", "/N": "-UserName", "/P": "-Password", "--db-path": "-InfoBasePath", "--user": "-UserName", "--password": "-Password", } extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints) # --- Validate config dir --- if not os.path.exists(args.ConfigDir): print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr) sys.exit(1) # --- Validate Commit mode --- if args.Source == "Commit" and not args.CommitRange: print("Error: -CommitRange required for Source=Commit", file=sys.stderr) sys.exit(1) # --- Check git --- try: subprocess.run(["git", "--version"], capture_output=True, text=True, check=True) except (subprocess.CalledProcessError, FileNotFoundError): print("Error: git not found in PATH", file=sys.stderr) sys.exit(1) # --- Get changed files from Git --- changed_files = [] if args.Source == "Staged": print("Getting staged changes...") changed_files += run_git(args.ConfigDir, ["diff", "--cached", "--name-only", "--relative"]) elif args.Source == "Unstaged": print("Getting unstaged changes...") changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative"]) changed_files += run_git(args.ConfigDir, ["ls-files", "--others", "--exclude-standard"]) elif args.Source == "Commit": print(f"Getting changes from {args.CommitRange}...") changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative", args.CommitRange]) elif args.Source == "All": print("Getting all uncommitted changes...") changed_files += run_git(args.ConfigDir, ["diff", "--cached", "--name-only", "--relative"]) changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative"]) changed_files += run_git(args.ConfigDir, ["ls-files", "--others", "--exclude-standard"]) # Deduplicate and filter blanks changed_files = list(dict.fromkeys(f for f in changed_files if f.strip())) if len(changed_files) == 0: print("No changes found") sys.exit(0) print(f"Git changes detected: {len(changed_files)} files") # --- Filter and map to config files --- config_files = [] support_skipped = [] for file in changed_files: file = file.strip().replace("\\", "/") if not file: continue # Skip service files (not partially loadable). Support-state files are # tracked to warn: support changes apply only via a full load. if file.endswith("ParentConfigurations.bin"): support_skipped.append(file) continue if file == "ConfigDumpInfo.xml" or file.endswith("/ConfigDumpInfo.xml"): continue full_path = os.path.join(args.ConfigDir, file) if file.endswith(".xml"): # XML file — add directly if exists if os.path.exists(full_path): if file not in config_files: config_files.append(file) else: # Non-XML (BSL, HTML, etc.) — map to parent object XML + include all Ext/ files object_xml = get_object_xml_from_subfile(file) if object_xml: full_xml_path = os.path.join(args.ConfigDir, object_xml) if os.path.exists(full_xml_path): if object_xml not in config_files: config_files.append(object_xml) if os.path.exists(full_path) and file not in config_files: config_files.append(file) # Add all files from Ext/ directory of the object parts = re.split(r"[\\/]", file) if len(parts) >= 2: ext_dir = os.path.join(args.ConfigDir, parts[0], parts[1], "Ext") if os.path.isdir(ext_dir): for root, dirs, files in os.walk(ext_dir): for fname in files: abs_path = os.path.join(root, fname) rel_path = os.path.relpath(abs_path, args.ConfigDir).replace("\\", "/") if rel_path not in config_files: config_files.append(rel_path) if support_skipped: print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):", file=sys.stderr) for sf in support_skipped: print(f" - {sf}", file=sys.stderr) print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).", file=sys.stderr) if len(config_files) == 0: print("No configuration files found in changes") sys.exit(0) print(f"Files for loading: {len(config_files)}") for f in config_files: print(f" {f}") # --- DryRun: stop here --- if args.DryRun: print("") print("DryRun mode - no changes applied") sys.exit(0) # --- Temp dir --- temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_git_{random.randint(0, 999999)}") os.makedirs(temp_dir, exist_ok=True) try: if engine == "ibcmd": # --- ibcmd branch (file infobase only; import specific files) --- if args.Format == "Plain": print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) sys.exit(1) if args.AllExtensions: print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)", file=sys.stderr) sys.exit(1) arguments = ["infobase", "config", "import", "files"] + config_files arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"] if args.Extension: arguments.append(f"--extension={args.Extension}") ib_data = tempfile.mkdtemp(prefix="ibcmd_data_") atexit.register(shutil.rmtree, ib_data, ignore_errors=True) if args.UserName: arguments.append(f"--user={args.UserName}") if args.Password: arguments.append(f"--password={args.Password}") arguments.append(f"--data={ib_data}") arguments.extend(extra_args) print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") result = run_ibcmd([v8path] + arguments, bool(args.UserName)) if result.returncode != 0: print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) sys.exit(result.returncode) print(f"Changes loaded successfully ({len(config_files)} files)") exit_code = 0 if args.UpdateDB: apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"] if args.UserName: apply_args.append(f"--user={args.UserName}") if args.Password: apply_args.append(f"--password={args.Password}") apply_args.append(f"--data={ib_data}") apply_args.extend(extra_args) print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}") ar = run_ibcmd([v8path] + apply_args, bool(args.UserName)) exit_code = ar.returncode if exit_code == 0: print("Database configuration updated successfully") else: print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print_platform_output(ar) sys.exit(exit_code) # --- Write list file (UTF-8 with BOM) --- list_file = os.path.join(temp_dir, "load_list.txt") with open(list_file, "w", encoding="utf-8-sig") as f: f.write("\n".join(config_files)) # --- Build arguments --- arguments = ["DESIGNER"] if args.InfoBaseServer and args.InfoBaseRef: arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'] else: arguments += ["/F", f'"{args.InfoBasePath}"'] if args.UserName: arguments.append(f'/N"{args.UserName}"') if args.Password: arguments.append(f'/P"{args.Password}"') arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"'] arguments += ["-listFile", f'"{list_file}"'] arguments += ["-Format", args.Format] arguments.append("-partial") arguments.append("-updateConfigDumpInfo") # --- Extensions --- if args.Extension: arguments += ["-Extension", f'"{args.Extension}"'] elif args.AllExtensions: arguments.append("-AllExtensions") # --- UpdateDB --- if args.UpdateDB: arguments.append("/UpdateDBCfg") # --- Output --- out_file = os.path.join(temp_dir, "load_log.txt") arguments += ["/Out", f'"{out_file}"'] arguments.append("/DisableStartupDialogs") arguments.extend(quote_if_needed(a) for a in extra_args) # --- Execute --- print("") print("Executing partial configuration load...") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") result = run_v8(v8path, arguments) exit_code = result.returncode # --- Result --- print("") if exit_code == 0: print("Load completed successfully") else: print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) 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()