config.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. import re
  5. import shutil
  6. import tempfile
  7. from pathlib import Path
  8. from typing import Any, Dict, Optional, Union
  9. import yaml
  10. from rich.console import Console
  11. from .variable import Variable
  12. from .section import VariableSection
  13. from .collection import VariableCollection
  14. from .exceptions import ConfigError, ConfigValidationError, YAMLParseError
  15. logger = logging.getLogger(__name__)
  16. console = Console()
  17. # Valid Python identifier pattern for variable names
  18. VALID_IDENTIFIER_PATTERN = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
  19. # Valid path pattern - prevents path traversal attempts
  20. VALID_PATH_PATTERN = re.compile(r'^[^\x00-\x1f<>:"|?*]+$')
  21. # Maximum allowed string lengths to prevent DOS attacks
  22. MAX_STRING_LENGTH = 1000
  23. MAX_PATH_LENGTH = 4096
  24. MAX_LIST_LENGTH = 100
  25. class ConfigManager:
  26. """Manages configuration for the CLI application."""
  27. def __init__(self, config_path: Optional[Union[str, Path]] = None) -> None:
  28. """Initialize the configuration manager.
  29. Args:
  30. config_path: Path to the configuration file. If None, uses default location.
  31. """
  32. if config_path is None:
  33. # Default to ~/.config/boilerplates/config.yaml
  34. config_dir = Path.home() / ".config" / "boilerplates"
  35. config_dir.mkdir(parents=True, exist_ok=True)
  36. self.config_path = config_dir / "config.yaml"
  37. else:
  38. self.config_path = Path(config_path)
  39. # Create default config if it doesn't exist
  40. if not self.config_path.exists():
  41. self._create_default_config()
  42. else:
  43. # Migrate existing config if needed
  44. self._migrate_config_if_needed()
  45. def _create_default_config(self) -> None:
  46. """Create a default configuration file."""
  47. default_config = {
  48. "defaults": {},
  49. "preferences": {
  50. "editor": "vim",
  51. "output_dir": None,
  52. "library_paths": []
  53. },
  54. "libraries": [
  55. {
  56. "name": "default",
  57. "type": "git",
  58. "url": "https://github.com/christianlempa/boilerplates.git",
  59. "branch": "main",
  60. "directory": "library",
  61. "enabled": True
  62. }
  63. ]
  64. }
  65. self._write_config(default_config)
  66. logger.info(f"Created default configuration at {self.config_path}")
  67. def _migrate_config_if_needed(self) -> None:
  68. """Migrate existing config to add missing sections and library types."""
  69. try:
  70. config = self._read_config()
  71. needs_migration = False
  72. # Add libraries section if missing
  73. if "libraries" not in config:
  74. logger.info("Migrating config: adding libraries section")
  75. config["libraries"] = [
  76. {
  77. "name": "default",
  78. "type": "git",
  79. "url": "https://github.com/christianlempa/boilerplates.git",
  80. "branch": "refactor/boilerplates-v2",
  81. "directory": "library",
  82. "enabled": True
  83. }
  84. ]
  85. needs_migration = True
  86. else:
  87. # Migrate existing libraries to add 'type' field if missing
  88. # For backward compatibility, assume all old libraries without 'type' are git libraries
  89. libraries = config.get("libraries", [])
  90. for library in libraries:
  91. if "type" not in library:
  92. logger.info(f"Migrating library '{library.get('name', 'unknown')}': adding type: git")
  93. library["type"] = "git"
  94. needs_migration = True
  95. # Write back if migration was needed
  96. if needs_migration:
  97. self._write_config(config)
  98. logger.info("Config migration completed successfully")
  99. except Exception as e:
  100. logger.warning(f"Config migration failed: {e}")
  101. @staticmethod
  102. def _validate_string_length(value: str, field_name: str, max_length: int = MAX_STRING_LENGTH) -> None:
  103. """Validate string length to prevent DOS attacks.
  104. Args:
  105. value: String value to validate
  106. field_name: Name of the field for error messages
  107. max_length: Maximum allowed length
  108. Raises:
  109. ConfigValidationError: If string exceeds maximum length
  110. """
  111. if len(value) > max_length:
  112. raise ConfigValidationError(
  113. f"{field_name} exceeds maximum length of {max_length} characters "
  114. f"(got {len(value)} characters)"
  115. )
  116. @staticmethod
  117. def _validate_path_string(path: str, field_name: str) -> None:
  118. """Validate path string for security concerns.
  119. Args:
  120. path: Path string to validate
  121. field_name: Name of the field for error messages
  122. Raises:
  123. ConfigValidationError: If path contains invalid characters or patterns
  124. """
  125. # Check length
  126. if len(path) > MAX_PATH_LENGTH:
  127. raise ConfigValidationError(
  128. f"{field_name} exceeds maximum path length of {MAX_PATH_LENGTH} characters"
  129. )
  130. # Check for null bytes and control characters
  131. if '\x00' in path or any(ord(c) < 32 for c in path if c not in '\t\n\r'):
  132. raise ConfigValidationError(
  133. f"{field_name} contains invalid control characters"
  134. )
  135. # Check for path traversal attempts
  136. if '..' in path.split('/'):
  137. logger.warning(f"Path '{path}' contains '..' - potential path traversal attempt")
  138. @staticmethod
  139. def _validate_list_length(lst: list, field_name: str, max_length: int = MAX_LIST_LENGTH) -> None:
  140. """Validate list length to prevent DOS attacks.
  141. Args:
  142. lst: List to validate
  143. field_name: Name of the field for error messages
  144. max_length: Maximum allowed length
  145. Raises:
  146. ConfigValidationError: If list exceeds maximum length
  147. """
  148. if len(lst) > max_length:
  149. raise ConfigValidationError(
  150. f"{field_name} exceeds maximum length of {max_length} items (got {len(lst)} items)"
  151. )
  152. def _read_config(self) -> Dict[str, Any]:
  153. """Read configuration from file.
  154. Returns:
  155. Dictionary containing the configuration.
  156. Raises:
  157. YAMLParseError: If YAML parsing fails.
  158. ConfigValidationError: If configuration structure is invalid.
  159. ConfigError: If reading fails for other reasons.
  160. """
  161. try:
  162. with open(self.config_path, 'r') as f:
  163. config = yaml.safe_load(f) or {}
  164. # Validate config structure
  165. self._validate_config_structure(config)
  166. return config
  167. except yaml.YAMLError as e:
  168. logger.error(f"Failed to parse YAML configuration: {e}")
  169. raise YAMLParseError(str(self.config_path), e)
  170. except ConfigValidationError:
  171. # Re-raise validation errors as-is
  172. raise
  173. except (IOError, OSError) as e:
  174. logger.error(f"Failed to read configuration file: {e}")
  175. raise ConfigError(f"Failed to read configuration file '{self.config_path}': {e}")
  176. def _write_config(self, config: Dict[str, Any]) -> None:
  177. """Write configuration to file atomically using temp file + rename pattern.
  178. This prevents config file corruption if write operation fails partway through.
  179. Args:
  180. config: Dictionary containing the configuration to write.
  181. Raises:
  182. ConfigValidationError: If configuration structure is invalid.
  183. ConfigError: If writing fails for any reason.
  184. """
  185. tmp_path = None
  186. try:
  187. # Validate config structure before writing
  188. self._validate_config_structure(config)
  189. # Ensure parent directory exists
  190. self.config_path.parent.mkdir(parents=True, exist_ok=True)
  191. # Write to temporary file in same directory for atomic rename
  192. with tempfile.NamedTemporaryFile(
  193. mode='w',
  194. delete=False,
  195. dir=self.config_path.parent,
  196. prefix='.config_',
  197. suffix='.tmp'
  198. ) as tmp_file:
  199. yaml.dump(config, tmp_file, default_flow_style=False)
  200. tmp_path = tmp_file.name
  201. # Atomic rename (overwrites existing file on POSIX systems)
  202. shutil.move(tmp_path, self.config_path)
  203. logger.debug(f"Configuration written atomically to {self.config_path}")
  204. except ConfigValidationError:
  205. # Re-raise validation errors as-is
  206. if tmp_path:
  207. Path(tmp_path).unlink(missing_ok=True)
  208. raise
  209. except (IOError, OSError, yaml.YAMLError) as e:
  210. # Clean up temp file if it exists
  211. if tmp_path:
  212. try:
  213. Path(tmp_path).unlink(missing_ok=True)
  214. except (IOError, OSError):
  215. logger.warning(f"Failed to clean up temporary file: {tmp_path}")
  216. logger.error(f"Failed to write configuration file: {e}")
  217. raise ConfigError(f"Failed to write configuration to '{self.config_path}': {e}")
  218. def _validate_config_structure(self, config: Dict[str, Any]) -> None:
  219. """Validate the configuration structure with comprehensive checks.
  220. Args:
  221. config: Configuration dictionary to validate.
  222. Raises:
  223. ConfigValidationError: If configuration structure is invalid.
  224. """
  225. if not isinstance(config, dict):
  226. raise ConfigValidationError("Configuration must be a dictionary")
  227. # Check top-level structure
  228. if "defaults" in config and not isinstance(config["defaults"], dict):
  229. raise ConfigValidationError("'defaults' must be a dictionary")
  230. if "preferences" in config and not isinstance(config["preferences"], dict):
  231. raise ConfigValidationError("'preferences' must be a dictionary")
  232. # Validate defaults structure
  233. if "defaults" in config:
  234. for module_name, module_defaults in config["defaults"].items():
  235. if not isinstance(module_name, str):
  236. raise ConfigValidationError(f"Module name must be a string, got {type(module_name).__name__}")
  237. # Validate module name length
  238. self._validate_string_length(module_name, "Module name", max_length=100)
  239. if not isinstance(module_defaults, dict):
  240. raise ConfigValidationError(f"Defaults for module '{module_name}' must be a dictionary")
  241. # Validate number of defaults per module
  242. self._validate_list_length(
  243. list(module_defaults.keys()),
  244. f"Defaults for module '{module_name}'"
  245. )
  246. # Validate variable names are valid Python identifiers
  247. for var_name, var_value in module_defaults.items():
  248. if not isinstance(var_name, str):
  249. raise ConfigValidationError(f"Variable name must be a string, got {type(var_name).__name__}")
  250. # Validate variable name length
  251. self._validate_string_length(var_name, "Variable name", max_length=100)
  252. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  253. raise ConfigValidationError(
  254. f"Invalid variable name '{var_name}' in module '{module_name}'. "
  255. f"Variable names must be valid Python identifiers (letters, numbers, underscores, "
  256. f"cannot start with a number)"
  257. )
  258. # Validate variable value types and lengths
  259. if isinstance(var_value, str):
  260. self._validate_string_length(
  261. var_value,
  262. f"Value for '{module_name}.{var_name}'"
  263. )
  264. elif isinstance(var_value, list):
  265. self._validate_list_length(
  266. var_value,
  267. f"Value for '{module_name}.{var_name}'"
  268. )
  269. elif var_value is not None and not isinstance(var_value, (bool, int, float)):
  270. raise ConfigValidationError(
  271. f"Invalid value type for '{module_name}.{var_name}': "
  272. f"must be string, number, boolean, list, or null (got {type(var_value).__name__})"
  273. )
  274. # Validate preferences structure and types
  275. if "preferences" in config:
  276. preferences = config["preferences"]
  277. # Validate known preference types
  278. if "editor" in preferences:
  279. if not isinstance(preferences["editor"], str):
  280. raise ConfigValidationError("Preference 'editor' must be a string")
  281. self._validate_string_length(preferences["editor"], "Preference 'editor'", max_length=100)
  282. if "output_dir" in preferences:
  283. output_dir = preferences["output_dir"]
  284. if output_dir is not None:
  285. if not isinstance(output_dir, str):
  286. raise ConfigValidationError("Preference 'output_dir' must be a string or null")
  287. self._validate_path_string(output_dir, "Preference 'output_dir'")
  288. if "library_paths" in preferences:
  289. if not isinstance(preferences["library_paths"], list):
  290. raise ConfigValidationError("Preference 'library_paths' must be a list")
  291. self._validate_list_length(preferences["library_paths"], "Preference 'library_paths'")
  292. for i, path in enumerate(preferences["library_paths"]):
  293. if not isinstance(path, str):
  294. raise ConfigValidationError(f"Library path must be a string, got {type(path).__name__}")
  295. self._validate_path_string(path, f"Library path at index {i}")
  296. # Validate libraries structure
  297. if "libraries" in config:
  298. libraries = config["libraries"]
  299. if not isinstance(libraries, list):
  300. raise ConfigValidationError("'libraries' must be a list")
  301. self._validate_list_length(libraries, "Libraries list")
  302. for i, library in enumerate(libraries):
  303. if not isinstance(library, dict):
  304. raise ConfigValidationError(f"Library at index {i} must be a dictionary")
  305. # Validate name field (required for all library types)
  306. if "name" not in library:
  307. raise ConfigValidationError(f"Library at index {i} missing required field 'name'")
  308. if not isinstance(library["name"], str):
  309. raise ConfigValidationError(f"Library 'name' at index {i} must be a string")
  310. self._validate_string_length(library["name"], f"Library 'name' at index {i}", max_length=500)
  311. # Validate type field (default to "git" for backward compatibility)
  312. lib_type = library.get("type", "git")
  313. if lib_type not in ("git", "static"):
  314. raise ConfigValidationError(f"Library type at index {i} must be 'git' or 'static', got '{lib_type}'")
  315. # Type-specific validation
  316. if lib_type == "git":
  317. # Git libraries require: url, directory
  318. required_fields = ["url", "directory"]
  319. for field in required_fields:
  320. if field not in library:
  321. raise ConfigValidationError(f"Git library at index {i} missing required field '{field}'")
  322. if not isinstance(library[field], str):
  323. raise ConfigValidationError(f"Library '{field}' at index {i} must be a string")
  324. self._validate_string_length(library[field], f"Library '{field}' at index {i}", max_length=500)
  325. # Validate optional branch field
  326. if "branch" in library:
  327. if not isinstance(library["branch"], str):
  328. raise ConfigValidationError(f"Library 'branch' at index {i} must be a string")
  329. self._validate_string_length(library["branch"], f"Library 'branch' at index {i}", max_length=200)
  330. elif lib_type == "static":
  331. # Static libraries require: path
  332. if "path" not in library:
  333. raise ConfigValidationError(f"Static library at index {i} missing required field 'path'")
  334. if not isinstance(library["path"], str):
  335. raise ConfigValidationError(f"Library 'path' at index {i} must be a string")
  336. self._validate_path_string(library["path"], f"Library 'path' at index {i}")
  337. # Validate optional enabled field (applies to all types)
  338. if "enabled" in library and not isinstance(library["enabled"], bool):
  339. raise ConfigValidationError(f"Library 'enabled' at index {i} must be a boolean")
  340. def get_config_path(self) -> Path:
  341. """Get the path to the configuration file.
  342. Returns:
  343. Path to the configuration file.
  344. """
  345. return self.config_path
  346. def get_defaults(self, module_name: str) -> Dict[str, Any]:
  347. """Get default variable values for a module.
  348. Returns defaults in a flat format:
  349. {
  350. "var_name": "value",
  351. "var2_name": "value2"
  352. }
  353. Args:
  354. module_name: Name of the module
  355. Returns:
  356. Dictionary of default values (flat key-value pairs)
  357. """
  358. config = self._read_config()
  359. defaults = config.get("defaults", {})
  360. return defaults.get(module_name, {})
  361. def set_defaults(self, module_name: str, defaults: Dict[str, Any]) -> None:
  362. """Set default variable values for a module with comprehensive validation.
  363. Args:
  364. module_name: Name of the module
  365. defaults: Dictionary of defaults (flat key-value pairs):
  366. {"var_name": "value", "var2_name": "value2"}
  367. Raises:
  368. ConfigValidationError: If module name or variable names are invalid.
  369. """
  370. # Validate module name
  371. if not isinstance(module_name, str) or not module_name:
  372. raise ConfigValidationError("Module name must be a non-empty string")
  373. self._validate_string_length(module_name, "Module name", max_length=100)
  374. # Validate defaults dictionary
  375. if not isinstance(defaults, dict):
  376. raise ConfigValidationError("Defaults must be a dictionary")
  377. # Validate number of defaults
  378. self._validate_list_length(list(defaults.keys()), "Defaults dictionary")
  379. # Validate variable names and values
  380. for var_name, var_value in defaults.items():
  381. if not isinstance(var_name, str):
  382. raise ConfigValidationError(f"Variable name must be a string, got {type(var_name).__name__}")
  383. self._validate_string_length(var_name, "Variable name", max_length=100)
  384. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  385. raise ConfigValidationError(
  386. f"Invalid variable name '{var_name}'. Variable names must be valid Python identifiers "
  387. f"(letters, numbers, underscores, cannot start with a number)"
  388. )
  389. # Validate value types and lengths
  390. if isinstance(var_value, str):
  391. self._validate_string_length(var_value, f"Value for '{var_name}'")
  392. elif isinstance(var_value, list):
  393. self._validate_list_length(var_value, f"Value for '{var_name}'")
  394. elif var_value is not None and not isinstance(var_value, (bool, int, float)):
  395. raise ConfigValidationError(
  396. f"Invalid value type for '{var_name}': "
  397. f"must be string, number, boolean, list, or null (got {type(var_value).__name__})"
  398. )
  399. config = self._read_config()
  400. if "defaults" not in config:
  401. config["defaults"] = {}
  402. config["defaults"][module_name] = defaults
  403. self._write_config(config)
  404. logger.info(f"Updated defaults for module '{module_name}'")
  405. def set_default_value(self, module_name: str, var_name: str, value: Any) -> None:
  406. """Set a single default variable value with comprehensive validation.
  407. Args:
  408. module_name: Name of the module
  409. var_name: Name of the variable
  410. value: Default value to set
  411. Raises:
  412. ConfigValidationError: If module name or variable name is invalid.
  413. """
  414. # Validate inputs
  415. if not isinstance(module_name, str) or not module_name:
  416. raise ConfigValidationError("Module name must be a non-empty string")
  417. self._validate_string_length(module_name, "Module name", max_length=100)
  418. if not isinstance(var_name, str):
  419. raise ConfigValidationError(f"Variable name must be a string, got {type(var_name).__name__}")
  420. self._validate_string_length(var_name, "Variable name", max_length=100)
  421. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  422. raise ConfigValidationError(
  423. f"Invalid variable name '{var_name}'. Variable names must be valid Python identifiers "
  424. f"(letters, numbers, underscores, cannot start with a number)"
  425. )
  426. # Validate value type and length
  427. if isinstance(value, str):
  428. self._validate_string_length(value, f"Value for '{var_name}'")
  429. elif isinstance(value, list):
  430. self._validate_list_length(value, f"Value for '{var_name}'")
  431. elif value is not None and not isinstance(value, (bool, int, float)):
  432. raise ConfigValidationError(
  433. f"Invalid value type for '{var_name}': "
  434. f"must be string, number, boolean, list, or null (got {type(value).__name__})"
  435. )
  436. defaults = self.get_defaults(module_name)
  437. defaults[var_name] = value
  438. self.set_defaults(module_name, defaults)
  439. logger.info(f"Set default for '{module_name}.{var_name}' = '{value}'")
  440. def get_default_value(self, module_name: str, var_name: str) -> Optional[Any]:
  441. """Get a single default variable value.
  442. Args:
  443. module_name: Name of the module
  444. var_name: Name of the variable
  445. Returns:
  446. Default value or None if not set
  447. """
  448. defaults = self.get_defaults(module_name)
  449. return defaults.get(var_name)
  450. def clear_defaults(self, module_name: str) -> None:
  451. """Clear all defaults for a module.
  452. Args:
  453. module_name: Name of the module
  454. """
  455. config = self._read_config()
  456. if "defaults" in config and module_name in config["defaults"]:
  457. del config["defaults"][module_name]
  458. self._write_config(config)
  459. logger.info(f"Cleared defaults for module '{module_name}'")
  460. def get_preference(self, key: str) -> Optional[Any]:
  461. """Get a user preference value.
  462. Args:
  463. key: Preference key (e.g., 'editor', 'output_dir', 'library_paths')
  464. Returns:
  465. Preference value or None if not set
  466. """
  467. config = self._read_config()
  468. preferences = config.get("preferences", {})
  469. return preferences.get(key)
  470. def set_preference(self, key: str, value: Any) -> None:
  471. """Set a user preference value with comprehensive validation.
  472. Args:
  473. key: Preference key
  474. value: Preference value
  475. Raises:
  476. ConfigValidationError: If key or value is invalid for known preference types.
  477. """
  478. # Validate key
  479. if not isinstance(key, str) or not key:
  480. raise ConfigValidationError("Preference key must be a non-empty string")
  481. self._validate_string_length(key, "Preference key", max_length=100)
  482. # Validate known preference types
  483. if key == "editor":
  484. if not isinstance(value, str):
  485. raise ConfigValidationError("Preference 'editor' must be a string")
  486. self._validate_string_length(value, "Preference 'editor'", max_length=100)
  487. elif key == "output_dir":
  488. if value is not None:
  489. if not isinstance(value, str):
  490. raise ConfigValidationError("Preference 'output_dir' must be a string or null")
  491. self._validate_path_string(value, "Preference 'output_dir'")
  492. elif key == "library_paths":
  493. if not isinstance(value, list):
  494. raise ConfigValidationError("Preference 'library_paths' must be a list")
  495. self._validate_list_length(value, "Preference 'library_paths'")
  496. for i, path in enumerate(value):
  497. if not isinstance(path, str):
  498. raise ConfigValidationError(f"Library path must be a string, got {type(path).__name__}")
  499. self._validate_path_string(path, f"Library path at index {i}")
  500. # For unknown preference keys, apply basic validation
  501. else:
  502. if isinstance(value, str):
  503. self._validate_string_length(value, f"Preference '{key}'")
  504. elif isinstance(value, list):
  505. self._validate_list_length(value, f"Preference '{key}'")
  506. config = self._read_config()
  507. if "preferences" not in config:
  508. config["preferences"] = {}
  509. config["preferences"][key] = value
  510. self._write_config(config)
  511. logger.info(f"Set preference '{key}' = '{value}'")
  512. def get_all_preferences(self) -> Dict[str, Any]:
  513. """Get all user preferences.
  514. Returns:
  515. Dictionary of all preferences
  516. """
  517. config = self._read_config()
  518. return config.get("preferences", {})
  519. def get_libraries(self) -> list[Dict[str, Any]]:
  520. """Get all configured libraries.
  521. Returns:
  522. List of library configurations
  523. """
  524. config = self._read_config()
  525. return config.get("libraries", [])
  526. def get_library_by_name(self, name: str) -> Optional[Dict[str, Any]]:
  527. """Get a specific library by name.
  528. Args:
  529. name: Name of the library
  530. Returns:
  531. Library configuration dictionary or None if not found
  532. """
  533. libraries = self.get_libraries()
  534. for library in libraries:
  535. if library.get("name") == name:
  536. return library
  537. return None
  538. def add_library(
  539. self,
  540. name: str,
  541. library_type: str = "git",
  542. url: Optional[str] = None,
  543. directory: Optional[str] = None,
  544. branch: str = "main",
  545. path: Optional[str] = None,
  546. enabled: bool = True
  547. ) -> None:
  548. """Add a new library to the configuration.
  549. Args:
  550. name: Unique name for the library
  551. library_type: Type of library ("git" or "static")
  552. url: Git repository URL (required for git type)
  553. directory: Directory within repo (required for git type)
  554. branch: Git branch (for git type)
  555. path: Local path to templates (required for static type)
  556. enabled: Whether the library is enabled
  557. Raises:
  558. ConfigValidationError: If library with the same name already exists or validation fails
  559. """
  560. # Validate name
  561. if not isinstance(name, str) or not name:
  562. raise ConfigValidationError("Library name must be a non-empty string")
  563. self._validate_string_length(name, "Library name", max_length=100)
  564. # Validate type
  565. if library_type not in ("git", "static"):
  566. raise ConfigValidationError(f"Library type must be 'git' or 'static', got '{library_type}'")
  567. # Check if library already exists
  568. if self.get_library_by_name(name):
  569. raise ConfigValidationError(f"Library '{name}' already exists")
  570. # Type-specific validation and config building
  571. if library_type == "git":
  572. if not url:
  573. raise ConfigValidationError("Git libraries require 'url' parameter")
  574. if not directory:
  575. raise ConfigValidationError("Git libraries require 'directory' parameter")
  576. # Validate git-specific fields
  577. if not isinstance(url, str) or not url:
  578. raise ConfigValidationError("Library URL must be a non-empty string")
  579. self._validate_string_length(url, "Library URL", max_length=500)
  580. if not isinstance(directory, str) or not directory:
  581. raise ConfigValidationError("Library directory must be a non-empty string")
  582. self._validate_string_length(directory, "Library directory", max_length=200)
  583. if not isinstance(branch, str) or not branch:
  584. raise ConfigValidationError("Library branch must be a non-empty string")
  585. self._validate_string_length(branch, "Library branch", max_length=200)
  586. library_config = {
  587. "name": name,
  588. "type": "git",
  589. "url": url,
  590. "branch": branch,
  591. "directory": directory,
  592. "enabled": enabled
  593. }
  594. else: # static
  595. if not path:
  596. raise ConfigValidationError("Static libraries require 'path' parameter")
  597. # Validate static-specific fields
  598. if not isinstance(path, str) or not path:
  599. raise ConfigValidationError("Library path must be a non-empty string")
  600. self._validate_path_string(path, "Library path")
  601. # For backward compatibility with older CLI versions,
  602. # add dummy values for git-specific fields
  603. library_config = {
  604. "name": name,
  605. "type": "static",
  606. "url": "", # Empty string for backward compatibility
  607. "branch": "main", # Default value for backward compatibility
  608. "directory": ".", # Default value for backward compatibility
  609. "path": path,
  610. "enabled": enabled
  611. }
  612. config = self._read_config()
  613. if "libraries" not in config:
  614. config["libraries"] = []
  615. config["libraries"].append(library_config)
  616. self._write_config(config)
  617. logger.info(f"Added {library_type} library '{name}'")
  618. def remove_library(self, name: str) -> None:
  619. """Remove a library from the configuration.
  620. Args:
  621. name: Name of the library to remove
  622. Raises:
  623. ConfigError: If library is not found
  624. """
  625. config = self._read_config()
  626. libraries = config.get("libraries", [])
  627. # Find and remove the library
  628. new_libraries = [lib for lib in libraries if lib.get("name") != name]
  629. if len(new_libraries) == len(libraries):
  630. raise ConfigError(f"Library '{name}' not found")
  631. config["libraries"] = new_libraries
  632. self._write_config(config)
  633. logger.info(f"Removed library '{name}'")
  634. def update_library(self, name: str, **kwargs: Any) -> None:
  635. """Update a library's configuration.
  636. Args:
  637. name: Name of the library to update
  638. **kwargs: Fields to update (url, branch, directory, enabled)
  639. Raises:
  640. ConfigError: If library is not found
  641. ConfigValidationError: If validation fails
  642. """
  643. config = self._read_config()
  644. libraries = config.get("libraries", [])
  645. # Find the library
  646. library_found = False
  647. for library in libraries:
  648. if library.get("name") == name:
  649. library_found = True
  650. # Update allowed fields
  651. if "url" in kwargs:
  652. url = kwargs["url"]
  653. if not isinstance(url, str) or not url:
  654. raise ConfigValidationError("Library URL must be a non-empty string")
  655. self._validate_string_length(url, "Library URL", max_length=500)
  656. library["url"] = url
  657. if "branch" in kwargs:
  658. branch = kwargs["branch"]
  659. if not isinstance(branch, str) or not branch:
  660. raise ConfigValidationError("Library branch must be a non-empty string")
  661. self._validate_string_length(branch, "Library branch", max_length=200)
  662. library["branch"] = branch
  663. if "directory" in kwargs:
  664. directory = kwargs["directory"]
  665. if not isinstance(directory, str) or not directory:
  666. raise ConfigValidationError("Library directory must be a non-empty string")
  667. self._validate_string_length(directory, "Library directory", max_length=200)
  668. library["directory"] = directory
  669. if "enabled" in kwargs:
  670. enabled = kwargs["enabled"]
  671. if not isinstance(enabled, bool):
  672. raise ConfigValidationError("Library enabled must be a boolean")
  673. library["enabled"] = enabled
  674. break
  675. if not library_found:
  676. raise ConfigError(f"Library '{name}' not found")
  677. config["libraries"] = libraries
  678. self._write_config(config)
  679. logger.info(f"Updated library '{name}'")
  680. def get_libraries_path(self) -> Path:
  681. """Get the path to the libraries directory.
  682. Returns:
  683. Path to the libraries directory (same directory as config file)
  684. """
  685. return self.config_path.parent / "libraries"