-
Notifications
You must be signed in to change notification settings - Fork 294
feat: add apm pack and apm unpack commands
#218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
3ab0c1c
feat: add apm pack and apm unpack commands
danielmeppiel 864f430
Initial plan
Copilot f5ba7b7
fix: address unresolved PR review comments on pack/unpack
Copilot 479f6e8
Merge pull request #219 from microsoft/copilot/sub-pr-218
danielmeppiel b47170f
Merge branch 'main' into feat/pack-unpack
danielmeppiel 6358c24
fix: add 'agents' target alias and -t short flag to pack command
danielmeppiel 7424c31
fix: remove non-existent 'agents' target alias from pack command
danielmeppiel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| # Pack & Unpack | ||
|
|
||
| Create self-contained bundles from installed dependencies and apply them to other projects. | ||
|
|
||
| ## `apm pack` | ||
|
|
||
| Collects all deployed files from the resolved dependency tree into a portable bundle. | ||
|
|
||
| ```bash | ||
| apm pack # bundle into ./build/<name>-<version>/ | ||
| apm pack --archive # produce a .tar.gz | ||
| apm pack --target vscode # only .github/ files | ||
| apm pack --dry-run # list files without writing | ||
| apm pack -o dist/ # custom output directory | ||
| ``` | ||
|
|
||
| ### Options | ||
|
|
||
| | Flag | Default | Description | | ||
| |------|---------|-------------| | ||
| | `--format` | `apm` | Bundle format (`apm` or `plugin`) | | ||
| | `--target` | auto-detect | Filter: `vscode`, `claude`, or `all` | | ||
| | `--archive` | off | Produce `.tar.gz` and remove the directory | | ||
| | `-o, --output` | `./build` | Output directory | | ||
| | `--dry-run` | off | Show what would be packed without writing | | ||
|
|
||
| ### What goes into the bundle | ||
|
|
||
| - Every file listed in `deployed_files` across all locked dependencies, filtered by the effective target. | ||
| - An enriched copy of `apm.lock` with a `pack:` metadata section (format, target, timestamp). The original lockfile is never modified. | ||
|
|
||
| ### Target filtering | ||
|
|
||
| | Target | Includes paths starting with | | ||
| |--------|------------------------------| | ||
| | `vscode` | `.github/` | | ||
| | `claude` | `.claude/` | | ||
| | `all` | both | | ||
|
|
||
| If no target is specified, it's auto-detected from `apm.yml` or project structure (same logic as `apm compile`). | ||
|
|
||
| ## `apm unpack` | ||
|
|
||
| Extracts a bundle into the current project directory. | ||
|
|
||
| ```bash | ||
| apm unpack ./build/my-pkg-1.0.0.tar.gz # from archive | ||
| apm unpack ./build/my-pkg-1.0.0/ # from directory | ||
| apm unpack bundle.tar.gz -o ./target/ # custom output | ||
| apm unpack bundle.tar.gz --skip-verify # skip completeness check | ||
| apm unpack bundle.tar.gz --dry-run # list files without writing | ||
| ``` | ||
|
|
||
| ### Options | ||
|
|
||
| | Flag | Default | Description | | ||
| |------|---------|-------------| | ||
| | `-o, --output` | `.` | Target directory | | ||
| | `--skip-verify` | off | Skip bundle completeness check | | ||
| | `--dry-run` | off | Show what would be unpacked without writing | | ||
|
|
||
| ### Verification | ||
|
|
||
| By default, `unpack` verifies that every file listed in the lockfile's `deployed_files` exists in the bundle before extracting. Use `--skip-verify` to bypass this. | ||
|
|
||
| ### Merge semantics (v1) | ||
|
|
||
| - **Additive-only**: files from the bundle are copied into the target directory. Existing local files not in the bundle are untouched. | ||
| - **Overwrites**: if a local file has the same path as a bundle file, the bundle file wins. | ||
| - `apm.lock` from the bundle is metadata only — it is **not** copied to the output directory. | ||
|
|
||
| ## Enriched lockfile | ||
|
|
||
| The `apm.lock` inside a bundle contains an additional `pack:` section: | ||
|
|
||
| ```yaml | ||
| pack: | ||
| format: apm | ||
| target: vscode | ||
| packed_at: '2026-03-09T12:00:00+00:00' | ||
| lockfile_version: '1' | ||
| generated_at: ... | ||
| dependencies: | ||
| - repo_url: owner/repo | ||
| ... | ||
| ``` | ||
|
|
||
| This section is only present in the bundle copy — the project's lockfile is never modified. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| """Bundle creation and consumption for APM packages.""" | ||
|
|
||
| from .packer import pack_bundle, PackResult | ||
| from .unpacker import unpack_bundle, UnpackResult | ||
|
|
||
| __all__ = ["pack_bundle", "PackResult", "unpack_bundle", "UnpackResult"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| """Lockfile enrichment for pack-time metadata.""" | ||
|
|
||
| from datetime import datetime, timezone | ||
|
|
||
| from ..deps.lockfile import LockFile | ||
|
|
||
|
|
||
| def enrich_lockfile_for_pack( | ||
| lockfile: LockFile, | ||
| fmt: str, | ||
| target: str, | ||
| ) -> str: | ||
| """Create an enriched copy of the lockfile YAML with a ``pack:`` section. | ||
|
|
||
| Does NOT mutate the original *lockfile* object — serialises a copy and | ||
| prepends the pack metadata. | ||
|
|
||
| Args: | ||
| lockfile: The resolved lockfile to enrich. | ||
| fmt: Bundle format (``"apm"`` or ``"plugin"``). | ||
| target: Effective target used for packing (``"vscode"``, ``"claude"``, ``"all"``). | ||
|
|
||
| Returns: | ||
| A YAML string with the ``pack:`` block followed by the original | ||
| lockfile content. | ||
| """ | ||
| import yaml | ||
|
|
||
| pack_section = yaml.dump( | ||
| { | ||
| "pack": { | ||
| "format": fmt, | ||
| "target": target, | ||
| "packed_at": datetime.now(timezone.utc).isoformat(), | ||
| } | ||
| }, | ||
| default_flow_style=False, | ||
| sort_keys=False, | ||
| ) | ||
|
|
||
| return pack_section + lockfile.to_yaml() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| """Bundle packer — creates self-contained APM bundles from the resolved dependency tree.""" | ||
|
|
||
| import shutil | ||
| import tarfile | ||
| from dataclasses import dataclass, field | ||
| from pathlib import Path | ||
| from typing import List, Optional | ||
|
|
||
| from ..deps.lockfile import LockFile | ||
| from ..models.apm_package import APMPackage | ||
| from ..core.target_detection import detect_target | ||
| from .lockfile_enrichment import enrich_lockfile_for_pack | ||
|
|
||
|
|
||
| # Target prefix mapping | ||
| _TARGET_PREFIXES = { | ||
| "vscode": [".github/"], | ||
| "claude": [".claude/"], | ||
| "all": [".github/", ".claude/"], | ||
| } | ||
|
|
||
|
|
||
| @dataclass | ||
| class PackResult: | ||
| """Result of a pack operation.""" | ||
|
|
||
| bundle_path: Path | ||
| files: List[str] = field(default_factory=list) | ||
| lockfile_enriched: bool = False | ||
|
|
||
|
|
||
| def _filter_files_by_target(deployed_files: List[str], target: str) -> List[str]: | ||
| """Filter deployed file paths by target prefix.""" | ||
| prefixes = _TARGET_PREFIXES.get(target, _TARGET_PREFIXES["all"]) | ||
| return [f for f in deployed_files if any(f.startswith(p) for p in prefixes)] | ||
|
|
||
|
|
||
| def pack_bundle( | ||
| project_root: Path, | ||
| output_dir: Path, | ||
| fmt: str = "apm", | ||
| target: Optional[str] = None, | ||
| archive: bool = False, | ||
| dry_run: bool = False, | ||
| ) -> PackResult: | ||
| """Create a self-contained bundle from installed APM dependencies. | ||
|
|
||
| Args: | ||
| project_root: Root of the project containing ``apm.lock`` and ``apm.yml``. | ||
| output_dir: Directory where the bundle will be created. | ||
| fmt: Bundle format — ``"apm"`` (default) or ``"plugin"``. | ||
| target: Target filter — ``"vscode"``, ``"claude"``, ``"all"``, or *None* | ||
| (auto-detect from apm.yml / project structure). | ||
| archive: If *True*, produce a ``.tar.gz`` and remove the directory. | ||
| dry_run: If *True*, resolve the file list but write nothing to disk. | ||
|
|
||
| Returns: | ||
| :class:`PackResult` describing what was (or would be) produced. | ||
|
|
||
| Raises: | ||
| FileNotFoundError: If ``apm.lock`` is missing. | ||
| ValueError: If deployed files referenced in the lockfile are missing on disk. | ||
| """ | ||
| # 1. Read lockfile | ||
| lockfile_path = project_root / "apm.lock" | ||
| lockfile = LockFile.read(lockfile_path) | ||
| if lockfile is None: | ||
| raise FileNotFoundError( | ||
| "apm.lock not found — run 'apm install' first to resolve dependencies." | ||
| ) | ||
|
|
||
| # 2. Read apm.yml for name / version / config target | ||
| apm_yml_path = project_root / "apm.yml" | ||
| try: | ||
| package = APMPackage.from_apm_yml(apm_yml_path) | ||
| pkg_name = package.name | ||
| pkg_version = package.version or "0.0.0" | ||
| config_target = package.target | ||
| except (FileNotFoundError, ValueError): | ||
| pkg_name = project_root.resolve().name | ||
| pkg_version = "0.0.0" | ||
| config_target = None | ||
|
|
||
| # 3. Resolve effective target | ||
| effective_target, _reason = detect_target( | ||
| project_root, | ||
| explicit_target=target, | ||
| config_target=config_target, | ||
| ) | ||
| # For packing purposes, "minimal" means nothing to pack — treat as "all" | ||
| if effective_target == "minimal": | ||
| effective_target = "all" | ||
|
|
||
| # 4. Collect deployed_files from all dependencies, filtered by target | ||
| all_deployed: List[str] = [] | ||
| for dep in lockfile.get_all_dependencies(): | ||
| all_deployed.extend(dep.deployed_files) | ||
|
|
||
| filtered_files = _filter_files_by_target(all_deployed, effective_target) | ||
| # Deduplicate while preserving order | ||
| seen = set() | ||
| unique_files: List[str] = [] | ||
| for f in filtered_files: | ||
| if f not in seen: | ||
| seen.add(f) | ||
| unique_files.append(f) | ||
|
|
||
| # 5. Verify each path is safe (no traversal) and exists on disk | ||
| project_root_resolved = project_root.resolve() | ||
| missing: List[str] = [] | ||
| for rel_path in unique_files: | ||
| # Guard against absolute paths or path-traversal entries in deployed_files | ||
| p = Path(rel_path) | ||
| if p.is_absolute() or ".." in p.parts: | ||
| raise ValueError( | ||
| f"Refusing to pack unsafe path from lockfile: {rel_path!r}" | ||
| ) | ||
| abs_path = project_root / rel_path | ||
| if not abs_path.resolve().is_relative_to(project_root_resolved): | ||
| raise ValueError( | ||
| f"Refusing to pack path that escapes project root: {rel_path!r}" | ||
| ) | ||
| # deployed_files may reference directories (ending with /) | ||
| if not abs_path.exists(): | ||
| missing.append(rel_path) | ||
| if missing: | ||
| raise ValueError( | ||
| f"The following deployed files are missing on disk — " | ||
| f"run 'apm install' to restore them:\n" | ||
| + "\n".join(f" - {m}" for m in missing) | ||
| ) | ||
|
|
||
| # Dry-run: return file list without writing anything | ||
| if dry_run: | ||
| bundle_dir = output_dir / f"{pkg_name}-{pkg_version}" | ||
| return PackResult( | ||
| bundle_path=bundle_dir, | ||
| files=unique_files, | ||
| lockfile_enriched=True, | ||
| ) | ||
|
|
||
| # 6. Build output directory | ||
| bundle_dir = output_dir / f"{pkg_name}-{pkg_version}" | ||
| bundle_dir.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| # 7. Copy files preserving directory structure | ||
| for rel_path in unique_files: | ||
| src = project_root / rel_path | ||
| dest = bundle_dir / rel_path | ||
| if src.is_dir(): | ||
| shutil.copytree(src, dest, dirs_exist_ok=True) | ||
| else: | ||
| dest.parent.mkdir(parents=True, exist_ok=True) | ||
| shutil.copy2(src, dest) | ||
|
|
||
| # 8. Enrich lockfile copy and write to bundle | ||
| enriched_yaml = enrich_lockfile_for_pack(lockfile, fmt, effective_target) | ||
| (bundle_dir / "apm.lock").write_text(enriched_yaml, encoding="utf-8") | ||
|
|
||
| result = PackResult( | ||
| bundle_path=bundle_dir, | ||
| files=unique_files, | ||
| lockfile_enriched=True, | ||
| ) | ||
|
|
||
| # 10. Archive if requested | ||
| if archive: | ||
| archive_path = output_dir / f"{pkg_name}-{pkg_version}.tar.gz" | ||
| with tarfile.open(archive_path, "w:gz") as tar: | ||
| tar.add(bundle_dir, arcname=bundle_dir.name) | ||
| shutil.rmtree(bundle_dir) | ||
| result.bundle_path = archive_path | ||
|
|
||
| return result |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.