Manages step catalog fetching, caching, and searching. Resolution order for catalog sources: 1. ``SPECKIT_STEP_CATALOG_URL`` env var (overrides all) 2. Project-level ``.specify/step-catalogs.yml`` 3. User-level ``~/.specify/step-catalogs.yml`` 4. Built-in defaults (official + co
| 735 | |
| 736 | |
| 737 | class StepCatalog: |
| 738 | """Manages step catalog fetching, caching, and searching. |
| 739 | |
| 740 | Resolution order for catalog sources: |
| 741 | 1. ``SPECKIT_STEP_CATALOG_URL`` env var (overrides all) |
| 742 | 2. Project-level ``.specify/step-catalogs.yml`` |
| 743 | 3. User-level ``~/.specify/step-catalogs.yml`` |
| 744 | 4. Built-in defaults (official + community) |
| 745 | """ |
| 746 | |
| 747 | DEFAULT_CATALOG_URL = ( |
| 748 | "https://raw.githubusercontent.com/github/spec-kit/main/" |
| 749 | "workflows/step-catalog.json" |
| 750 | ) |
| 751 | COMMUNITY_CATALOG_URL = ( |
| 752 | "https://raw.githubusercontent.com/github/spec-kit/main/" |
| 753 | "workflows/step-catalog.community.json" |
| 754 | ) |
| 755 | CACHE_DURATION = 3600 # 1 hour |
| 756 | |
| 757 | def __init__(self, project_root: Path) -> None: |
| 758 | self.project_root = project_root |
| 759 | self.steps_dir = project_root / ".specify" / "workflows" / "steps" |
| 760 | self.cache_dir = self.steps_dir / ".cache" |
| 761 | |
| 762 | def _is_cache_path_safe(self) -> bool: |
| 763 | """Return False if any component of the cache path is a symlink.""" |
| 764 | current = self.project_root |
| 765 | for part in (".specify", "workflows", "steps", ".cache"): |
| 766 | current = current / part |
| 767 | if current.is_symlink(): |
| 768 | return False |
| 769 | return True |
| 770 | |
| 771 | # -- Catalog resolution ----------------------------------------------- |
| 772 | |
| 773 | def _validate_catalog_url(self, url: str) -> None: |
| 774 | """Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" |
| 775 | from urllib.parse import urlparse |
| 776 | |
| 777 | parsed = urlparse(url) |
| 778 | is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") |
| 779 | if parsed.scheme != "https" and not ( |
| 780 | parsed.scheme == "http" and is_localhost |
| 781 | ): |
| 782 | raise StepValidationError( |
| 783 | f"Catalog URL must use HTTPS (got {parsed.scheme}://). " |
| 784 | "HTTP is only allowed for localhost." |
| 785 | ) |
| 786 | if not parsed.hostname: |
| 787 | raise StepValidationError( |
| 788 | "Catalog URL must be a valid URL with a host." |
| 789 | ) |
| 790 | |
| 791 | def _load_catalog_config( |
| 792 | self, config_path: Path |
| 793 | ) -> list[StepCatalogEntry] | None: |
| 794 | """Load catalog stack configuration from a YAML file.""" |
no outgoing calls