config.py 37 KB

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