Install extension from ZIP file. Args: zip_path: Path to extension ZIP file speckit_version: Current spec-kit version priority: Resolution priority (lower = higher precedence, default 10) force: If True and extension is already installed, remo
(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
)
| 1466 | return manifest |
| 1467 | |
| 1468 | def install_from_zip( |
| 1469 | self, |
| 1470 | zip_path: Path, |
| 1471 | speckit_version: str, |
| 1472 | priority: int = 10, |
| 1473 | force: bool = False, |
| 1474 | ) -> ExtensionManifest: |
| 1475 | """Install extension from ZIP file. |
| 1476 | |
| 1477 | Args: |
| 1478 | zip_path: Path to extension ZIP file |
| 1479 | speckit_version: Current spec-kit version |
| 1480 | priority: Resolution priority (lower = higher precedence, default 10) |
| 1481 | force: If True and extension is already installed, remove it first |
| 1482 | before proceeding with installation |
| 1483 | |
| 1484 | Returns: |
| 1485 | Installed extension manifest |
| 1486 | |
| 1487 | Raises: |
| 1488 | ValidationError: If manifest is invalid or priority is invalid |
| 1489 | CompatibilityError: If extension is incompatible |
| 1490 | """ |
| 1491 | # Validate priority early |
| 1492 | if priority < 1: |
| 1493 | raise ValidationError("Priority must be a positive integer (1 or higher)") |
| 1494 | |
| 1495 | with tempfile.TemporaryDirectory() as tmpdir: |
| 1496 | temp_path = Path(tmpdir) |
| 1497 | |
| 1498 | # Extract ZIP safely (prevent Zip Slip attack) |
| 1499 | with zipfile.ZipFile(zip_path, "r") as zf: |
| 1500 | # Validate all paths first before extracting anything |
| 1501 | temp_path_resolved = temp_path.resolve() |
| 1502 | for member in zf.namelist(): |
| 1503 | member_path = (temp_path / member).resolve() |
| 1504 | # Use is_relative_to for safe path containment check |
| 1505 | try: |
| 1506 | member_path.relative_to(temp_path_resolved) |
| 1507 | except ValueError: |
| 1508 | raise ValidationError( |
| 1509 | f"Unsafe path in ZIP archive: {member} (potential path traversal)" |
| 1510 | ) |
| 1511 | # Only extract after all paths are validated |
| 1512 | zf.extractall(temp_path) |
| 1513 | |
| 1514 | # Find extension directory (may be nested) |
| 1515 | extension_dir = temp_path |
| 1516 | manifest_path = extension_dir / "extension.yml" |
| 1517 | |
| 1518 | # Check if manifest is in a subdirectory |
| 1519 | if not manifest_path.exists(): |
| 1520 | subdirs = [d for d in temp_path.iterdir() if d.is_dir()] |
| 1521 | if len(subdirs) == 1: |
| 1522 | extension_dir = subdirs[0] |
| 1523 | manifest_path = extension_dir / "extension.yml" |
| 1524 | |
| 1525 | if not manifest_path.exists(): |