"""Post-``hermes update`` config-schema migration for the active profile and every sibling. Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.`` still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle; patches hold). """ import logging import sys from pathlib import Path from hermes_cli.update_cmd_common import _best_effort # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.update_cmd") def _reload_config_modules() -> None: """Force-reload modules from disk after git pull. ``hermes update`` runs in the PRE-pull process, so cached ``config_defaults`` / ``config`` / ``config_migrations`` hold OLD code and ``check_config_version()`` reports "up to date" despite a newer pulled version with a migration to run. Also reloads ``_subprocess_compat`` / ``dashboard_procs`` so the later dashboard cleanup sees symbols the update added instead of dying with ImportError. """ import importlib importlib.invalidate_caches() for mod_name in ( "hermes_cli.config_defaults", "hermes_cli.config", "hermes_cli.config_migrations", "hermes_cli._subprocess_compat", "hermes_cli.dashboard_procs", ): mod = sys.modules.get(mod_name) if mod is not None: try: importlib.reload(mod) except Exception as exc: logger.debug("Could not reload %s for fresh post-update code: %s", mod_name, exc) def _run_config_check_fresh() -> tuple: """Return ``(current_ver, latest_ver)`` using freshly-reloaded modules (see ``_reload_config_modules``).""" from hermes_cli.update_cmd import _reload_config_modules _reload_config_modules() from hermes_cli.config import check_config_version return check_config_version() def _run_migrate_config_fresh(*, interactive: bool = False, quiet: bool = False) -> dict: """Run config migration using freshly-reloaded modules (see ``_reload_config_modules``); returns results dict.""" from hermes_cli.update_cmd import _reload_config_modules _reload_config_modules() from hermes_cli.config import migrate_config return migrate_config(interactive=interactive, quiet=quiet) def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]: """Migrate every SIBLING profile's config.yaml (the shared checkout serves all profiles; migrating only the active one left siblings on configs the new code couldn't read). Per sibling home (active one skipped — caller already did it): scope reads/writes via the context-local HERMES_HOME override (thread-safe — never ``os.environ``) and run the NON-INTERACTIVE quiet migration; prompt-requiring settings wait for that profile's own interactive session. Returns ``[(profile_name, from_version, to_version), ...]`` for profiles actually migrated. Never raises; a failing profile is skipped (its startup migration remains the fallback). """ from hermes_cli.update_cmd import _run_config_check_fresh, _run_migrate_config_fresh migrated: list[tuple[str, int, int]] = [] with _best_effort('Sibling profile enumeration failed: %s'): from hermes_constants import ( get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override, ) from hermes_cli.profiles import _get_profiles_root, _PROFILE_ID_RE active_home = get_process_hermes_home() root = _get_profiles_root() if not root.is_dir(): return migrated for entry in sorted(root.iterdir()): if not entry.is_dir() or not _PROFILE_ID_RE.match(entry.name): continue try: if entry.resolve() == Path(active_home).resolve(): continue except OSError: continue if not (entry / "config.yaml").is_file(): continue # profile never configured — nothing to migrate token = set_hermes_home_override(entry) try: current_ver, latest_ver = _run_config_check_fresh() if current_ver >= latest_ver: continue _run_migrate_config_fresh(interactive=False, quiet=True) after_ver, _ = _run_config_check_fresh() if after_ver > current_ver: migrated.append((entry.name, current_ver, after_ver)) except Exception as exc: logger.debug("Config migration for profile %s failed: %s", entry.name, exc) finally: reset_hermes_home_override(token) return migrated def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None: """Post-migration safety nets: restore cron jobs / protected model settings lost during the update, for the active profile (from *pre_update_snapshot_id*) and every sibling profile (own snapshot).""" # Safety net: migrations/desktop scheduler have emptied or truncated cron/jobs.json; # restore from the pre-update snapshot if jobs went missing. try: from hermes_cli.backup import restore_cron_jobs_if_emptied cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id) if cron_restore: print() print( " ⚠️ cron/jobs.json lost jobs during this update — " f"restored {cron_restore['job_count']} job(s) from " f"pre-update snapshot {cron_restore['snapshot_id']}." ) except Exception as exc: # Never let the cron safety net break an otherwise-good update. logger.debug("Cron jobs auto-restore check failed: %s", exc) # Desktop update/repair cycles have rewritten model.provider/model.default and dropped # moa:; restore only those protected keys from the same pre-update snapshot. try: from hermes_cli.backup import restore_config_model_settings_if_rewritten cfg_restore = restore_config_model_settings_if_rewritten(pre_update_snapshot_id) if cfg_restore: print() print( " ⚠️ config.yaml user model settings were rewritten during " f"this update — restored {', '.join(cfg_restore['keys'])} " f"from pre-update snapshot {cfg_restore['snapshot_id']}." ) except Exception as exc: # Never let the config safety net break an otherwise-good update. logger.debug("Config model-settings auto-restore check failed: %s", exc) # Same cron-jobs safety net per sibling profile against ITS OWN pre-update snapshot. with _best_effort('Sibling cron auto-restore check failed: %s'): from hermes_cli.backup import restore_cron_jobs_all_profiles for _restored in restore_cron_jobs_all_profiles( _LAST_SIBLING_SNAPSHOTS ): print() print( f" ⚠️ Profile '{_restored['profile']}': cron/jobs.json " f"lost jobs during this update — restored " f"{_restored['job_count']} job(s) from pre-update " f"snapshot {_restored['snapshot_id']}." ) # Same config model-settings safety net for sibling profiles. with _best_effort('Sibling config auto-restore check failed: %s'): from hermes_cli.backup import restore_config_model_settings_all_profiles for _cfg_restored in restore_config_model_settings_all_profiles( _LAST_SIBLING_SNAPSHOTS ): print() print( f" ⚠️ Profile '{_cfg_restored['profile']}': config.yaml " f"user model settings were rewritten during this update — " f"restored {', '.join(_cfg_restored['keys'])} from " f"pre-update snapshot {_cfg_restored['snapshot_id']}." ) def _check_and_apply_config_migration( *, assume_yes: bool = False, gateway_mode: bool = False, pre_update_snapshot_id: str | None = None, ) -> None: """Check/apply config migrations on an update completion path. Must use freshly-reloaded modules (see ``_reload_config_modules``) and run on EVERY completion path (post-pull, venv-repair retry, Node-deps repair on ``commit_count == 0``) so an interrupted update that already pulled code doesn't strand an old config version. """ from hermes_cli.update_cmd import ( _gateway_prompt, _migrate_sibling_profile_configs, _reload_config_modules, _run_config_check_fresh, _run_migrate_config_fresh, ) print() print("→ Checking configuration for new options...") # Reload BEFORE any config reads so all checks use the updated code. _reload_config_modules() from hermes_cli.config import ( get_missing_env_vars, get_missing_config_fields, ) # A config-check failure must not break an otherwise-successful update. try: missing_env = get_missing_env_vars(required_only=True) missing_config = get_missing_config_fields() current_ver, latest_ver = _run_config_check_fresh() except Exception as exc: logger.debug("Config check during update failed: %s", exc) print(" ⚠️ Could not check config version.") print(" Run 'hermes config migrate' to check manually.") return has_new_options = bool(missing_env or missing_config) version_bump_only = not has_new_options and current_ver < latest_ver needs_migration = has_new_options or current_ver < latest_ver if version_bump_only: # Only the format version changed (defaults merge transparently); prompting # would look like a no-op on yes — apply silently and say what happened. print() print(f" ℹ Updating config format (v{current_ver} → v{latest_ver})…") try: _mig_results = _run_migrate_config_fresh(interactive=False, quiet=True) print(" ✓ Config format updated (no new settings to configure)") # quiet=True also mutes steps that RESET/REMOVE a setting; re-surface them so an # unattended update never silently changes config (config_added holds only mutations here). for _note in _mig_results.get("config_added") or []: print(f" ℹ {_note}") for _warn in _mig_results.get("warnings") or []: print(f" ⚠️ {_warn}") except Exception as _mig_err: print(f" ⚠️ Config format update failed: {_mig_err}") print(" Run 'hermes config migrate' to retry.") elif needs_migration: print() # Show WHAT changed, not just a count, for an informed yes/no. if missing_env: print(f" ⚠️ {len(missing_env)} new required setting(s) need configuration") _print_items(missing_env, "New settings", "name") if missing_config: print(f" ℹ️ {len(missing_config)} new config option(s) available") _print_items(missing_config, "New options", "key") print() if assume_yes: print(" ℹ --yes: auto-applying config migration (skipping API-key prompts).") response = "y" elif gateway_mode: response = ( _gateway_prompt( "Would you like to configure new options now? [Y/n]", "n" ) .strip() .lower() ) elif not (sys.stdin.isatty() and sys.stdout.isatty()): print(" ℹ Non-interactive session — applying safe config migrations.") response = "auto" else: try: response = ( input("Would you like to configure them now? [Y/n]: ") .strip() .lower() ) except EOFError: response = "n" except UnicodeDecodeError: # Non-UTF-8 locales / embedded terminals can make input() raise this. print( " ⚠ Could not read input (encoding issue). Skipping. " "Run 'hermes config migrate' manually to configure." ) response = "n" if response in {"", "y", "yes", "auto"}: print() # Gateway/--yes/non-interactive can't prompt for API keys; still run the # non-interactive pass so defaults and version bumps land before the gateway restarts. interactive_migration = not (gateway_mode or assume_yes or response == "auto") results = _run_migrate_config_fresh(interactive=interactive_migration, quiet=False) if results["env_added"] or results["config_added"]: print() print("✓ Configuration updated!") if (gateway_mode or assume_yes or response == "auto") and missing_env: print(" ℹ API keys require manual entry: hermes config migrate") else: print() print("Skipped. Run 'hermes config migrate' later to configure.") else: print(" ✓ Configuration is up to date") # The migration above touched only the active profile; run the same NON-INTERACTIVE # migration per sibling home via the context-local HERMES_HOME override (never os.environ). with _best_effort('Sibling config migration failed: %s'): _migrated_siblings = _migrate_sibling_profile_configs() for _name, _from_ver, _to_ver in _migrated_siblings: print(f" ✓ Profile '{_name}': config format updated " f"(v{_from_ver} → v{_to_ver})") _restore_snapshot_safety_nets(pre_update_snapshot_id) # {profile: snapshot_id} from this run's pre-update backup, consumed by the per-profile # safety nets. Module-level because snapshot and restore run far apart in _cmd_update_impl. _LAST_SIBLING_SNAPSHOTS: dict = {} def _print_items(items, label, key, fallback_key=None): if not items: return print(f" {label}:") shown = items[:8] for it in shown: if isinstance(it, dict): name = it.get(key) or (fallback_key and it.get(fallback_key)) or "?" desc = (it.get("description") or "").strip() else: # Defensive: some callers/mocks pass bare name strings. name = str(it) desc = "" if desc: print(f" • {name} — {desc}") else: print(f" • {name}") extra = len(items) - len(shown) if extra > 0: print(f" … and {extra} more")