Skip to content

Commit f87c8f5

Browse files
nadav-yclaude
andcommitted
feat(pack): switch --archive output from tar.gz to zip
Replace tarfile/gzip with zipfile (ZIP_DEFLATED) in both pack_bundle (apm format) and export_plugin_bundle (plugin format). The unpacker gains .zip support as the primary path; .tar.gz extraction is kept for backward compatibility with existing bundles. Aligns apm pack --archive with apm publish, which switched to .zip in #1695, making the whole toolchain consistent on a single archive format. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fa8a0ca commit f87c8f5

11 files changed

Lines changed: 79 additions & 38 deletions

src/apm_cli/bundle/packer.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Bundle packer -- creates self-contained APM bundles from the resolved dependency tree."""
22

33
import shutil
4-
import tarfile
4+
import zipfile
55
from dataclasses import dataclass, field
66
from pathlib import Path
77

@@ -41,7 +41,7 @@ def pack_bundle(
4141
target: Target filter -- ``"copilot"``, ``"claude"``, ``"all"``, a list of
4242
target strings (e.g. ``["claude", "vscode"]``), or *None*
4343
(auto-detect from apm.yml / project structure).
44-
archive: If *True*, produce a ``.tar.gz`` and remove the directory.
44+
archive: If *True*, produce a ``.zip`` and remove the directory.
4545
dry_run: If *True*, resolve the file list but write nothing to disk.
4646
force: On collision (plugin format), last writer wins.
4747
@@ -270,9 +270,12 @@ def pack_bundle(
270270

271271
# 10. Archive if requested
272272
if archive:
273-
archive_path = output_dir / f"{pkg_name}-{pkg_version}.tar.gz"
274-
with tarfile.open(archive_path, "w:gz") as tar:
275-
tar.add(bundle_dir, arcname=bundle_dir.name)
273+
archive_path = output_dir / f"{pkg_name}-{pkg_version}.zip"
274+
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
275+
for fp in sorted(bundle_dir.rglob("*")):
276+
if fp.is_symlink() or not fp.is_file():
277+
continue
278+
zf.write(fp, arcname=f"{bundle_dir.name}/{fp.relative_to(bundle_dir).as_posix()}")
276279
shutil.rmtree(bundle_dir)
277280
result.bundle_path = archive_path
278281

src/apm_cli/bundle/plugin_exporter.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import json
1212
import re
1313
import shutil
14-
import tarfile
14+
import zipfile
1515
from pathlib import Path, PurePosixPath
1616

1717
import yaml
@@ -420,7 +420,7 @@ def export_plugin_bundle(
420420
project_root: Root of the project containing ``apm.yml``.
421421
output_dir: Parent directory for the generated bundle.
422422
target: Unused for plugin format (reserved for future use).
423-
archive: If True, produce a ``.tar.gz`` and remove the directory.
423+
archive: If True, produce a ``.zip`` and remove the directory.
424424
dry_run: If True, resolve the file list without writing to disk.
425425
force: On collision, last writer wins instead of first.
426426
@@ -645,16 +645,13 @@ def export_plugin_bundle(
645645

646646
# 15. Archive if requested
647647
if archive:
648-
archive_path = output_dir / f"{bundle_dir.name}.tar.gz"
648+
archive_path = output_dir / f"{bundle_dir.name}.zip"
649649
ensure_path_within(archive_path, output_dir)
650-
with tarfile.open(archive_path, "w:gz") as tar:
651-
652-
def _tar_filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
653-
if info.issym() or info.islnk():
654-
return None # reject symlinks injected after write
655-
return info
656-
657-
tar.add(bundle_dir, arcname=bundle_dir.name, filter=_tar_filter)
650+
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
651+
for fp in sorted(bundle_dir.rglob("*")):
652+
if fp.is_symlink() or not fp.is_file():
653+
continue # reject symlinks injected after write
654+
zf.write(fp, arcname=f"{bundle_dir.name}/{fp.relative_to(bundle_dir).as_posix()}")
658655
shutil.rmtree(bundle_dir)
659656
result.bundle_path = archive_path
660657

src/apm_cli/bundle/unpacker.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import tarfile
66
import tempfile
7+
import zipfile
78
from dataclasses import dataclass, field
89
from pathlib import Path, PureWindowsPath
910

@@ -39,7 +40,7 @@ def unpack_bundle(
3940
file has the same name as a bundle file, the bundle file wins (overwrite).
4041
4142
Args:
42-
bundle_path: Path to a ``.tar.gz`` archive or an unpacked bundle directory.
43+
bundle_path: Path to a ``.zip`` (or legacy ``.tar.gz``) archive, or an unpacked bundle directory.
4344
output_dir: Target project directory to copy files into.
4445
skip_verify: If *True*, skip completeness verification against the lockfile.
4546
dry_run: If *True*, resolve the file list but write nothing to disk.
@@ -55,7 +56,44 @@ def unpack_bundle(
5556
"""
5657
# 1. If archive, extract to temp dir
5758
cleanup_temp = False
58-
if bundle_path.is_file() and bundle_path.name.endswith(".tar.gz"):
59+
if bundle_path.is_file() and bundle_path.name.endswith(".zip"):
60+
from ..config import get_apm_temp_dir
61+
62+
temp_dir = Path(tempfile.mkdtemp(prefix="apm-unpack-", dir=get_apm_temp_dir()))
63+
cleanup_temp = True
64+
try:
65+
with zipfile.ZipFile(bundle_path, "r") as zf:
66+
# Security: prevent path traversal and symlink entries
67+
for member in zf.infolist():
68+
name = member.filename
69+
if (
70+
name.startswith("/")
71+
or PureWindowsPath(name).drive
72+
or PureWindowsPath(name).is_absolute()
73+
):
74+
raise ValueError(f"Refusing to extract path-traversal entry: {name}")
75+
try:
76+
validate_path_segments(name, context="zip member")
77+
except PathTraversalError:
78+
raise ValueError(
79+
f"Refusing to extract path-traversal entry: {name}"
80+
) from None
81+
# Detect Unix symlinks stored in zip external_attr
82+
if (member.external_attr >> 16) & 0o170000 == 0o120000:
83+
raise ValueError(f"Refusing to extract symlink: {name}")
84+
zf.extractall(temp_dir) # noqa: S202
85+
except Exception:
86+
shutil.rmtree(temp_dir, ignore_errors=True)
87+
raise
88+
89+
# Locate inner directory (the archive wraps a single top-level dir)
90+
children = list(temp_dir.iterdir())
91+
if len(children) == 1 and children[0].is_dir(): # noqa: SIM108
92+
source_dir = children[0]
93+
else:
94+
source_dir = temp_dir
95+
elif bundle_path.is_file() and bundle_path.name.endswith(".tar.gz"):
96+
# Legacy .tar.gz support (backward compat)
5997
from ..config import get_apm_temp_dir
6098

