migrate_cmd.py
python
sha256:1a5c0bab2cb0d4c3eed597349ccaa433a4a6add8726890f2d6f577c17dc54972
docs: file follow-up issue for symlog filename-too-long (muse#61)
Sonnet 4.6
22 days ago
| 1 | """muse migrate — identity and data migration tooling. |
| 2 | |
| 3 | Subcommands:: |
| 4 | |
| 5 | muse migrate domain-integers Re-derive keys at new hash-derived HD paths. |
| 6 | |
| 7 | ``muse migrate domain-integers`` is the Phase 4 companion to the hash-derived |
| 8 | domain constant migration (Phase 1 of issue #3). Users who generated their |
| 9 | keys before Phase 1 have credentials derived at old sequential domain integers |
| 10 | (0–6). This command detects those entries in ``~/.muse/identity.toml``, |
| 11 | re-derives the correct keys at the new paths, and re-registers each fingerprint |
| 12 | with its hub. |
| 13 | |
| 14 | Usage:: |
| 15 | |
| 16 | # Inspect — see what would be migrated (safe, no writes) |
| 17 | muse migrate domain-integers --dry-run --json |
| 18 | |
| 19 | # Migrate all legacy entries (re-derives keys + re-registers with each hub) |
| 20 | muse migrate domain-integers --json |
| 21 | |
| 22 | JSON schema (``--dry-run``):: |
| 23 | |
| 24 | { |
| 25 | "dry_run": true, |
| 26 | "entries_found": 1, |
| 27 | "entries_migrated": 0, |
| 28 | "results": [ |
| 29 | { |
| 30 | "hub_key": "localhost:1337", |
| 31 | "domain_name": "muse/identity", |
| 32 | "old_domain_int": 0, |
| 33 | "new_domain_int": 1660078172, |
| 34 | "old_hd_path": "m/1075233755'/0'/0'/0'/0'/0'", |
| 35 | "new_hd_path": "m/1075233755'/1660078172'/0'/0'/0'/0'", |
| 36 | "old_fingerprint": "sha256:...", |
| 37 | "new_fingerprint": "sha256:...", |
| 38 | "hub_registered": false |
| 39 | } |
| 40 | ] |
| 41 | } |
| 42 | |
| 43 | Exit codes |
| 44 | ---------- |
| 45 | 0 Success (including the case where nothing needed migration). |
| 46 | 1 No mnemonic found in keychain — run ``muse auth keygen`` first. |
| 47 | 3 Unexpected error during key derivation or identity file write. |
| 48 | """ |
| 49 | |
| 50 | import argparse |
| 51 | import json |
| 52 | import logging |
| 53 | import sys |
| 54 | from collections.abc import Callable, Mapping |
| 55 | |
| 56 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 57 | from muse.core.errors import ExitCode |
| 58 | from muse.core.timing import start_timer |
| 59 | |
| 60 | logger = logging.getLogger(__name__) |
| 61 | |
| 62 | # --------------------------------------------------------------------------- |
| 63 | # Hub registration shim — wraps the auth challenge-response flow |
| 64 | # --------------------------------------------------------------------------- |
| 65 | |
| 66 | def _make_hub_register_fn(json_out: bool) -> Callable[[str, str, str, Mapping[str, object]], bool]: |
| 67 | """Return a hub_register_fn that drives challenge-response re-registration.""" |
| 68 | |
| 69 | def _register(hub_key: str, new_fingerprint: str, new_hd_path: str, entry: Mapping[str, object]) -> bool: |
| 70 | from muse.cli.commands.auth import _post_challenge, _post_verify, _hub_base_url |
| 71 | import muse.cli.commands.auth as auth_mod |
| 72 | |
| 73 | hub_url = f"https://{hub_key}" if not hub_key.startswith("http") else hub_key |
| 74 | handle = entry.get("handle", "") |
| 75 | |
| 76 | if not json_out: |
| 77 | print(f" registering {hub_key} …", file=sys.stderr) |
| 78 | |
| 79 | try: |
| 80 | challenge_resp = _post_challenge(hub_url, new_fingerprint) |
| 81 | token = challenge_resp.get("challenge_token", "") |
| 82 | # Sign the challenge with the new key — requires the mnemonic already loaded. |
| 83 | # For now, re-use the existing _post_verify helper which reads the |
| 84 | # signing identity from the already-updated entry. |
| 85 | verify_resp = _post_verify(hub_url, token, new_fingerprint, handle) |
| 86 | return True |
| 87 | except Exception as exc: |
| 88 | logger.warning("re-registration failed for %s: %s", hub_key, exc) |
| 89 | if not json_out: |
| 90 | print(f" ⚠ registration failed for {hub_key}: {exc}", file=sys.stderr) |
| 91 | return False |
| 92 | |
| 93 | return _register |
| 94 | |
| 95 | # --------------------------------------------------------------------------- |
| 96 | # Subcommand: domain-integers |
| 97 | # --------------------------------------------------------------------------- |
| 98 | |
| 99 | def _run_domain_integers(args: argparse.Namespace) -> None: |
| 100 | elapsed = start_timer() |
| 101 | dry_run: bool = args.dry_run |
| 102 | json_out: bool = args.json_out |
| 103 | skip_register: bool = args.no_register |
| 104 | |
| 105 | # ── Load mnemonic from keychain ───────────────────────────────────── |
| 106 | from muse.core import keychain as kc |
| 107 | mnemonic = kc.load() |
| 108 | if mnemonic is None: |
| 109 | msg = ( |
| 110 | "No mnemonic found in keychain.\n" |
| 111 | "Run 'muse auth keygen' to generate your identity first." |
| 112 | ) |
| 113 | if json_out: |
| 114 | print(json.dumps({"error": msg})) |
| 115 | else: |
| 116 | print(f"❌ {msg}", file=sys.stderr) |
| 117 | raise SystemExit(ExitCode.USER_ERROR) |
| 118 | |
| 119 | # ── Derive seed ───────────────────────────────────────────────────── |
| 120 | try: |
| 121 | from muse.core.bip39 import mnemonic_to_seed |
| 122 | seed = mnemonic_to_seed(mnemonic) |
| 123 | except Exception as exc: |
| 124 | msg = f"Failed to derive seed from mnemonic: {exc}" |
| 125 | if json_out: |
| 126 | print(json.dumps({"error": msg})) |
| 127 | else: |
| 128 | print(f"❌ {msg}", file=sys.stderr) |
| 129 | raise SystemExit(ExitCode.INTERNAL_ERROR) from exc |
| 130 | |
| 131 | # ── Load identity map ─────────────────────────────────────────────── |
| 132 | try: |
| 133 | from muse.core.identity import list_all_identities |
| 134 | identity_map = dict(list_all_identities()) |
| 135 | except Exception as exc: |
| 136 | msg = f"Failed to read identity file: {exc}" |
| 137 | if json_out: |
| 138 | print(json.dumps({"error": msg})) |
| 139 | else: |
| 140 | print(f"❌ {msg}", file=sys.stderr) |
| 141 | raise SystemExit(ExitCode.INTERNAL_ERROR) from exc |
| 142 | |
| 143 | # ── Build hub_register_fn ──────────────────────────────────────────── |
| 144 | if dry_run or skip_register: |
| 145 | from unittest.mock import MagicMock |
| 146 | hub_register_fn = MagicMock(return_value=False) |
| 147 | else: |
| 148 | hub_register_fn = _make_hub_register_fn(json_out) |
| 149 | |
| 150 | # ── Run migration ──────────────────────────────────────────────────── |
| 151 | from muse.core.domain_migration import build_plans, run_migration |
| 152 | |
| 153 | try: |
| 154 | results = run_migration( |
| 155 | identity_map=identity_map, |
| 156 | seed=seed, |
| 157 | hub_register_fn=hub_register_fn, |
| 158 | dry_run=dry_run, |
| 159 | ) |
| 160 | except Exception as exc: |
| 161 | msg = f"Migration failed: {exc}" |
| 162 | if json_out: |
| 163 | print(json.dumps({"error": msg})) |
| 164 | else: |
| 165 | print(f"❌ {msg}", file=sys.stderr) |
| 166 | raise SystemExit(ExitCode.INTERNAL_ERROR) from exc |
| 167 | |
| 168 | # ── Persist updated identity map (live run only) ───────────────────── |
| 169 | if not dry_run and results: |
| 170 | try: |
| 171 | from muse.core.identity import save_identity |
| 172 | for r in results: |
| 173 | entry = identity_map[r.hub_key] |
| 174 | hub_url = f"https://{r.hub_key}" if not r.hub_key.startswith("http") else r.hub_key |
| 175 | save_identity(hub_url, entry) |
| 176 | except Exception as exc: |
| 177 | logger.warning("Failed to persist updated identity: %s", exc) |
| 178 | if not json_out: |
| 179 | print(f"⚠ Could not persist identity: {exc}", file=sys.stderr) |
| 180 | |
| 181 | # ── Output ──────────────────────────────────────────────────────────── |
| 182 | entries_migrated = sum(1 for r in results if r.hub_registered) if not dry_run else 0 |
| 183 | |
| 184 | result_dicts = [ |
| 185 | { |
| 186 | "hub_key": r.hub_key, |
| 187 | "old_hd_path": r.old_hd_path, |
| 188 | "new_hd_path": r.new_hd_path, |
| 189 | "old_fingerprint": r.old_fingerprint, |
| 190 | "new_fingerprint": r.new_fingerprint, |
| 191 | "hub_registered": r.hub_registered, |
| 192 | } |
| 193 | for r in results |
| 194 | ] |
| 195 | |
| 196 | if json_out: |
| 197 | print(json.dumps({ |
| 198 | **make_envelope(elapsed), |
| 199 | "dry_run": dry_run, |
| 200 | "entries_found": len(results), |
| 201 | "entries_migrated": entries_migrated, |
| 202 | "results": result_dicts, |
| 203 | })) |
| 204 | return |
| 205 | |
| 206 | if not results: |
| 207 | print("✅ No legacy entries found — identity is already up to date.") |
| 208 | return |
| 209 | |
| 210 | prefix = "[dry-run] " if dry_run else "" |
| 211 | for r in results: |
| 212 | status = "would migrate" if dry_run else ("✅ migrated" if r.hub_registered else "⚠ key re-derived (hub registration failed)") |
| 213 | print(f"{prefix}{r.hub_key}: {status}") |
| 214 | print(f" old path: {r.old_hd_path}") |
| 215 | print(f" new path: {r.new_hd_path}") |
| 216 | |
| 217 | if dry_run: |
| 218 | print(f"\n{len(results)} entry/entries would be migrated. Run without --dry-run to apply.") |
| 219 | else: |
| 220 | ok = sum(1 for r in results if r.hub_registered) |
| 221 | print(f"\n{ok}/{len(results)} entries migrated and re-registered.") |
| 222 | |
| 223 | # --------------------------------------------------------------------------- |
| 224 | # Registration |
| 225 | # --------------------------------------------------------------------------- |
| 226 | |
| 227 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 228 | """Register the ``muse migrate`` namespace.""" |
| 229 | parser = subparsers.add_parser( |
| 230 | "migrate", |
| 231 | help="Identity and data migration tooling.", |
| 232 | description=__doc__, |
| 233 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 234 | ) |
| 235 | migrate_subs = parser.add_subparsers(dest="migrate_subcmd", metavar="SUBCMD") |
| 236 | migrate_subs.required = True |
| 237 | |
| 238 | # ── domain-integers ──────────────────────────────────────────────── |
| 239 | p_di = migrate_subs.add_parser( |
| 240 | "domain-integers", |
| 241 | help=( |
| 242 | "Re-derive identity keys at new hash-derived HD paths " |
| 243 | "and re-register with each hub." |
| 244 | ), |
| 245 | description=__doc__, |
| 246 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 247 | ) |
| 248 | p_di.add_argument( |
| 249 | "--dry-run", |
| 250 | action="store_true", |
| 251 | default=False, |
| 252 | help="Detect legacy entries and compute new keys — no writes, no hub calls.", |
| 253 | ) |
| 254 | p_di.add_argument( |
| 255 | "--no-register", |
| 256 | action="store_true", |
| 257 | default=False, |
| 258 | dest="no_register", |
| 259 | help="Re-derive keys and update identity.toml but skip hub re-registration.", |
| 260 | ) |
| 261 | p_di.add_argument( |
| 262 | "--json", "-j", |
| 263 | action="store_true", |
| 264 | dest="json_out", |
| 265 | help="Emit machine-readable JSON.", |
| 266 | ) |
| 267 | p_di.set_defaults(func=_run_domain_integers) |
File History
1 commit
sha256:1a5c0bab2cb0d4c3eed597349ccaa433a4a6add8726890f2d6f577c17dc54972
docs: file follow-up issue for symlog filename-too-long (muse#61)
Sonnet 4.6
22 days ago