Manages the registry of installed custom step types. Tracks installed step types and their metadata in ``.specify/workflows/steps/step-registry.json``.
| 624 | |
| 625 | |
| 626 | class StepRegistry: |
| 627 | """Manages the registry of installed custom step types. |
| 628 | |
| 629 | Tracks installed step types and their metadata in |
| 630 | ``.specify/workflows/steps/step-registry.json``. |
| 631 | """ |
| 632 | |
| 633 | REGISTRY_FILE = "step-registry.json" |
| 634 | SCHEMA_VERSION = "1.0" |
| 635 | |
| 636 | def __init__(self, project_root: Path) -> None: |
| 637 | self.project_root = project_root |
| 638 | self.steps_dir = project_root / ".specify" / "workflows" / "steps" |
| 639 | self.registry_path = self.steps_dir / self.REGISTRY_FILE |
| 640 | self.data = self._load() |
| 641 | |
| 642 | def _has_symlinked_parent(self) -> bool: |
| 643 | """Return True if any directory under .specify/workflows/steps is a symlink.""" |
| 644 | current = self.project_root |
| 645 | for part in (".specify", "workflows", "steps"): |
| 646 | current = current / part |
| 647 | if current.is_symlink(): |
| 648 | return True |
| 649 | return False |
| 650 | |
| 651 | def _load(self) -> dict[str, Any]: |
| 652 | """Load registry from disk or create default.""" |
| 653 | default_registry: dict[str, Any] = {"schema_version": self.SCHEMA_VERSION, "steps": {}} |
| 654 | # Defense-in-depth: refuse to read the registry if any parent directory |
| 655 | # under .specify/workflows/steps is a symlink, which could redirect the |
| 656 | # read outside the project root. |
| 657 | if self._has_symlinked_parent(): |
| 658 | return default_registry |
| 659 | # Defense-in-depth: also refuse to read a symlinked registry file, |
| 660 | # which could redirect the read outside the project root. |
| 661 | if self.registry_path.is_symlink(): |
| 662 | return default_registry |
| 663 | if self.registry_path.exists(): |
| 664 | try: |
| 665 | with open(self.registry_path, encoding="utf-8") as f: |
| 666 | data = json.load(f) |
| 667 | # Validate shape: must be a dict with a dict "steps" field |
| 668 | if not isinstance(data, dict): |
| 669 | return default_registry |
| 670 | if not isinstance(data.get("steps"), dict): |
| 671 | data["steps"] = {} |
| 672 | return data |
| 673 | except (json.JSONDecodeError, ValueError, OSError, UnicodeError): |
| 674 | return default_registry |
| 675 | return default_registry |
| 676 | |
| 677 | def save(self) -> None: |
| 678 | """Persist registry to disk. |
| 679 | |
| 680 | Raises ``StepValidationError`` with a clear message on filesystem |
| 681 | errors (read-only fs, permission denied, ...) so callers can surface |
| 682 | a clean error to the user rather than an unhandled ``OSError``. |
| 683 | """ |
no outgoing calls