gabriel / muse public
migrate_cmd.py python
267 lines 10.4 KB
Raw
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