Coverage for src / moai_adk / core / project / phase_executor.py: 15.59%
263 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1# type: ignore
2"""Phase-based installation executor (SPEC-INIT-003 v0.4.2)
4Runs the project initialization across five phases:
5- Phase 1: Preparation (create single backup at .moai-backups/backup/)
6- Phase 2: Directory (build directory structure)
7- Phase 3: Resource (copy templates while preserving user content)
8- Phase 4: Configuration (generate configuration files)
9- Phase 5: Validation (verify and finalize)
11Test coverage includes 5-phase integration tests with backup, configuration, and validation
12"""
14import json
15import logging
16import platform
17import shutil
18import subprocess
19from collections.abc import Callable
20from datetime import datetime
21from pathlib import Path
22from typing import Any
24from rich.console import Console
26from moai_adk import __version__
27from moai_adk.core.project.backup_utils import (
28 get_backup_targets,
29 has_any_moai_files,
30 is_protected_path,
31)
32from moai_adk.core.project.validator import ProjectValidator
33from moai_adk.core.template.processor import TemplateProcessor
34from moai_adk.statusline.version_reader import VersionConfig, VersionReader
36console = Console()
38# Progress callback type alias
39ProgressCallback = Callable[[str, int, int], None]
42class PhaseExecutor:
43 """Execute the installation across the five phases.
45 Phases:
46 1. Preparation: Back up and verify the system.
47 2. Directory: Create the directory structure.
48 3. Resource: Copy template resources.
49 4. Configuration: Generate configuration files.
50 5. Validation: Perform final checks.
52 Enhanced with improved version reading and context management.
53 """
55 # Required directory structure
56 REQUIRED_DIRECTORIES = [
57 ".moai/",
58 ".moai/project/",
59 ".moai/specs/",
60 ".moai/reports/",
61 ".moai/memory/",
62 ".claude/",
63 ".claude/logs/",
64 ".github/",
65 ]
67 def __init__(self, validator: ProjectValidator) -> None:
68 """Initialize the executor.
70 Args:
71 validator: Project validation helper.
72 """
73 self.validator = validator
74 self.total_phases = 5
75 self.current_phase = 0
76 self._version_reader: VersionReader | None = None
78 def _get_version_reader(self) -> VersionReader:
79 """
80 Get or create version reader instance.
82 Returns:
83 VersionReader instance with enhanced configuration
84 """
85 if self._version_reader is None:
86 config = VersionConfig(
87 cache_ttl_seconds=120, # Longer cache for phase execution
88 fallback_version=__version__,
89 version_format_regex=r"^v?(\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?)$",
90 cache_enabled=True,
91 debug_mode=False,
92 )
93 self._version_reader = VersionReader(config)
94 return self._version_reader
96 def _get_enhanced_version_context(self) -> dict[str, str]:
97 """
98 Get enhanced version context with fallback strategies and comprehensive configuration.
100 Returns:
101 Dictionary containing version-related template variables with enhanced formatting
102 """
103 version_context = {}
104 logger = logging.getLogger(__name__)
106 try:
107 version_reader = self._get_version_reader()
108 moai_version = version_reader.get_version()
110 # Enhanced version context with multiple format options
111 version_context["MOAI_VERSION"] = moai_version
112 version_context["MOAI_VERSION_SHORT"] = self._format_short_version(
113 moai_version
114 )
115 version_context["MOAI_VERSION_DISPLAY"] = self._format_display_version(
116 moai_version
117 )
118 version_context["MOAI_VERSION_TRIMMED"] = self._format_trimmed_version(
119 moai_version, max_length=10
120 )
121 version_context["MOAI_VERSION_SEMVER"] = self._format_semver_version(
122 moai_version
123 )
124 version_context["MOAI_VERSION_VALID"] = (
125 "true" if moai_version != "unknown" else "false"
126 )
127 version_context["MOAI_VERSION_SOURCE"] = self._get_version_source(
128 version_reader
129 )
131 # Add performance metrics for debugging
132 cache_age = version_reader.get_cache_age_seconds()
133 if cache_age is not None:
134 version_context["MOAI_VERSION_CACHE_AGE"] = f"{cache_age:.2f}s"
135 else:
136 version_context["MOAI_VERSION_CACHE_AGE"] = "uncached"
138 except Exception as e:
139 logger.warning(f"Failed to read version for context: {e}")
140 # Use fallback version with comprehensive fallback formatting
141 fallback_version = __version__
142 version_context["MOAI_VERSION"] = fallback_version
143 version_context["MOAI_VERSION_SHORT"] = self._format_short_version(
144 fallback_version
145 )
146 version_context["MOAI_VERSION_DISPLAY"] = self._format_display_version(
147 fallback_version
148 )
149 version_context["MOAI_VERSION_TRIMMED"] = self._format_trimmed_version(
150 fallback_version, max_length=10
151 )
152 version_context["MOAI_VERSION_SEMVER"] = self._format_semver_version(
153 fallback_version
154 )
155 version_context["MOAI_VERSION_VALID"] = "true"
156 version_context["MOAI_VERSION_SOURCE"] = "fallback_package"
157 version_context["MOAI_VERSION_CACHE_AGE"] = "unavailable"
159 return version_context
161 def _format_short_version(self, version: str) -> str:
162 """
163 Format short version by removing 'v' prefix if present.
165 Args:
166 version: Version string
168 Returns:
169 Short version string
170 """
171 return version[1:] if version.startswith("v") else version
173 def _format_display_version(self, version: str) -> str:
174 """
175 Format display version with proper formatting.
177 Args:
178 version: Version string
180 Returns:
181 Display version string
182 """
183 if version == "unknown":
184 return "MoAI-ADK unknown version"
185 elif version.startswith("v"):
186 return f"MoAI-ADK {version}"
187 else:
188 return f"MoAI-ADK v{version}"
190 def _format_trimmed_version(self, version: str, max_length: int = 10) -> str:
191 """
192 Format version with maximum length, suitable for UI displays.
194 Args:
195 version: Version string
196 max_length: Maximum allowed length for the version string
198 Returns:
199 Trimmed version string
200 """
201 if version == "unknown":
202 return "unknown"
204 # Remove 'v' prefix for trimming
205 clean_version = version[1:] if version.startswith("v") else version
207 # Trim if necessary
208 if len(clean_version) > max_length:
209 return clean_version[:max_length]
210 return clean_version
212 def _format_semver_version(self, version: str) -> str:
213 """
214 Format version as semantic version with major.minor.patch structure.
216 Args:
217 version: Version string
219 Returns:
220 Semantic version string
221 """
222 if version == "unknown":
223 return "0.0.0"
225 # Remove 'v' prefix and extract semantic version
226 clean_version = version[1:] if version.startswith("v") else version
228 # Extract core semantic version (remove pre-release and build metadata)
229 import re
231 semver_match = re.match(r"^(\d+\.\d+\.\d+)", clean_version)
232 if semver_match:
233 return semver_match.group(1)
234 return "0.0.0"
236 def _get_version_source(self, version_reader: VersionReader) -> str:
237 """
238 Determine the source of the version information.
240 Args:
241 version_reader: VersionReader instance
243 Returns:
244 String indicating version source
245 """
246 config = version_reader.get_config()
248 # Check if we have a cached version (most likely from config)
249 cache_age = version_reader.get_cache_age_seconds()
250 if cache_age is not None and cache_age < config.cache_ttl_seconds:
251 return "config_cached"
252 elif cache_age is not None:
253 return "config_stale"
254 else:
255 return config.fallback_version
257 def execute_preparation_phase(
258 self,
259 project_path: Path,
260 backup_enabled: bool = True,
261 progress_callback: ProgressCallback | None = None,
262 ) -> None:
263 """Phase 1: preparation and backup.
265 Args:
266 project_path: Project path.
267 backup_enabled: Whether backups are enabled.
268 progress_callback: Optional progress callback.
269 """
270 self.current_phase = 1
271 self._report_progress("Phase 1: Preparation and backup...", progress_callback)
273 # Validate system requirements
274 self.validator.validate_system_requirements()
276 # Verify the project path
277 self.validator.validate_project_path(project_path)
279 # Create a backup when needed
280 if backup_enabled and has_any_moai_files(project_path):
281 self._create_backup(project_path)
283 def execute_directory_phase(
284 self,
285 project_path: Path,
286 progress_callback: ProgressCallback | None = None,
287 ) -> None:
288 """Phase 2: create directories.
290 Args:
291 project_path: Project path.
292 progress_callback: Optional progress callback.
293 """
294 self.current_phase = 2
295 self._report_progress(
296 "Phase 2: Creating directory structure...", progress_callback
297 )
299 for directory in self.REQUIRED_DIRECTORIES:
300 dir_path = project_path / directory
301 dir_path.mkdir(parents=True, exist_ok=True)
303 def execute_resource_phase(
304 self,
305 project_path: Path,
306 config: dict[str, str] | None = None,
307 progress_callback: ProgressCallback | None = None,
308 ) -> list[str]:
309 """Phase 3: install resources with variable substitution.
311 Args:
312 project_path: Project path.
313 config: Configuration dictionary for template variable substitution.
314 progress_callback: Optional progress callback.
316 Returns:
317 List of created files or directories.
318 """
319 import stat
321 self.current_phase = 3
322 self._report_progress("Phase 3: Installing resources...", progress_callback)
324 # Copy resources via TemplateProcessor in silent mode
325 processor = TemplateProcessor(project_path)
327 # Set template variable context (if provided)
328 if config:
329 language_config: dict[str, Any] = config.get("language", {})
330 if not isinstance(language_config, dict):
331 language_config = {}
333 # Detect OS for cross-platform Hook path configuration
334 hook_project_dir = (
335 "%CLAUDE_PROJECT_DIR%"
336 if platform.system() == "Windows"
337 else "$CLAUDE_PROJECT_DIR"
338 )
340 # Get enhanced version context with fallback strategies
341 version_context = self._get_enhanced_version_context()
343 context = {
344 **version_context,
345 "CREATION_TIMESTAMP": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
346 "PROJECT_NAME": config.get("name", "unknown"),
347 "PROJECT_DESCRIPTION": config.get("description", ""),
348 "PROJECT_MODE": config.get("mode", "personal"),
349 "PROJECT_VERSION": config.get("version", "0.1.0"),
350 "PROJECT_OWNER": config.get("author", "@user"),
351 "AUTHOR": config.get("author", "@user"),
352 "CONVERSATION_LANGUAGE": language_config.get(
353 "conversation_language", "en"
354 ),
355 "CONVERSATION_LANGUAGE_NAME": language_config.get(
356 "conversation_language_name", "English"
357 ),
358 "CODEBASE_LANGUAGE": config.get("language", "generic"),
359 "PROJECT_DIR": hook_project_dir,
360 }
361 processor.set_context(context)
363 processor.copy_templates(
364 backup=False, silent=True
365 ) # Avoid progress bar conflicts
367 # Post-process: Set executable permission on shell scripts
368 # This is necessary because git may not preserve file permissions during clone/checkout
369 scripts_dir = project_path / ".moai" / "scripts"
370 logger = logging.getLogger(__name__)
371 if scripts_dir.exists():
372 logger.debug(f"Processing shell scripts in {scripts_dir}")
373 for script_file in scripts_dir.glob("*.sh"):
374 try:
375 # Add execute permission for user, group, and others
376 current_mode = script_file.stat().st_mode
377 new_mode = current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
378 script_file.chmod(new_mode)
379 logger.debug(f"Set executable permission on {script_file}: {oct(current_mode)} -> {oct(new_mode)}")
380 except Exception as e:
381 logger.warning(f"Failed to set executable permission on {script_file}: {e}")
382 else:
383 logger.debug(f"Scripts directory not found: {scripts_dir}")
385 # Return a simplified list of generated assets
386 return [
387 ".claude/",
388 ".moai/",
389 ".github/",
390 "CLAUDE.md",
391 ".gitignore",
392 ]
394 def execute_configuration_phase(
395 self,
396 project_path: Path,
397 config: dict[str, str | bool | dict[Any, Any]],
398 progress_callback: ProgressCallback | None = None,
399 ) -> list[str]:
400 """Phase 4: generate configuration.
402 Args:
403 project_path: Project path.
404 config: Configuration dictionary.
405 progress_callback: Optional progress callback.
407 Returns:
408 List of created files.
409 """
410 self.current_phase = 4
411 self._report_progress(
412 "Phase 4: Generating configurations...", progress_callback
413 )
415 logger = logging.getLogger(__name__)
417 # Read existing config to preserve user settings (Issue #165)
418 config_path = project_path / ".moai" / "config" / "config.json"
419 existing_config: dict[str, Any] = {}
420 if config_path.exists():
421 try:
422 with open(config_path, "r", encoding="utf-8") as f:
423 existing_config = json.load(f)
424 logger.debug(f"Successfully read existing config from {config_path}")
425 except (json.JSONDecodeError, OSError) as e:
426 logger.warning(f"Failed to read existing config: {e}. Starting fresh.")
427 existing_config = {}
429 # Enhanced config merging with comprehensive version preservation
430 merged_config = self._merge_configuration_preserving_versions(
431 config, existing_config
432 )
434 # Enhanced version handling using VersionReader for consistency
435 try:
436 version_reader = self._get_version_reader()
437 current_config_version = version_reader.get_version()
439 # Ensure version consistency across the merged config
440 self._ensure_version_consistency(
441 merged_config, current_config_version, existing_config
442 )
444 logger.debug(
445 f"Version consistency check completed. Current version: {current_config_version}"
446 )
447 except Exception as e:
448 logger.warning(
449 f"Version consistency check failed: {e}. Using fallback version."
450 )
451 merged_config["moai"]["version"] = __version__
453 # Write final config with enhanced formatting
454 self._write_configuration_file(config_path, merged_config)
455 logger.info(f"Configuration file written to {config_path}")
457 return [str(config_path)]
459 def _merge_configuration_preserving_versions(
460 self, new_config: dict[str, Any], existing_config: dict[str, Any]
461 ) -> dict[str, Any]:
462 """
463 Merge configurations while preserving user settings and version information.
465 Args:
466 new_config: New configuration from initialization
467 existing_config: Existing configuration from project
469 Returns:
470 Merged configuration dictionary
471 """
472 logger = logging.getLogger(__name__)
473 merged_config = new_config.copy()
475 # Define configuration sections with their merge strategies
476 config_sections = {
477 "moai": {"preserve_all": True, "priority": "user"},
478 "user": {"preserve_keys": ["nickname"], "priority": "user"},
479 "language": {
480 "preserve_keys": [],
481 "priority": "new",
482 }, # Use new language config during init
483 "project": {"preserve_keys": [], "priority": "new"},
484 "git": {"preserve_keys": [], "priority": "new"},
485 }
487 for section_name, strategy in config_sections.items():
488 if section_name in existing_config:
489 logger.debug(f"Merging section: {section_name}")
490 self._merge_config_section(
491 merged_config, existing_config, section_name, strategy
492 )
494 return merged_config
496 def _merge_config_section(
497 self,
498 merged_config: dict[str, Any],
499 existing_config: dict[str, Any],
500 section_name: str,
501 strategy: dict[str, Any],
502 ) -> None:
503 """
504 Merge a specific configuration section.
506 Args:
507 merged_config: Target configuration to merge into
508 existing_config: Source configuration to merge from
509 section_name: Name of the section to merge
510 strategy: Merge strategy for this section
511 """
512 logger = logging.getLogger(__name__)
513 if section_name not in merged_config:
514 merged_config[section_name] = {}
516 section_config = merged_config[section_name]
517 existing_section = existing_config[section_name]
519 if strategy["priority"] == "user":
520 # User priority: preserve existing values
521 preserve_keys = strategy.get("preserve_keys", [])
522 # Convert frozenset to list if needed
523 if isinstance(preserve_keys, frozenset):
524 preserve_keys = list(preserve_keys)
525 elif not isinstance(preserve_keys, list):
526 preserve_keys = list(preserve_keys) if preserve_keys else []
528 for key, value in existing_section.items():
529 if strategy.get("preserve_all", False) or key in preserve_keys:
530 section_config[key] = value
531 logger.debug(f"Preserved {section_name}.{key} = {value}")
532 else:
533 # New priority: keep new config, but don't overwrite if exists
534 for key, value in existing_section.items():
535 if key not in section_config:
536 section_config[key] = value
537 logger.debug(f"Inherited {section_name}.{key} = {value}")
539 def _ensure_version_consistency(
540 self,
541 config: dict[str, Any],
542 current_version: str,
543 existing_config: dict[str, Any],
544 ) -> None:
545 """
546 Ensure version consistency across the configuration.
548 Args:
549 config: Configuration to update
550 current_version: Current version from VersionReader
551 existing_config: Existing configuration for reference
552 """
553 logger = logging.getLogger(__name__)
555 # Ensure moai section exists
556 if "moai" not in config:
557 config["moai"] = {}
559 # Version field priority strategy:
560 # 1. User explicitly set in existing config -> preserve
561 # 2. Version from config file -> use
562 # 3. Current version from VersionReader -> use
563 # 4. Package version -> fallback
565 existing_moai = existing_config.get("moai", {})
566 config_moai = config["moai"]
568 # Check if user explicitly set a version in existing config
569 if "version" in existing_moai:
570 user_version = existing_moai["version"]
571 logger.debug(f"User explicitly set version: {user_version}")
572 config_moai["version"] = user_version
573 elif "version" in config_moai:
574 # Version already in new config, validate it
575 config_version = config_moai["version"]
576 if config_version == "unknown" or not self._is_valid_version_format(
577 config_version
578 ):
579 logger.debug(
580 f"Invalid config version {config_version}, updating to current: {current_version}"
581 )
582 config_moai["version"] = current_version
583 else:
584 # No version found, use current version
585 logger.debug(f"No version found, setting to current: {current_version}")
586 config_moai["version"] = current_version
588 def _is_valid_version_format(self, version: str) -> bool:
589 """
590 Check if version format is valid.
592 Args:
593 version: Version string to validate
595 Returns:
596 True if version format is valid
597 """
598 import re
600 pattern = r"^v?(\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?)$"
601 return bool(re.match(pattern, version))
603 def _write_configuration_file(
604 self, config_path: Path, config: dict[str, Any]
605 ) -> None:
606 """
607 Write configuration file with enhanced formatting and error handling.
609 Args:
610 config_path: Path to write configuration file
611 config: Configuration dictionary to write
612 """
613 logger = logging.getLogger(__name__)
615 try:
616 # Ensure parent directory exists
617 config_path.parent.mkdir(parents=True, exist_ok=True)
619 # Write with enhanced formatting
620 with open(config_path, "w", encoding="utf-8") as f:
621 json.dump(config, f, indent=2, ensure_ascii=False)
623 logger.info(f"Configuration successfully written to {config_path}")
625 except Exception as e:
626 logger.error(f"Failed to write configuration file: {e}")
627 raise
629 def execute_validation_phase(
630 self,
631 project_path: Path,
632 mode: str = "personal",
633 progress_callback: ProgressCallback | None = None,
634 ) -> None:
635 """Phase 5: validation and wrap-up.
638 Args:
639 project_path: Project path.
640 mode: Project mode (personal/team).
641 progress_callback: Optional progress callback.
642 """
643 self.current_phase = 5
644 self._report_progress(
645 "Phase 5: Validation and finalization...", progress_callback
646 )
648 # Validate installation results
649 # Comprehensive installation validation
650 # Verifies all required files including 4 Alfred command files:
651 # - 0-project.md, 1-plan.md, 2-run.md, 3-sync.md
652 self.validator.validate_installation(project_path)
654 # Initialize Git for team mode
655 if mode == "team":
656 self._initialize_git(project_path)
658 def _create_backup(self, project_path: Path) -> None:
659 """Create a single backup (v0.4.2).
661 Maintains only one backup at .moai-backups/backup/.
663 Args:
664 project_path: Project path.
665 """
666 # Define backup directory
667 backups_dir = project_path / ".moai-backups"
668 backup_path = backups_dir / "backup"
670 # Remove existing backup if present
671 if backup_path.exists():
672 shutil.rmtree(backup_path)
674 # Create backup directories
675 backups_dir.mkdir(parents=True, exist_ok=True)
676 backup_path.mkdir(parents=True, exist_ok=True)
678 # Collect backup targets
679 targets = get_backup_targets(project_path)
680 backed_up_files: list[str] = []
682 # Execute the backup
683 for target in targets:
684 src_path = project_path / target
685 dst_path = backup_path / target
687 if src_path.is_dir():
688 self._copy_directory_selective(src_path, dst_path)
689 backed_up_files.append(f"{target}/")
690 else:
691 dst_path.parent.mkdir(parents=True, exist_ok=True)
692 shutil.copy2(src_path, dst_path)
693 backed_up_files.append(target)
695 # Avoid additional console messages to prevent progress bar conflicts
697 def _copy_directory_selective(self, src: Path, dst: Path) -> None:
698 """Copy a directory while skipping protected paths.
700 Args:
701 src: Source directory.
702 dst: Destination directory.
703 """
704 dst.mkdir(parents=True, exist_ok=True)
706 for item in src.rglob("*"):
707 rel_path = item.relative_to(src)
709 # Skip protected paths
710 if is_protected_path(rel_path):
711 continue
713 dst_item = dst / rel_path
714 if item.is_file():
715 dst_item.parent.mkdir(parents=True, exist_ok=True)
716 shutil.copy2(item, dst_item)
717 elif item.is_dir():
718 dst_item.mkdir(parents=True, exist_ok=True)
720 def _initialize_git(self, project_path: Path) -> None:
721 """Initialize a Git repository.
723 Args:
724 project_path: Project path.
725 """
726 try:
727 subprocess.run(
728 ["git", "init"],
729 cwd=project_path,
730 check=True,
731 capture_output=True,
732 timeout=30, # Default timeout for git operations
733 )
734 # Intentionally avoid printing to keep progress output clean
735 except subprocess.TimeoutExpired:
736 # Timeout is non-fatal
737 pass
738 except subprocess.CalledProcessError:
739 # Only log on error; failures are non-fatal
740 pass
742 def _report_progress(self, message: str, callback: ProgressCallback | None) -> None:
743 """Report progress.
745 Args:
746 message: Progress message.
747 callback: Callback function.
748 """
749 if callback:
750 callback(message, self.current_phase, self.total_phases)