#!/usr/bin/env python3 """Переключение живой установки хаба jira-hub -> sdlc-hub (одноразовый скрипт, stdlib, Python >= 3.9). Без --apply только печатает проверки и шаги, ничего не меняя. С --apply выполняет шаги 2-7 спеки sdlc-rename по порядку и останавливается на первой ошибке. """ from __future__ import annotations import argparse import json import os import shutil import socket import subprocess import sys import time from dataclasses import dataclass, field from pathlib import Path from typing import Callable, List, Tuple OLD_LABEL = "ru.aepif.jira-hub" NEW_LABEL = "ru.aepif.sdlc-hub" SUFFIX = ".pre-sdlc" def _home() -> Path: return Path(os.environ.get("HOME") or os.path.expanduser("~")) @dataclass class Config: bridge: Path data_root: Path launch_agents: Path logs: Path remote: str = "https://git.aepif.ru/aep/sdlc-hub.git" projects: List[Path] = field(default_factory=list) port: int = 8790 @classmethod def from_env(cls) -> "Config": e = os.environ h = _home() projects = e.get("CUTOVER_PROJECTS") return cls( bridge=Path(e.get("CUTOVER_BRIDGE") or h / "projects" / "jira-bridge"), data_root=Path(e.get("CUTOVER_DATA_ROOT") or h / ".local" / "share"), launch_agents=Path(e.get("CUTOVER_LAUNCH_AGENTS") or h / "Library" / "LaunchAgents"), logs=Path(e.get("CUTOVER_LOGS") or h / "Library" / "Logs"), remote=e.get("CUTOVER_REMOTE") or "https://git.aepif.ru/aep/sdlc-hub.git", projects=[Path(p) for p in projects.split(":") if p] if projects is not None else [h / "projects" / "awp-ca", h / "projects" / "stocks_drrk"], port=int(e.get("CUTOVER_PORT") or 8790), ) @property def old_checkout(self) -> Path: return self.bridge / "jira-hub" @property def new_checkout(self) -> Path: return self.bridge / "sdlc-hub" def _git_env() -> dict: return dict(os.environ, GIT_OPTIONAL_LOCKS="0", GIT_TERMINAL_PROMPT="0") def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, env=_git_env(), stdin=subprocess.DEVNULL) def preflight(cfg: Config) -> List[str]: """Проверки шага 1. Только чтение.""" problems: List[str] = [] for tool in ("uv", "make", "claude", "launchctl", "node"): if not shutil.which(tool): problems.append("не найден инструмент %s в PATH (нужен для шагов переключения)" % tool) old = cfg.old_checkout if not old.is_dir(): problems.append("старый checkout %s не найден" % old) else: st = _git(old, "status", "--porcelain") if st.returncode != 0: problems.append("git status в %s не выполнился: %s" % (old, st.stderr.strip())) elif st.stdout.strip(): problems.append("checkout %s не чист (есть незакоммиченные изменения)" % old) br = _git(old, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() if br != "main": problems.append("checkout %s не на ветке main (сейчас: %s)" % (old, br or "?")) head = _git(old, "rev-parse", "HEAD").stdout.strip() # ls-remote вместо fetch: проверка остаётся строго чтением (fetch пишет FETCH_HEAD) lr = _git(old, "ls-remote", "origin", "refs/heads/main") remote_head = lr.stdout.split()[0] if lr.returncode == 0 and lr.stdout.split() else "" if not remote_head: problems.append("не удалось получить origin/main из %s" % old) elif remote_head != head: problems.append("HEAD не совпадает с origin/main: сделайте git pull --ff-only в %s" % old) lr = subprocess.run(["git", "ls-remote", cfg.remote], capture_output=True, text=True, env=_git_env(), stdin=subprocess.DEVNULL) if lr.returncode != 0: problems.append("репозиторий %s недоступен: переименуйте aep/jira-hub -> aep/sdlc-hub в Gitea (%s)" % (cfg.remote, lr.stderr.strip().splitlines()[-1] if lr.stderr.strip() else "нет ответа")) for p in (cfg.data_root / "sdlc-hub", cfg.new_checkout): if p.exists(): problems.append("каталог %s уже существует" % p) if not (cfg.data_root / "jira-hub").exists(): problems.append("каталог данных %s не найден" % (cfg.data_root / "jira-hub")) return problems def rewrite_mcp_json(path: Path, checkout: Path) -> bool: data = json.loads(path.read_text(encoding="utf-8")) servers = data.get("mcpServers") if not isinstance(servers, dict): return False names = {"jira-hub": ("sdlc-jira", "jira"), "jira-hub-wiki": ("sdlc-wiki", "wiki")} changed = False new: dict = {} for key, val in servers.items(): if key not in names: new[key] = val continue new_key, module = names[key] args = list(val.get("args", [])) tail: List[str] = [] for marker in ("mcp", "jira-hub"): if marker in args: tail = args[args.index(marker) + 1:] break if tail and tail[0] in ("jira", "wiki"): tail = tail[1:] val = dict(val) val["args"] = ["run", "--project", str(checkout), "sdlc-hub", "mcp", module] + tail new[new_key] = val changed = True if changed: data["mcpServers"] = new path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return changed _REPLACEMENTS = [ ("mcp__jira-hub-wiki__", "mcp__sdlc-wiki__"), ("mcp__jira-hub__", "mcp__sdlc-jira__"), ("aep/jira-hub", "aep/sdlc-hub"), ('"jira-hub-wiki"', '"sdlc-wiki"'), ('"jira-hub"', '"sdlc-jira"'), ("MCP `jira-hub`", "MCP `sdlc-jira`"), ("MCP jira-hub", "MCP sdlc-jira"), ] def rewrite_text(path: Path) -> List[str]: """Замены имён серверов и инструментов; возвращает строки с остатками `jira-hub`.""" try: text = path.read_text(encoding="utf-8") except (UnicodeDecodeError, OSError): return [] out = text for a, b in _REPLACEMENTS: out = out.replace(a, b) if out != text: path.write_text(out, encoding="utf-8") return [ln.strip() for ln in out.splitlines() if "jira-hub" in ln] def backup(path: Path) -> Path: dst = path.with_name(path.name + SUFFIX) shutil.copy2(str(path), str(dst)) return dst def plist_text(checkout: Path, log: Path, uv: str) -> str: args = "".join("%s" % a for a in [uv, "run", "--project", str(checkout), "sdlc-hub", "serve"]) return ( '\n' '\n' '\n' " Label%s\n" " ProgramArguments%s\n" " EnvironmentVariables\n" " PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\n" " PYTHONUNBUFFERED1\n" " \n" " RunAtLoad\n" " KeepAlive\n" " StandardOutPath%s\n" " StandardErrorPath%s\n" "\n" % (NEW_LABEL, args, log, log) ) def _run(*cmd: str, cwd: Path = None, check: bool = True) -> subprocess.CompletedProcess: return subprocess.run(list(cmd), cwd=str(cwd) if cwd else None, check=check) def _port_open(port: int) -> bool: try: socket.create_connection(("127.0.0.1", port), timeout=1).close() return True except OSError: return False def _domain() -> str: return "gui/%d" % os.getuid() def _project_files(project: Path) -> List[Path]: files: List[Path] = [] claude = project / ".claude" files += sorted(claude.glob("settings*.json")) if claude.is_dir() else [] if (project / "CLAUDE.md").is_file(): files.append(project / "CLAUDE.md") skills = claude / "skills" if skills.is_dir(): files += [p for p in sorted(skills.rglob("*")) if p.is_file() and not p.name.endswith(SUFFIX)] return files def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None) -> List[Tuple[str, Callable[[], None]]]: leftovers = leftovers if leftovers is not None else [] backups = backups if backups is not None else [] new_co = cfg.new_checkout old_plist = cfg.launch_agents / (OLD_LABEL + ".plist") new_plist = cfg.launch_agents / (NEW_LABEL + ".plist") log = cfg.logs / "sdlc-hub.log" uv = shutil.which("uv") or "/opt/homebrew/bin/uv" def stop() -> None: _run("launchctl", "bootout", "%s/%s" % (_domain(), OLD_LABEL), check=False) # не загружен - не ошибка if old_plist.exists(): old_plist.unlink() if _port_open(cfg.port): raise RuntimeError("после остановки %s порт %d всё ещё занят: хаб не остановлен" % (OLD_LABEL, cfg.port)) def move_data() -> None: shutil.move(str(cfg.data_root / "jira-hub"), str(cfg.data_root / "sdlc-hub")) def move_checkout() -> None: shutil.move(str(cfg.old_checkout), str(new_co)) wts = sorted(p for p in (new_co / ".wt").glob("*") if p.is_dir()) if (new_co / ".wt").is_dir() else [] _run("git", "-C", str(new_co), "worktree", "repair", *[str(p) for p in wts]) _run("git", "-C", str(new_co), "remote", "set-url", "origin", cfg.remote) shutil.rmtree(str(new_co / ".venv"), ignore_errors=True) _run("uv", "sync", cwd=new_co) _run("make", "web", cwd=new_co) def start() -> None: cfg.launch_agents.mkdir(parents=True, exist_ok=True) cfg.logs.mkdir(parents=True, exist_ok=True) new_plist.write_text(plist_text(new_co, log, uv), encoding="utf-8") _run("launchctl", "bootstrap", _domain(), str(new_plist)) deadline = time.time() + 30 while time.time() < deadline: if _port_open(cfg.port): return time.sleep(1) raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s); остановить: launchctl bootout %s/%s" % (cfg.port, log, _domain(), NEW_LABEL)) def consumers() -> None: for project in cfg.projects: mcp = project / ".mcp.json" if mcp.is_file(): text = mcp.read_text(encoding="utf-8") if "jira-hub" in text: backups.append(backup(mcp)) rewrite_mcp_json(mcp, new_co) leftovers.extend("%s: %s" % (mcp, ln.strip()) for ln in mcp.read_text(encoding="utf-8").splitlines() if "jira-hub" in ln) for f in _project_files(project): try: if "jira-hub" not in f.read_text(encoding="utf-8"): continue except (UnicodeDecodeError, OSError): continue backups.append(backup(f)) leftovers.extend("%s: %s" % (f, ln) for ln in rewrite_text(f)) def user_scope() -> None: _run("claude", "mcp", "add", "-s", "user", "sdlc-jira", "--", "uv", "run", "--project", str(new_co), "sdlc-hub", "mcp", "jira") r = _run("claude", "mcp", "remove", "-s", "user", "jira-hub", check=False) if r.returncode != 0: print(" ПРЕДУПРЕЖДЕНИЕ: claude mcp remove -s user jira-hub не выполнился (код %d): удалите запись руками" % r.returncode) return [ ("2. остановить launchd %s и удалить %s" % (OLD_LABEL, old_plist), stop), ("3. перенести данные %s -> %s" % (cfg.data_root / "jira-hub", cfg.data_root / "sdlc-hub"), move_data), ("4. перенести checkout %s -> %s, worktree repair, origin -> %s, uv sync, make web" % (cfg.old_checkout, new_co, cfg.remote), move_checkout), ("5. поставить и запустить %s (лог %s), дождаться порта %d" % (NEW_LABEL, log, cfg.port), start), ("6. править проекты %s (.mcp.json, .claude/settings*.json, CLAUDE.md, .claude/skills; копии *%s)" % (", ".join(str(p) for p in cfg.projects), SUFFIX), consumers), ("7. user scope Claude: add sdlc-jira, затем remove jira-hub", user_scope), ] def main(argv: List[str]) -> int: ap = argparse.ArgumentParser(description="Переключение живого хаба jira-hub -> sdlc-hub") ap.add_argument("--apply", action="store_true", help="выполнить (по умолчанию сухой прогон)") args = ap.parse_args(argv) cfg = Config.from_env() problems = preflight(cfg) print("1. проверки:") if problems: for p in problems: print(" ПРОБЛЕМА: " + p) else: print(" ок") leftovers: List[str] = [] backups: List[Path] = [] plan = steps(cfg, leftovers, backups) if not args.apply: print("Сухой прогон, ничего не изменено. Шаги:") for desc, _ in plan: print(" " + desc) return 1 if problems else 0 if problems: print("Остановлено: исправьте проблемы шага 1, ничего не изменено.") return 1 done: List[str] = [] for desc, fn in plan: print("-> " + desc) try: fn() except Exception as e: # noqa: BLE001 print("остановлено на шаге %s: %s" % (desc.split(".")[0], e)) print("Выполнено: " + ("; ".join(done) if done else "ничего")) print("Промежуточное состояние (по выполненным шагам; упавший шаг мог выполниться частично, проверьте):") for line in _state_lines(done): print(" " + line) print("Откат: остановить ru.aepif.sdlc-hub (launchctl bootout %s/%s), перенести каталоги обратно " "(данные %s -> %s, checkout %s -> %s, origin вернуть на aep/jira-hub), вернуть plist %s и " "launchctl bootstrap %s; раздел «Переключение с jira-hub» в README." % ( _domain(), NEW_LABEL, cfg.data_root / "sdlc-hub", cfg.data_root / "jira-hub", cfg.new_checkout, cfg.old_checkout, cfg.launch_agents / (OLD_LABEL + ".plist"), _domain())) _print_tail(backups, leftovers) return 1 done.append(desc) print(" готово") print("\nОсталось владельцу:") print(" - /mcp reconnect в открытых сессиях Claude") print(" - codegraph init в %s и в каждом worktree (.wt/*)" % cfg.new_checkout) _print_tail(backups, leftovers) return 0 def _state_lines(done: List[str]) -> List[str]: n = {d.split(".")[0] for d in done} out = [] if "2" in n: out.append("хаб ru.aepif.jira-hub остановлен, его plist удалён") if "3" in n: out.append("каталог данных перенесён в sdlc-hub") if "4" in n: out.append("checkout перенесён в sdlc-hub, origin переключён") if "5" in n: out.append("ru.aepif.sdlc-hub установлен и запущен") if "6" in n: out.append("файлы проектов изменены (копии *%s)" % SUFFIX) return out or ["ничего не изменено"] def _print_tail(backups: List[Path], leftovers: List[str]) -> None: print(" - проверить правки и удалить копии *%s:" % SUFFIX) for b in backups: print(" " + str(b)) if leftovers: print(" - проверить руками (остались упоминания jira-hub):") for ln in leftovers: print(" " + ln) if __name__ == "__main__": sys.exit(main(sys.argv[1:]))