6199
temp_dir = Path(tempfile.mkdtemp(prefix="apm-unpack-", dir=get_apm_temp_dir()))

src/apm_cli/commands/pack.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
2727
Reads apm.yml to decide what to produce:
2828
29-
dependencies: block -> bundle (directory or .tar.gz)
29+
dependencies: block -> bundle (directory or .zip)
3030
marketplace: block -> selected marketplace artifacts
3131
target: / targets: -> ecosystem-specific plugin.json (claude/copilot)
3232
both blocks present -> bundle plus selected marketplace artifacts
@@ -168,7 +168,7 @@ def _parse_marketplace_filter(
168168
"--archive",
169169
is_flag=True,
170170
default=False,
171-
help="Produce a .tar.gz archive instead of a directory.",
171+
help="Produce a .zip archive instead of a directory.",
172172
)
173173
@click.option(
174174
"-o",

tests/integration/test_pack_unpack_e2e.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def test_full_round_trip(self, apm_command, temp_project, tmp_path):
7878
assert result.returncode == 0, f"pack failed: {result.stderr}"
7979

8080
build_dir = temp_project / "build"
81-
archives = list(build_dir.glob("*.tar.gz"))
81+
archives = list(build_dir.glob("*.zip"))
8282
assert len(archives) == 1, f"Expected 1 archive, found {archives}"
8383

8484
# 3. Install the bundle in a clean directory. Seed the copilot

tests/integration/test_wave3_marketplace_coverage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ def test_pack_with_output_directory(self, runner: CliRunner, tmp_path: Path) ->
297297
assert result.exit_code == 0, result.output
298298

299299
def test_pack_with_archive_flag(self, runner: CliRunner, tmp_path: Path) -> None:
300-
"""Test pack with --archive to create .tar.gz."""
300+
"""Test pack with --archive to create .zip."""
301301
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
302302
_write_minimal_project(Path.cwd())
303303
(Path.cwd() / "apm.yml").write_text(

tests/integration/test_wave6_init_pack_coverage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ def test_pack_format_apm(self, runner, tmp_path, monkeypatch):
494494
assert result.exit_code == 0, result.output
495495

496496
def test_pack_archive(self, runner, tmp_path, monkeypatch):
497-
"""--archive flag produces a .tar.gz artifact."""
497+
"""--archive flag produces a .zip artifact."""
498498
monkeypatch.chdir(tmp_path)
499499
clear_apm_yml_cache()
500500
_make_skill_project(tmp_path)

tests/unit/commands/test_pack_cli_surface.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def test_live_plugin_format_progress_message(self) -> None:
216216
def test_live_apm_format_no_plugin_message(self) -> None:
217217
"""'apm' format does NOT emit the plugin-ready progress message."""
218218
logger = _RecordingLogger()
219-
result = _pack_result(files=["bundle.tar.gz"])
219+
result = _pack_result(files=["bundle.zip"])
220220
_render_bundle_result(logger, result, "apm", None, False)
221221
assert not any("Plugin bundle ready" in p for p in logger.progresses)
222222

@@ -539,7 +539,7 @@ def test_json_output_envelope_shape(self, tmp_path: Path, monkeypatch) -> None:
539539
class TestUnpackCmd:
540540
def test_unpack_deprecation_warning(self, tmp_path: Path) -> None:
541541
"""unpack always emits a deprecation warning."""
542-
bundle = tmp_path / "bundle.tar.gz"
542+
bundle = tmp_path / "bundle.zip"
543543
bundle.write_bytes(b"fake")
544544
result = CliRunner().invoke(
545545
unpack_cmd,
@@ -551,7 +551,7 @@ def test_unpack_nonexistent_bundle(self, tmp_path: Path) -> None:
551551
"""Passing a non-existent path exits non-zero."""
552552
result = CliRunner().invoke(
553553
unpack_cmd,
554-
[str(tmp_path / "no-such-bundle.tar.gz"), "--dry-run"],
554+
[str(tmp_path / "no-such-bundle.zip"), "--dry-run"],
555555
)
556556
assert result.exit_code != 0
557557

tests/unit/commands/test_pack_phase3.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def test_live_plugin_format_progress_message(self) -> None:
216216
def test_live_apm_format_no_plugin_message(self) -> None:
217217
"""'apm' format does NOT emit the plugin-ready progress message."""
218218
logger = _RecordingLogger()
219-
result = _pack_result(files=["bundle.tar.gz"])
219+
result = _pack_result(files=["bundle.zip"])
220220
_render_bundle_result(logger, result, "apm", None, False)
221221
assert not any("Plugin bundle ready" in p for p in logger.progresses)
222222

@@ -539,7 +539,7 @@ def test_json_output_envelope_shape(self, tmp_path: Path, monkeypatch) -> None:
539539
class TestUnpackCmd:
540540
def test_unpack_deprecation_warning(self, tmp_path: Path) -> None:
541541
"""unpack always emits a deprecation warning."""
542-
bundle = tmp_path / "bundle.tar.gz"
542+
bundle = tmp_path / "bundle.zip"
543543
bundle.write_bytes(b"fake")
544544
result = CliRunner().invoke(
545545
unpack_cmd,
@@ -551,7 +551,7 @@ def test_unpack_nonexistent_bundle(self, tmp_path: Path) -> None:
551551
"""Passing a non-existent path exits non-zero."""
552552
result = CliRunner().invoke(
553553
unpack_cmd,
554-
[str(tmp_path / "no-such-bundle.tar.gz"), "--dry-run"],
554+
[str(tmp_path / "no-such-bundle.zip"), "--dry-run"],
555555
)
556556
assert result.exit_code != 0
557557

tests/unit/test_packer.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Unit tests for apm_cli.bundle.packer."""
22

33
import os
4-
import tarfile
4+
import zipfile
55
from pathlib import Path
66
from unittest.mock import patch
77

@@ -210,13 +210,13 @@ def test_pack_archive(self, tmp_path):
210210

211211
result = pack_bundle(project, out, archive=True)
212212

213-
assert result.bundle_path.name == "test-pkg-1.0.0.tar.gz"
213+
assert result.bundle_path.name == "test-pkg-1.0.0.zip"
214214
assert result.bundle_path.exists()
215215
# The directory should be cleaned up
216216
assert not (out / "test-pkg-1.0.0").exists()
217217
# Archive is valid
218-
with tarfile.open(result.bundle_path, "r:gz") as tar:
219-
names = tar.getnames()
218+
with zipfile.ZipFile(result.bundle_path, "r") as zf:
219+
names = zf.namelist()
220220
assert any("a.md" in n for n in names)
221221

222222
def test_pack_custom_output_dir(self, tmp_path):

0 commit comments

Comments
 (0)