Skip to content

Commit 19090fc

Browse files
niechenclaude
andcommitted
Implement comprehensive v1 to v2 migration system
- Add V1ConfigDetector to identify v1 configurations with smart detection - Add V1ToV2Migrator with progressive disclosure migration prompts - Support three migration paths: migrate, start fresh, or ignore - Create timestamped backups in ~/.config/mcpm/backups/ with documentation - Migrate v1 profiles to v2 virtual profiles with global servers - Handle stashed servers with restore or documentation options - Remove v1 config.json completely after migration (not just null values) - Exclude auth.json from v1 detection as it's a v2 feature - Add cross-platform "press any key" functionality - Integrate migration check into all CLI commands - Add manual migration command: mcpm migrate 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 3771be0 commit 19090fc

File tree

8 files changed

+1015
-8
lines changed

8 files changed

+1015
-8
lines changed

src/mcpm/cli.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@
1919
inspect,
2020
inspector,
2121
list,
22+
migrate,
2223
profile,
2324
remove,
2425
run,
2526
search,
2627
usage,
2728
)
2829
from mcpm.commands.share import share
30+
from mcpm.migration import V1ConfigDetector, V1ToV2Migrator
2931
from mcpm.utils.logging_config import setup_logging
3032

3133
console = Console()
@@ -146,6 +148,19 @@ def main(ctx, help_flag, version):
146148
print_logo()
147149
return
148150

151+
# Check for v1 configuration and offer migration (even with subcommands)
152+
detector = V1ConfigDetector()
153+
if detector.has_v1_config():
154+
migrator = V1ToV2Migrator()
155+
migration_choice = migrator.show_migration_prompt()
156+
if migration_choice == "migrate":
157+
migrator.migrate_config()
158+
return
159+
elif migration_choice == "start_fresh":
160+
migrator.start_fresh()
161+
# Continue to execute the subcommand
162+
# If "ignore", continue to subcommand without migration
163+
149164
# v2.0 simplified model - no active target system
150165
# If no command was invoked or help is requested, show our custom help
151166
if ctx.invoked_subcommand is None or help_flag:
@@ -239,6 +254,7 @@ def main(ctx, help_flag, version):
239254
main.add_command(doctor.doctor)
240255
main.add_command(usage.usage)
241256
main.add_command(config.config)
257+
main.add_command(migrate.migrate)
242258
main.add_command(share)
243259

244260
# Legacy command aliases that still work

src/mcpm/commands/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"inspect",
1212
"inspector",
1313
"list",
14+
"migrate",
1415
"profile",
1516
"remove",
1617
"run",
@@ -29,6 +30,7 @@
2930
inspect,
3031
inspector,
3132
list,
33+
migrate,
3234
profile,
3335
run,
3436
search,

src/mcpm/commands/migrate.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Migrate command for MCPM - Manual v1 to v2 migration"""
2+
3+
import click
4+
from rich.console import Console
5+
6+
from mcpm.migration import V1ConfigDetector, V1ToV2Migrator
7+
8+
console = Console()
9+
10+
11+
@click.command()
12+
@click.option("--force", is_flag=True, help="Force migration even if v1 config not detected")
13+
@click.help_option("-h", "--help")
14+
def migrate(force):
15+
"""Migrate v1 configuration to v2.
16+
17+
This command helps you migrate from MCPM v1 to v2, converting your
18+
profiles, servers, and configuration to the new simplified format.
19+
20+
Examples:
21+
mcpm migrate # Check for v1 config and migrate if found
22+
mcpm migrate --force # Force migration check
23+
"""
24+
detector = V1ConfigDetector()
25+
26+
if not force and not detector.has_v1_config():
27+
console.print("[yellow]No v1 configuration detected.[/]")
28+
console.print("If you believe this is incorrect, use [cyan]--force[/] to run migration anyway.")
29+
console.print("\nTo learn about v2 features, run: [cyan]mcpm --help[/]")
30+
return
31+
32+
migrator = V1ToV2Migrator()
33+
34+
choice = migrator.show_migration_prompt()
35+
if choice == "migrate":
36+
success = migrator.migrate_config()
37+
if success:
38+
console.print("\n[bold green]🎉 Migration completed successfully![/]")
39+
console.print("You can now use all v2 features. Run [cyan]mcpm ls[/] to see your servers.")
40+
else:
41+
console.print("\n[red]❌ Migration failed. Check the output above for details.[/]")
42+
elif choice == "start_fresh":
43+
success = migrator.start_fresh()
44+
if success:
45+
console.print("\n[bold green]🎉 Fresh start completed successfully![/]")
46+
console.print("You can now start building your v2 configuration from scratch.")
47+
else:
48+
console.print("\n[red]❌ Fresh start failed. Check the output above for details.[/]")
49+
else:
50+
console.print("\n[yellow]Migration cancelled.[/]")
51+
console.print("You can run [cyan]mcpm migrate[/] again anytime to migrate your configuration.")

src/mcpm/migration/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""
2+
MCPM Migration System
3+
"""
4+
5+
from .v1_detector import V1ConfigDetector
6+
from .v1_migrator import V1ToV2Migrator
7+
8+
__all__ = ["V1ConfigDetector", "V1ToV2Migrator"]

