mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
feat: support tar archives for installs (#3874)
* feat: support tar archives for installs Add secure .tar.gz and .tgz parity with ZIP installation for extensions, presets, and workflows, including full workflow package preservation. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd * chore: clean rebased archive imports Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd * fix: preserve hardened archive install behavior Keep malformed ZIP diagnostics, filesystem-independent manifest selection, and reserved workflow overlays consistent after adding generic archive support. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd * fix: extract staged workflow archives by descriptor Avoid reopening a held staging path on Windows while retaining authoritative-inode archive validation. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd * fix: extract catalog archives from verified bytes Use the already bounded and SHA-verified response bytes directly so Windows file-sharing semantics cannot affect archive detection. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd * fix: address archive install review feedback Preserve forced preset reinstalls, sniff suffixless workflow archives without weakening YAML limits, and restore prior workflow packages before failed-install cleanup. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd --------- Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd
This commit is contained in:
co-authored by
Copilot App
parent
6bf51e728a
commit
a3e183d069
@@ -27,11 +27,14 @@ from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
MAX_JSON_CATALOG_BYTES,
|
||||
build_safe_download_path,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
read_response_limited,
|
||||
safe_extract_zip,
|
||||
safe_extract_archive,
|
||||
)
|
||||
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||
from .._init_options import (
|
||||
@@ -3534,17 +3537,17 @@ class PresetManager:
|
||||
return
|
||||
_materialize_constitution_template(self.project_root, memory_constitution)
|
||||
|
||||
def install_from_zip(
|
||||
def install_from_archive(
|
||||
self,
|
||||
zip_path: Path,
|
||||
archive_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> PresetManifest:
|
||||
"""Install preset from ZIP file.
|
||||
"""Install a preset from a supported archive.
|
||||
|
||||
Args:
|
||||
zip_path: Path to preset ZIP file
|
||||
archive_path: Path to a .zip, .tar.gz, or .tgz archive
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
force: If True and the preset is already installed, remove it first
|
||||
@@ -3563,7 +3566,11 @@ class PresetManager:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
safe_extract_zip(zip_path, temp_path, error_type=PresetValidationError)
|
||||
safe_extract_archive(
|
||||
archive_path,
|
||||
temp_path,
|
||||
error_type=PresetValidationError,
|
||||
)
|
||||
|
||||
pack_dir = temp_path
|
||||
manifest_path = pack_dir / "preset.yml"
|
||||
@@ -3576,11 +3583,26 @@ class PresetManager:
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise PresetValidationError(
|
||||
"No preset.yml found in ZIP file"
|
||||
"No preset.yml found in archive"
|
||||
)
|
||||
|
||||
return self.install_from_directory(pack_dir, speckit_version, priority, force=force)
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> PresetManifest:
|
||||
"""Backward-compatible wrapper for archive installation."""
|
||||
return self.install_from_archive(
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority,
|
||||
force=force,
|
||||
)
|
||||
|
||||
def remove(self, pack_id: str) -> bool:
|
||||
"""Remove an installed preset.
|
||||
|
||||
@@ -4605,14 +4627,14 @@ class PresetCatalog:
|
||||
def download_pack(
|
||||
self, pack_id: str, target_dir: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Download preset ZIP from catalog.
|
||||
"""Download a preset archive from a catalog.
|
||||
|
||||
Args:
|
||||
pack_id: ID of the preset to download
|
||||
target_dir: Directory to save ZIP file (defaults to cache directory)
|
||||
target_dir: Directory to save the archive
|
||||
|
||||
Returns:
|
||||
Path to downloaded ZIP file
|
||||
Path to the downloaded archive
|
||||
|
||||
Raises:
|
||||
PresetError: If pack not found or download fails
|
||||
@@ -4681,42 +4703,86 @@ class PresetCatalog:
|
||||
target_dir = self.cache_dir / "downloads"
|
||||
target_dir = Path(target_dir)
|
||||
version = pack_info.get("version", "unknown")
|
||||
zip_path = build_safe_download_path(
|
||||
declared_format = archive_format_from_name(download_url)
|
||||
build_safe_download_path(
|
||||
target_dir,
|
||||
pack_id,
|
||||
version,
|
||||
error_type=PresetError,
|
||||
label="preset",
|
||||
suffix=archive_suffix(declared_format or "tar.gz"),
|
||||
)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_download_url = download_url
|
||||
extra_headers = None
|
||||
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
|
||||
if resolved_download_url:
|
||||
download_url = resolved_download_url
|
||||
extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
staging_path: Path | None = None
|
||||
try:
|
||||
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
|
||||
zip_data = read_response_limited(
|
||||
archive_data = read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset '{pack_id}' download",
|
||||
)
|
||||
final_url = (
|
||||
response.geturl()
|
||||
if hasattr(response, "geturl")
|
||||
else download_url
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
|
||||
verify_archive_sha256(
|
||||
zip_data, pack_info.get("sha256"), pack_id, PresetError
|
||||
archive_data, pack_info.get("sha256"), pack_id, PresetError
|
||||
)
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="preset-download-",
|
||||
suffix=".archive",
|
||||
dir=target_dir,
|
||||
delete=False,
|
||||
) as staging_file:
|
||||
staging_path = Path(staging_file.name)
|
||||
staging_file.write(archive_data)
|
||||
archive_format = detect_archive_format(
|
||||
staging_path,
|
||||
source_name=(
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else original_download_url
|
||||
),
|
||||
content_type=content_type,
|
||||
error_type=PresetError,
|
||||
)
|
||||
archive_path = build_safe_download_path(
|
||||
target_dir,
|
||||
pack_id,
|
||||
version,
|
||||
error_type=PresetError,
|
||||
label="preset",
|
||||
suffix=archive_suffix(archive_format),
|
||||
)
|
||||
os.replace(staging_path, archive_path)
|
||||
staging_path = None
|
||||
return archive_path
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise PresetError(
|
||||
f"Failed to download preset from {download_url}: {e}"
|
||||
)
|
||||
except IOError as e:
|
||||
raise PresetError(f"Failed to save preset ZIP: {e}")
|
||||
raise PresetError(f"Failed to save preset archive: {e}")
|
||||
finally:
|
||||
if staging_path is not None:
|
||||
staging_path.unlink(missing_ok=True)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear all catalog cache files, including per-URL hashed caches."""
|
||||
|
||||
@@ -17,6 +17,9 @@ from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
is_safe_download_redirect,
|
||||
read_response_limited,
|
||||
@@ -75,7 +78,11 @@ def preset_list():
|
||||
@preset_app.command("add")
|
||||
def preset_add(
|
||||
preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
|
||||
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
|
||||
from_url: str = typer.Option(
|
||||
None,
|
||||
"--from",
|
||||
help="Install from a .zip, .tar.gz, or .tgz URL",
|
||||
),
|
||||
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
|
||||
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||
):
|
||||
@@ -142,7 +149,7 @@ def preset_add(
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
archive_path = Path(tmpdir) / "preset.archive"
|
||||
try:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
@@ -170,13 +177,33 @@ def preset_add(
|
||||
"or HTTP for localhost (127.0.0.1, ::1)."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
zip_path.write_bytes(
|
||||
read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset {from_url}",
|
||||
)
|
||||
archive_data = read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset {from_url}",
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
archive_path.write_bytes(archive_data)
|
||||
format_source = (
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else from_url
|
||||
)
|
||||
archive_format = detect_archive_format(
|
||||
archive_path,
|
||||
source_name=format_source,
|
||||
content_type=content_type,
|
||||
error_type=PresetError,
|
||||
)
|
||||
detected_path = archive_path.with_suffix(
|
||||
archive_suffix(archive_format)
|
||||
)
|
||||
os.replace(archive_path, detected_path)
|
||||
archive_path = detected_path
|
||||
except (urllib.error.URLError, PresetError) as e:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to download: "
|
||||
@@ -184,7 +211,11 @@ def preset_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority,
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
|
||||
@@ -227,12 +258,16 @@ def preset_add(
|
||||
console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...")
|
||||
|
||||
try:
|
||||
zip_path = catalog.download_pack(preset_id)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
archive_path = catalog.download_pack(preset_id)
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority,
|
||||
)
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
finally:
|
||||
if 'zip_path' in locals() and zip_path.exists():
|
||||
zip_path.unlink(missing_ok=True)
|
||||
if 'archive_path' in locals() and archive_path.exists():
|
||||
archive_path.unlink(missing_ok=True)
|
||||
else:
|
||||
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
|
||||
raise typer.Exit(1)
|
||||
|
||||
Reference in New Issue
Block a user