2121import yaml
2222from packaging import version as pkg_version
2323
24+ from ..catalogs import CatalogEntry , CatalogStackBase
25+
2426
2527# ---------------------------------------------------------------------------
2628# Errors
@@ -43,21 +45,15 @@ class IntegrationDescriptorError(Exception):
4345# ---------------------------------------------------------------------------
4446
4547@dataclass
46- class IntegrationCatalogEntry :
48+ class IntegrationCatalogEntry ( CatalogEntry ) :
4749 """Represents a single catalog source in the catalog stack."""
4850
49- url : str
50- name : str
51- priority : int
52- install_allowed : bool
53- description : str = ""
54-
5551
5652# ---------------------------------------------------------------------------
5753# IntegrationCatalog
5854# ---------------------------------------------------------------------------
5955
60- class IntegrationCatalog :
56+ class IntegrationCatalog ( CatalogStackBase ) :
6157 """Manages integration catalog fetching, caching, and searching."""
6258
6359 DEFAULT_CATALOG_URL = (
@@ -67,136 +63,15 @@ class IntegrationCatalog:
6763 "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json"
6864 )
6965 CACHE_DURATION = 3600 # 1 hour
66+ CONFIG_FILENAME = "integration-catalogs.yml"
67+ ENTRY_CLASS = IntegrationCatalogEntry
68+ ERROR_TYPE = IntegrationCatalogError
69+ VALIDATION_ERROR_TYPE = IntegrationValidationError
7070
7171 def __init__ (self , project_root : Path ) -> None :
7272 self .project_root = project_root
7373 self .cache_dir = project_root / ".specify" / "integrations" / ".cache"
7474
75- # -- URL validation ---------------------------------------------------
76-
77- @staticmethod
78- def _validate_catalog_url (url : str ) -> None :
79- from urllib .parse import urlparse
80-
81- parsed = urlparse (url )
82- is_localhost = parsed .hostname in ("localhost" , "127.0.0.1" , "::1" )
83- if parsed .scheme != "https" and not (parsed .scheme == "http" and is_localhost ):
84- raise IntegrationCatalogError (
85- f"Catalog URL must use HTTPS (got { parsed .scheme } ://). "
86- "HTTP is only allowed for localhost."
87- )
88- if not parsed .netloc :
89- raise IntegrationCatalogError (
90- "Catalog URL must be a valid URL with a host."
91- )
92-
93- # -- Catalog stack ----------------------------------------------------
94-
95- def _load_catalog_config (
96- self , config_path : Path
97- ) -> Optional [List [IntegrationCatalogEntry ]]:
98- """Load catalog stack from a YAML file.
99-
100- Returns None when the file does not exist.
101-
102- Raises:
103- IntegrationValidationError: on any local-config / YAML problem
104- (parse failures, wrong shape, missing/invalid fields,
105- invalid catalog URLs, etc.). This is a subclass of
106- :class:`IntegrationCatalogError`, so any caller that already
107- catches ``IntegrationCatalogError`` keeps working — but
108- callers that want to distinguish *local config* problems
109- from *remote/network* problems can match the subclass.
110- """
111- if not config_path .exists ():
112- return None
113- try :
114- data = yaml .safe_load (config_path .read_text (encoding = "utf-8" ))
115- except (yaml .YAMLError , OSError , UnicodeError ) as exc :
116- raise IntegrationValidationError (
117- f"Failed to read catalog config { config_path } : { exc } "
118- ) from exc
119- if data is None :
120- data = {}
121- if not isinstance (data , dict ):
122- raise IntegrationValidationError (
123- f"Invalid catalog config { config_path } : expected a YAML mapping at the root"
124- )
125- catalogs_data = data .get ("catalogs" , [])
126- if not isinstance (catalogs_data , list ):
127- raise IntegrationValidationError (
128- f"Invalid catalog config { config_path } : 'catalogs' must be a list, "
129- f"got { type (catalogs_data ).__name__ } "
130- )
131- if not catalogs_data :
132- raise IntegrationValidationError (
133- f"Catalog config { config_path } exists but contains no 'catalogs' entries. "
134- f"Remove the file to use built-in defaults, or add valid catalog entries."
135- )
136- entries : List [IntegrationCatalogEntry ] = []
137- skipped : List [int ] = []
138- for idx , item in enumerate (catalogs_data ):
139- if not isinstance (item , dict ):
140- raise IntegrationValidationError (
141- f"Invalid catalog config { config_path } : catalog entry at index { idx } : "
142- f"expected a mapping, got { type (item ).__name__ } "
143- )
144- url = str (item .get ("url" , "" )).strip ()
145- if not url :
146- skipped .append (idx )
147- continue
148- try :
149- self ._validate_catalog_url (url )
150- except IntegrationCatalogError as exc :
151- # ``_validate_catalog_url`` raises the base class for direct
152- # callers (e.g. ``add_catalog`` validating user input); when
153- # the bad URL came from a local config file, surface it as a
154- # validation error so CLI handlers can route it accordingly.
155- raise IntegrationValidationError (
156- f"Invalid catalog URL in { config_path } at index { idx } : { exc } "
157- ) from exc
158- raw_priority = item .get ("priority" , idx + 1 )
159- if isinstance (raw_priority , bool ):
160- raise IntegrationValidationError (
161- f"Invalid catalog config { config_path } : "
162- f"Invalid priority for catalog '{ item .get ('name' , idx + 1 )} ': "
163- f"expected integer, got { raw_priority !r} "
164- )
165- try :
166- priority = int (raw_priority )
167- except (TypeError , ValueError ):
168- raise IntegrationValidationError (
169- f"Invalid catalog config { config_path } : "
170- f"Invalid priority for catalog '{ item .get ('name' , idx + 1 )} ': "
171- f"expected integer, got { raw_priority !r} "
172- )
173- raw_install = item .get ("install_allowed" , False )
174- if isinstance (raw_install , str ):
175- install_allowed = raw_install .strip ().lower () in ("true" , "yes" , "1" )
176- else :
177- install_allowed = bool (raw_install )
178- raw_name = item .get ("name" )
179- name = str (raw_name ).strip () if raw_name is not None else ""
180- if not name :
181- name = f"catalog-{ len (entries ) + 1 } "
182- entries .append (
183- IntegrationCatalogEntry (
184- url = url ,
185- name = name ,
186- priority = priority ,
187- install_allowed = install_allowed ,
188- description = str (item .get ("description" , "" )),
189- )
190- )
191- entries .sort (key = lambda e : e .priority )
192- if not entries :
193- raise IntegrationValidationError (
194- f"Catalog config { config_path } contains { len (catalogs_data )} "
195- f"entries but none have valid URLs (entries at indices { skipped } "
196- f"were skipped). Each catalog entry must have a 'url' field."
197- )
198- return entries
199-
20075 def get_active_catalogs (self ) -> List [IntegrationCatalogEntry ]:
20176 """Return the ordered list of active integration catalogs.
20277
@@ -444,8 +319,6 @@ def clear_cache(self) -> None:
444319
445320 # -- Catalog-source management ----------------------------------------
446321
447- CONFIG_FILENAME = "integration-catalogs.yml"
448-
449322 def get_catalog_configs (self ) -> List [Dict [str , Any ]]:
450323 """Return the active catalog stack as a list of dicts.
451324
0 commit comments