Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,81 @@ apm prune --dry-run
- Removes deployed integration files (prompts, agents, hooks, etc.) for pruned packages using the `deployed_files` manifest in `apm.lock`
- Updates `apm.lock` to reflect the pruned state

### `apm pack` - Create a portable bundle

Create a self-contained bundle from installed APM dependencies using the `deployed_files` recorded in `apm.lock` as the source of truth.

```bash
apm pack [OPTIONS]
```

**Options:**
- `-o, --output TEXT` - Output directory (default: `./build/`)
- `-t, --target [vscode|claude|all]` - Filter files by target. Auto-detects from `apm.yml` if not specified
- `--archive` - Produce a `.tar.gz` archive instead of a directory
- `--dry-run` - List files that would be packed without writing anything
- `--format [apm|plugin]` - Bundle format (default: `apm`)

**Examples:**
```bash
# Pack to ./build/<name>-<version>/
apm pack

# Pack as a .tar.gz archive
apm pack --archive

# Pack only VS Code / Copilot files
apm pack --target vscode

# Preview what would be packed
apm pack --dry-run

# Custom output directory
apm pack -o dist/
```

**Behavior:**
- Reads `apm.lock` to enumerate all `deployed_files` from installed dependencies
- Copies files preserving directory structure
- Writes an enriched `apm.lock` inside the bundle with a `pack:` metadata section
- The project's own `apm.lock` is never modified

### `apm unpack` - Extract a bundle

Extract an APM bundle into the current project with optional completeness verification.

```bash
apm unpack BUNDLE_PATH [OPTIONS]
```

**Arguments:**
- `BUNDLE_PATH` - Path to a `.tar.gz` archive or an unpacked bundle directory

**Options:**
- `--output TEXT` - Target project directory (default: current directory)
- `--skip-verify` - Skip completeness verification against the bundle lockfile
- `--dry-run` - Show what would be extracted without writing anything

**Examples:**
```bash
# Unpack an archive into the current directory
apm unpack ./build/my-pkg-1.0.0.tar.gz

# Unpack into a specific directory
apm unpack bundle.tar.gz --output /path/to/project

# Skip verification (useful for partial bundles)
apm unpack bundle.tar.gz --skip-verify

# Preview what would be extracted
apm unpack bundle.tar.gz --dry-run
```

**Behavior:**
- Additive-only: only writes files listed in the bundle's `apm.lock`; never deletes existing files
- If a local file has the same path as a bundle file, the bundle file wins (overwrite)
- Verification checks that all `deployed_files` from the bundle lockfile are present in the bundle

### `apm update` - Update APM to the latest version

Update the APM CLI to the latest version available on GitHub releases.
Expand Down
88 changes: 88 additions & 0 deletions docs/pack-unpack.md
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`
Comment thread
danielmeppiel marked this conversation as resolved.

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.
6 changes: 6 additions & 0 deletions src/apm_cli/bundle/__init__.py
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"]
41 changes: 41 additions & 0 deletions src/apm_cli/bundle/lockfile_enrichment.py
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()
174 changes: 174 additions & 0 deletions src/apm_cli/bundle/packer.py
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
Loading