src/mcpm/migration/v1_detector.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""
2+
V1 Configuration Detection and Analysis
3+
"""
4+
5+
import json
6+
import logging
7+
from pathlib import Path
8+
from typing import Dict, List, Optional
9+
10+
from mcpm.utils.config import DEFAULT_CONFIG_DIR
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class V1ConfigDetector:
16+
"""Detects and analyzes v1 configuration files"""
17+
18+
def __init__(self, config_dir: Optional[Path] = None):
19+
self.config_dir = Path(config_dir) if config_dir else Path(DEFAULT_CONFIG_DIR)
20+
self.config_file = self.config_dir / "config.json"
21+
self.profiles_file = self.config_dir / "profiles.json"
22+
23+
def has_v1_config(self) -> bool:
24+
"""Check if v1 configuration files exist with actual v1 content"""
25+
# Check for legacy profiles file (clear indicator of v1)
26+
if self.profiles_file.exists():
27+
return True
28+
29+
# Check config file for v1-specific content
30+
if self.config_file.exists():
31+
try:
32+
with open(self.config_file) as f:
33+
config = json.load(f)
34+
35+
# Look for v1-specific content (not just the keys with None values)
36+
v1_indicators = [
37+
config.get("active_client"),
38+
config.get("active_target"),
39+
config.get("stashed_servers"),
40+
config.get("router"),
41+
config.get("share")
42+
]
43+
44+
# Return True if any v1 indicator has actual content (not None/empty)
45+
return any(indicator for indicator in v1_indicators)
46+
47+
except (json.JSONDecodeError, IOError):
48+
pass
49+
50+
return False
51+
52+
def detect_v1_features(self) -> Dict[str, bool]:
53+
"""Detect which v1 features are present in the config"""
54+
features = {
55+
"active_target": False,
56+
"stashed_servers": False,
57+
"router_config": False,
58+
"share_status": False,
59+
"legacy_profiles": False,
60+
}
61+
62+
# Check main config file
63+
if self.config_file.exists():
64+
try:
65+
with open(self.config_file) as f:
66+
config = json.load(f)
67+
68+
# Check for actual v1 content, not just the presence of keys with null values
69+
features["active_target"] = any(config.get(key) for key in ["active_client", "active_target"])
70+
features["stashed_servers"] = bool(config.get("stashed_servers"))
71+
features["router_config"] = bool(config.get("router"))
72+
features["share_status"] = bool(config.get("share"))
73+
74+
except (json.JSONDecodeError, IOError) as e:
75+
logger.warning(f"Failed to read v1 config file: {e}")
76+
77+
# Check profiles file
78+
if self.profiles_file.exists():
79+
features["legacy_profiles"] = True
80+
81+
return features
82+
83+
def analyze_v1_config(self) -> Dict[str, any]:
84+
"""Analyze v1 configuration and return migration info"""
85+
analysis = {
86+
"config_found": False,
87+
"profiles_found": False,
88+
"server_count": 0,
89+
"profile_count": 0,
90+
"stashed_count": 0,
91+
"active_target": None,
92+
"router_enabled": False,
93+
"share_active": False,
94+
"profiles": {},
95+
"clients_with_stashed": [],
96+
}
97+
98+
# Analyze main config
99+
if self.config_file.exists():
100+
try:
101+
with open(self.config_file) as f:
102+
config = json.load(f)
103+
104+
analysis["config_found"] = True
105+
analysis["active_target"] = config.get("active_target")
106+
analysis["router_enabled"] = bool(config.get("router"))
107+
share_config = config.get("share", {})
108+
analysis["share_active"] = bool(share_config and share_config.get("url"))
109+
110+
# Count stashed servers
111+
stashed = config.get("stashed_servers", {}) or {}
112+
analysis["stashed_count"] = sum(len(servers) for servers in stashed.values())
113+
analysis["clients_with_stashed"] = list(stashed.keys())
114+
115+
except (json.JSONDecodeError, IOError) as e:
116+
logger.warning(f"Failed to analyze v1 config file: {e}")
117+
118+
# Analyze profiles
119+
if self.profiles_file.exists():
120+
try:
121+
with open(self.profiles_file) as f:
122+
profiles = json.load(f)
123+
124+
analysis["profiles_found"] = True
125+
analysis["profile_count"] = len(profiles)
126+
analysis["profiles"] = {name: len(servers) for name, servers in profiles.items()}
127+
analysis["server_count"] = sum(analysis["profiles"].values())
128+
129+
except (json.JSONDecodeError, IOError) as e:
130+
logger.warning(f"Failed to analyze v1 profiles file: {e}")
131+
132+
return analysis
133+
134+
def get_v1_profiles(self) -> Dict[str, List[Dict]]:
135+
"""Get v1 profiles for migration"""
136+
if not self.profiles_file.exists():
137+
return {}
138+
139+
try:
140+
with open(self.profiles_file) as f:
141+
return json.load(f)
142+
except (json.JSONDecodeError, IOError) as e:
143+
logger.error(f"Failed to read v1 profiles: {e}")
144+
return {}
145+
146+
def get_stashed_servers(self) -> Dict[str, Dict[str, Dict]]:
147+
"""Get stashed servers for migration"""
148+
if not self.config_file.exists():
149+
return {}
150+
151+
try:
152+
with open(self.config_file) as f:
153+
config = json.load(f)
154+
return config.get("stashed_servers", {})
155+
except (json.JSONDecodeError, IOError) as e:
156+
logger.error(f"Failed to read stashed servers: {e}")
157+
return {}
158+
159+
def backup_v1_configs(self) -> List[Path]:
160+
"""Create backups of v1 config files in system backup directory"""
161+
import shutil
162+
from datetime import datetime
163+
164+
# Create backup directory with timestamp
165+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
166+
backup_dir = self.config_dir / "backups" / f"v1_migration_{timestamp}"
167+
backup_dir.mkdir(parents=True, exist_ok=True)
168+
169+
backed_up = []
170+
171+
for config_file in [self.config_file, self.profiles_file]:
172+
if config_file.exists():
173+
backup_path = backup_dir / config_file.name
174+
try:
175+
shutil.copy2(config_file, backup_path)
176+
backed_up.append(backup_path)
177+
logger.info(f"Backed up {config_file} to {backup_path}")
178+
except IOError as e:
179+
logger.error(f"Failed to backup {config_file}: {e}")
180+
181+
# Create a README file explaining the backup
182+
readme_path = backup_dir / "README.md"
183+
try:
184+
with open(readme_path, "w") as f:
185+
f.write(f"""# MCPM v1 Configuration Backup
186+
187+
This directory contains your MCPM v1 configuration files that were automatically backed up during migration to v2.
188+
189+
**Backup Date:** {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
190+
191+
## Files Backed Up
192+
193+
- `config.json`: Main v1 configuration (active targets, stashed servers, router settings)
194+
- `profiles.json`: v1 profile definitions (if existed)
195+
196+
## Restoring from Backup
197+
198+
If you need to restore any v1 settings:
199+
200+
1. **Manual Review**: Open the JSON files to see your old configuration
201+
2. **Individual Settings**: Copy specific values you need back to v2 config
202+
3. **Full Restore**: Replace current config files with these backups (not recommended)
203+
204+
## v1 vs v2 Differences
205+
206+
- **Active Targets**: No longer needed - use profiles as tags instead
207+
- **Stashed Servers**: Managed directly - enable/disable servers as needed
208+
- **Router Daemon**: Replaced with direct execution and sharing features
209+
210+
## Support
211+
212+
If you need help understanding or migrating specific v1 settings:
213+
- Run `mcpm --help` to see v2 commands
214+
- Visit: https://github.com/pathintegral-institute/mcpm.sh/issues
215+
""")
216+
backed_up.append(readme_path)
217+
except IOError as e:
218+
logger.warning(f"Failed to create backup README: {e}")
219+
220+
return backed_up

0 commit comments

Comments
 (0)