apm-cli 0.7.5__tar.gz → 0.7.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. {apm_cli-0.7.5/src/apm_cli.egg-info → apm_cli-0.7.7}/PKG-INFO +5 -3
  2. {apm_cli-0.7.5 → apm_cli-0.7.7}/README.md +4 -2
  3. {apm_cli-0.7.5 → apm_cli-0.7.7}/pyproject.toml +1 -1
  4. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/codex.py +9 -0
  5. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/copilot.py +13 -0
  6. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/vscode.py +12 -0
  7. apm_cli-0.7.7/src/apm_cli/bundle/__init__.py +6 -0
  8. apm_cli-0.7.7/src/apm_cli/bundle/lockfile_enrichment.py +41 -0
  9. apm_cli-0.7.7/src/apm_cli/bundle/packer.py +175 -0
  10. apm_cli-0.7.7/src/apm_cli/bundle/unpacker.py +165 -0
  11. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/cli.py +30 -786
  12. apm_cli-0.7.7/src/apm_cli/commands/pack.py +110 -0
  13. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/target_detection.py +17 -7
  14. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/plugin_parser.py +193 -12
  15. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/__init__.py +2 -0
  16. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/agent_integrator.py +4 -4
  17. apm_cli-0.7.7/src/apm_cli/integration/mcp_integrator.py +1036 -0
  18. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/skill_integrator.py +31 -4
  19. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/models/plugin.py +7 -1
  20. {apm_cli-0.7.5 → apm_cli-0.7.7/src/apm_cli.egg-info}/PKG-INFO +5 -3
  21. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/SOURCES.txt +6 -0
  22. {apm_cli-0.7.5 → apm_cli-0.7.7}/AUTHORS +0 -0
  23. {apm_cli-0.7.5 → apm_cli-0.7.7}/LICENSE +0 -0
  24. {apm_cli-0.7.5 → apm_cli-0.7.7}/setup.cfg +0 -0
  25. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/__init__.py +0 -0
  26. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/__init__.py +0 -0
  27. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/__init__.py +0 -0
  28. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/base.py +0 -0
  29. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/package_manager/__init__.py +0 -0
  30. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/package_manager/base.py +0 -0
  31. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/package_manager/default_manager.py +0 -0
  32. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/commands/__init__.py +0 -0
  33. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/commands/deps.py +0 -0
  34. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/__init__.py +0 -0
  35. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/agents_compiler.py +0 -0
  36. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/claude_formatter.py +0 -0
  37. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/constants.py +0 -0
  38. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/constitution.py +0 -0
  39. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/constitution_block.py +0 -0
  40. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/context_optimizer.py +0 -0
  41. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/distributed_compiler.py +0 -0
  42. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/injector.py +0 -0
  43. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/link_resolver.py +0 -0
  44. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/template_builder.py +0 -0
  45. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/config.py +0 -0
  46. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/__init__.py +0 -0
  47. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/conflict_detector.py +0 -0
  48. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/docker_args.py +0 -0
  49. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/operations.py +0 -0
  50. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/safe_installer.py +0 -0
  51. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/script_runner.py +0 -0
  52. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/token_manager.py +0 -0
  53. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/__init__.py +0 -0
  54. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/aggregator.py +0 -0
  55. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/apm_resolver.py +0 -0
  56. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/collection_parser.py +0 -0
  57. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/dependency_graph.py +0 -0
  58. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/github_downloader.py +0 -0
  59. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/lockfile.py +0 -0
  60. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/package_validator.py +0 -0
  61. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/verifier.py +0 -0
  62. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/factory.py +0 -0
  63. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/base_integrator.py +0 -0
  64. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/command_integrator.py +0 -0
  65. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/hook_integrator.py +0 -0
  66. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/instruction_integrator.py +0 -0
  67. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/prompt_integrator.py +0 -0
  68. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/skill_transformer.py +0 -0
  69. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/utils.py +0 -0
  70. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/models/__init__.py +0 -0
  71. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/models/apm_package.py +0 -0
  72. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/output/__init__.py +0 -0
  73. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/output/formatters.py +0 -0
  74. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/output/models.py +0 -0
  75. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/output/script_formatters.py +0 -0
  76. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/primitives/__init__.py +0 -0
  77. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/primitives/discovery.py +0 -0
  78. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/primitives/models.py +0 -0
  79. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/primitives/parser.py +0 -0
  80. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/registry/__init__.py +0 -0
  81. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/registry/client.py +0 -0
  82. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/registry/integration.py +0 -0
  83. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/registry/operations.py +0 -0
  84. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/__init__.py +0 -0
  85. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/base.py +0 -0
  86. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/codex_runtime.py +0 -0
  87. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/copilot_runtime.py +0 -0
  88. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/factory.py +0 -0
  89. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/llm_runtime.py +0 -0
  90. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/manager.py +0 -0
  91. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/__init__.py +0 -0
  92. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/console.py +0 -0
  93. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/github_host.py +0 -0
  94. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/helpers.py +0 -0
  95. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/version_checker.py +0 -0
  96. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/version.py +0 -0
  97. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/workflow/__init__.py +0 -0
  98. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/workflow/discovery.py +0 -0
  99. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/workflow/parser.py +0 -0
  100. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/workflow/runner.py +0 -0
  101. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/dependency_links.txt +0 -0
  102. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/entry_points.txt +0 -0
  103. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/requires.txt +0 -0
  104. {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/top_level.txt +0 -0
  105. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_apm_package_models.py +0 -0
  106. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_apm_resolver.py +0 -0
  107. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_codex_docker_args_fix.py +0 -0
  108. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_codex_empty_string_and_defaults.py +0 -0
  109. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_collision_integration.py +0 -0
  110. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_console.py +0 -0
  111. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_distributed_compilation.py +0 -0
  112. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_empty_string_and_defaults.py +0 -0
  113. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_enhanced_discovery.py +0 -0
  114. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_github_downloader.py +0 -0
  115. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_github_downloader_token_precedence.py +0 -0
  116. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_lockfile.py +0 -0
  117. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_runnable_prompts.py +0 -0
  118. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_runtime_manager_token_precedence.py +0 -0
  119. {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_virtual_package_multi_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apm-cli
3
- Version: 0.7.5
3
+ Version: 0.7.7
4
4
  Summary: MCP configuration tool
5
5
  Author-email: Daniel Meppiel <user@example.com>
6
6
  License: MIT License
@@ -208,7 +208,7 @@ git add . && git commit -m "Initial standards" && git push
208
208
 
209
209
  Anyone can now `apm install you/my-standards`.
210
210
 
211
- ## All Commands
211
+ ## Key Commands
212
212
 
213
213
  | Command | What it does |
214
214
  |---------|--------------|
@@ -217,8 +217,10 @@ Anyone can now `apm install you/my-standards`.
217
217
  | `apm init [name]` | Scaffold a new APM project or package |
218
218
  | `apm run <prompt>` | Execute a prompt workflow via AI runtime |
219
219
  | `apm uninstall <pkg>` | Remove a package from apm.yml and clean up its files |
220
+ | `apm pack` | Bundle resolved dependencies for offline distribution |
220
221
  | `apm deps list` | Show installed packages and versions |
221
- | `apm compile --target` | Target a specific agent (`vscode`, `claude`, `all`) |
222
+
223
+ → [Full CLI Reference](docs/cli-reference.md)
222
224
 
223
225
  ## Configuration
224
226
 
@@ -147,7 +147,7 @@ git add . && git commit -m "Initial standards" && git push
147
147
 
148
148
  Anyone can now `apm install you/my-standards`.
149
149
 
150
- ## All Commands
150
+ ## Key Commands
151
151
 
152
152
  | Command | What it does |
153
153
  |---------|--------------|
@@ -156,8 +156,10 @@ Anyone can now `apm install you/my-standards`.
156
156
  | `apm init [name]` | Scaffold a new APM project or package |
157
157
  | `apm run <prompt>` | Execute a prompt workflow via AI runtime |
158
158
  | `apm uninstall <pkg>` | Remove a package from apm.yml and clean up its files |
159
+ | `apm pack` | Bundle resolved dependencies for offline distribution |
159
160
  | `apm deps list` | Show installed packages and versions |
160
- | `apm compile --target` | Target a specific agent (`vscode`, `claude`, `all`) |
161
+
162
+ → [Full CLI Reference](docs/cli-reference.md)
161
163
 
162
164
  ## Configuration
163
165
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "apm-cli"
7
- version = "0.7.5"
7
+ version = "0.7.7"
8
8
  description = "MCP configuration tool"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -173,6 +173,15 @@ class CodexClientAdapter(MCPClientAdapter):
173
173
  "env": {},
174
174
  "id": server_info.get("id", "") # Add registry UUID for conflict detection
175
175
  }
176
+
177
+ # Self-defined stdio deps carry raw command/args — use directly
178
+ raw = server_info.get("_raw_stdio")
179
+ if raw:
180
+ config["command"] = raw["command"]
181
+ config["args"] = raw["args"]
182
+ if raw.get("env"):
183
+ config["env"] = raw["env"]
184
+ return config
176
185
 
177
186
  # Note: Remote servers (SSE type) are handled in configure_mcp_server and rejected early
178
187
  # This method only handles local servers with packages
@@ -165,6 +165,19 @@ class CopilotClientAdapter(MCPClientAdapter):
165
165
  "tools": ["*"], # Required by Copilot CLI specification - default to all tools
166
166
  "id": server_info.get("id", "") # Add registry UUID for conflict detection
167
167
  }
168
+
169
+ # Self-defined stdio deps carry raw command/args — use directly
170
+ raw = server_info.get("_raw_stdio")
171
+ if raw:
172
+ config["command"] = raw["command"]
173
+ config["args"] = raw["args"]
174
+ if raw.get("env"):
175
+ config["env"] = raw["env"]
176
+ # Apply tools override if present
177
+ tools_override = server_info.get("_apm_tools_override")
178
+ if tools_override:
179
+ config["tools"] = tools_override
180
+ return config
168
181
 
169
182
  # Check for remote endpoints first (registry-defined priority)
170
183
  remotes = server_info.get("remotes", [])
@@ -186,6 +186,18 @@ class VSCodeClientAdapter(MCPClientAdapter):
186
186
  # Initialize the base config structure
187
187
  server_config = {}
188
188
  input_vars = []
189
+
190
+ # Self-defined stdio deps carry raw command/args — use directly
191
+ raw = server_info.get("_raw_stdio")
192
+ if raw:
193
+ server_config = {
194
+ "type": "stdio",
195
+ "command": raw["command"],
196
+ "args": raw["args"],
197
+ }
198
+ if raw.get("env"):
199
+ server_config["env"] = raw["env"]
200
+ return server_config, input_vars
189
201
 
190
202
  # Check for packages information
191
203
  if "packages" in server_info and server_info["packages"]:
@@ -0,0 +1,6 @@
1
+ """Bundle creation and consumption for APM packages."""
2
+
3
+ from .packer import pack_bundle, PackResult
4
+ from .unpacker import unpack_bundle, UnpackResult
5
+
6
+ __all__ = ["pack_bundle", "PackResult", "unpack_bundle", "UnpackResult"]
@@ -0,0 +1,41 @@
1
+ """Lockfile enrichment for pack-time metadata."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ from ..deps.lockfile import LockFile
6
+
7
+
8
+ def enrich_lockfile_for_pack(
9
+ lockfile: LockFile,
10
+ fmt: str,
11
+ target: str,
12
+ ) -> str:
13
+ """Create an enriched copy of the lockfile YAML with a ``pack:`` section.
14
+
15
+ Does NOT mutate the original *lockfile* object — serialises a copy and
16
+ prepends the pack metadata.
17
+
18
+ Args:
19
+ lockfile: The resolved lockfile to enrich.
20
+ fmt: Bundle format (``"apm"`` or ``"plugin"``).
21
+ target: Effective target used for packing (``"vscode"``, ``"claude"``, ``"all"``).
22
+
23
+ Returns:
24
+ A YAML string with the ``pack:`` block followed by the original
25
+ lockfile content.
26
+ """
27
+ import yaml
28
+
29
+ pack_section = yaml.dump(
30
+ {
31
+ "pack": {
32
+ "format": fmt,
33
+ "target": target,
34
+ "packed_at": datetime.now(timezone.utc).isoformat(),
35
+ }
36
+ },
37
+ default_flow_style=False,
38
+ sort_keys=False,
39
+ )
40
+
41
+ return pack_section + lockfile.to_yaml()
@@ -0,0 +1,175 @@
1
+ """Bundle packer — creates self-contained APM bundles from the resolved dependency tree."""
2
+
3
+ import shutil
4
+ import tarfile
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ from ..deps.lockfile import LockFile
10
+ from ..models.apm_package import APMPackage
11
+ from ..core.target_detection import detect_target
12
+ from .lockfile_enrichment import enrich_lockfile_for_pack
13
+
14
+
15
+ # Target prefix mapping ("copilot" and "vscode" both map to .github/)
16
+ _TARGET_PREFIXES = {
17
+ "copilot": [".github/"],
18
+ "vscode": [".github/"],
19
+ "claude": [".claude/"],
20
+ "all": [".github/", ".claude/"],
21
+ }
22
+
23
+
24
+ @dataclass
25
+ class PackResult:
26
+ """Result of a pack operation."""
27
+
28
+ bundle_path: Path
29
+ files: List[str] = field(default_factory=list)
30
+ lockfile_enriched: bool = False
31
+
32
+
33
+ def _filter_files_by_target(deployed_files: List[str], target: str) -> List[str]:
34
+ """Filter deployed file paths by target prefix."""
35
+ prefixes = _TARGET_PREFIXES.get(target, _TARGET_PREFIXES["all"])
36
+ return [f for f in deployed_files if any(f.startswith(p) for p in prefixes)]
37
+
38
+
39
+ def pack_bundle(
40
+ project_root: Path,
41
+ output_dir: Path,
42
+ fmt: str = "apm",
43
+ target: Optional[str] = None,
44
+ archive: bool = False,
45
+ dry_run: bool = False,
46
+ ) -> PackResult:
47
+ """Create a self-contained bundle from installed APM dependencies.
48
+
49
+ Args:
50
+ project_root: Root of the project containing ``apm.lock`` and ``apm.yml``.
51
+ output_dir: Directory where the bundle will be created.
52
+ fmt: Bundle format — ``"apm"`` (default) or ``"plugin"``.
53
+ target: Target filter — ``"vscode"``, ``"claude"``, ``"all"``, or *None*
54
+ (auto-detect from apm.yml / project structure).
55
+ archive: If *True*, produce a ``.tar.gz`` and remove the directory.
56
+ dry_run: If *True*, resolve the file list but write nothing to disk.
57
+
58
+ Returns:
59
+ :class:`PackResult` describing what was (or would be) produced.
60
+
61
+ Raises:
62
+ FileNotFoundError: If ``apm.lock`` is missing.
63
+ ValueError: If deployed files referenced in the lockfile are missing on disk.
64
+ """
65
+ # 1. Read lockfile
66
+ lockfile_path = project_root / "apm.lock"
67
+ lockfile = LockFile.read(lockfile_path)
68
+ if lockfile is None:
69
+ raise FileNotFoundError(
70
+ "apm.lock not found — run 'apm install' first to resolve dependencies."
71
+ )
72
+
73
+ # 2. Read apm.yml for name / version / config target
74
+ apm_yml_path = project_root / "apm.yml"
75
+ try:
76
+ package = APMPackage.from_apm_yml(apm_yml_path)
77
+ pkg_name = package.name
78
+ pkg_version = package.version or "0.0.0"
79
+ config_target = package.target
80
+ except (FileNotFoundError, ValueError):
81
+ pkg_name = project_root.resolve().name
82
+ pkg_version = "0.0.0"
83
+ config_target = None
84
+
85
+ # 3. Resolve effective target
86
+ effective_target, _reason = detect_target(
87
+ project_root,
88
+ explicit_target=target,
89
+ config_target=config_target,
90
+ )
91
+ # For packing purposes, "minimal" means nothing to pack — treat as "all"
92
+ if effective_target == "minimal":
93
+ effective_target = "all"
94
+
95
+ # 4. Collect deployed_files from all dependencies, filtered by target
96
+ all_deployed: List[str] = []
97
+ for dep in lockfile.get_all_dependencies():
98
+ all_deployed.extend(dep.deployed_files)
99
+
100
+ filtered_files = _filter_files_by_target(all_deployed, effective_target)
101
+ # Deduplicate while preserving order
102
+ seen = set()
103
+ unique_files: List[str] = []
104
+ for f in filtered_files:
105
+ if f not in seen:
106
+ seen.add(f)
107
+ unique_files.append(f)
108
+
109
+ # 5. Verify each path is safe (no traversal) and exists on disk
110
+ project_root_resolved = project_root.resolve()
111
+ missing: List[str] = []
112
+ for rel_path in unique_files:
113
+ # Guard against absolute paths or path-traversal entries in deployed_files
114
+ p = Path(rel_path)
115
+ if p.is_absolute() or ".." in p.parts:
116
+ raise ValueError(
117
+ f"Refusing to pack unsafe path from lockfile: {rel_path!r}"
118
+ )
119
+ abs_path = project_root / rel_path
120
+ if not abs_path.resolve().is_relative_to(project_root_resolved):
121
+ raise ValueError(
122
+ f"Refusing to pack path that escapes project root: {rel_path!r}"
123
+ )
124
+ # deployed_files may reference directories (ending with /)
125
+ if not abs_path.exists():
126
+ missing.append(rel_path)
127
+ if missing:
128
+ raise ValueError(
129
+ f"The following deployed files are missing on disk — "
130
+ f"run 'apm install' to restore them:\n"
131
+ + "\n".join(f" - {m}" for m in missing)
132
+ )
133
+
134
+ # Dry-run: return file list without writing anything
135
+ if dry_run:
136
+ bundle_dir = output_dir / f"{pkg_name}-{pkg_version}"
137
+ return PackResult(
138
+ bundle_path=bundle_dir,
139
+ files=unique_files,
140
+ lockfile_enriched=True,
141
+ )
142
+
143
+ # 6. Build output directory
144
+ bundle_dir = output_dir / f"{pkg_name}-{pkg_version}"
145
+ bundle_dir.mkdir(parents=True, exist_ok=True)
146
+
147
+ # 7. Copy files preserving directory structure
148
+ for rel_path in unique_files:
149
+ src = project_root / rel_path
150
+ dest = bundle_dir / rel_path
151
+ if src.is_dir():
152
+ shutil.copytree(src, dest, dirs_exist_ok=True)
153
+ else:
154
+ dest.parent.mkdir(parents=True, exist_ok=True)
155
+ shutil.copy2(src, dest)
156
+
157
+ # 8. Enrich lockfile copy and write to bundle
158
+ enriched_yaml = enrich_lockfile_for_pack(lockfile, fmt, effective_target)
159
+ (bundle_dir / "apm.lock").write_text(enriched_yaml, encoding="utf-8")
160
+
161
+ result = PackResult(
162
+ bundle_path=bundle_dir,
163
+ files=unique_files,
164
+ lockfile_enriched=True,
165
+ )
166
+
167
+ # 10. Archive if requested
168
+ if archive:
169
+ archive_path = output_dir / f"{pkg_name}-{pkg_version}.tar.gz"
170
+ with tarfile.open(archive_path, "w:gz") as tar:
171
+ tar.add(bundle_dir, arcname=bundle_dir.name)
172
+ shutil.rmtree(bundle_dir)
173
+ result.bundle_path = archive_path
174
+
175
+ return result
@@ -0,0 +1,165 @@
1
+ """Bundle unpacker — extracts and verifies APM bundles."""
2
+
3
+ import shutil
4
+ import sys
5
+ import tarfile
6
+ import tempfile
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import List
10
+
11
+ from ..deps.lockfile import LockFile
12
+
13
+
14
+ @dataclass
15
+ class UnpackResult:
16
+ """Result of an unpack operation."""
17
+
18
+ extracted_dir: Path
19
+ files: List[str] = field(default_factory=list)
20
+ verified: bool = False
21
+
22
+
23
+ def unpack_bundle(
24
+ bundle_path: Path,
25
+ output_dir: Path = Path("."),
26
+ skip_verify: bool = False,
27
+ dry_run: bool = False,
28
+ ) -> UnpackResult:
29
+ """Extract and apply an APM bundle to a project directory.
30
+
31
+ Additive-only semantics (v1): only writes files listed in the bundle's
32
+ lockfile ``deployed_files``. Never deletes existing files. If a local
33
+ file has the same name as a bundle file, the bundle file wins (overwrite).
34
+
35
+ Args:
36
+ bundle_path: Path to a ``.tar.gz`` archive or an unpacked bundle directory.
37
+ output_dir: Target project directory to copy files into.
38
+ skip_verify: If *True*, skip completeness verification against the lockfile.
39
+ dry_run: If *True*, resolve the file list but write nothing to disk.
40
+
41
+ Returns:
42
+ :class:`UnpackResult` describing what was (or would be) extracted.
43
+
44
+ Raises:
45
+ FileNotFoundError: If the bundle's ``apm.lock`` is missing.
46
+ ValueError: If verification finds files listed in the lockfile but
47
+ absent from the bundle.
48
+ """
49
+ # 1. If archive, extract to temp dir
50
+ cleanup_temp = False
51
+ if bundle_path.is_file() and bundle_path.name.endswith(".tar.gz"):
52
+ temp_dir = Path(tempfile.mkdtemp(prefix="apm-unpack-"))
53
+ cleanup_temp = True
54
+ try:
55
+ with tarfile.open(bundle_path, "r:gz") as tar:
56
+ # Security: prevent path traversal
57
+ for member in tar.getmembers():
58
+ if member.name.startswith("/") or ".." in member.name:
59
+ raise ValueError(
60
+ f"Refusing to extract path-traversal entry: {member.name}"
61
+ )
62
+ # filter="data" was added in Python 3.12; use it when available
63
+ if sys.version_info >= (3, 12):
64
+ tar.extractall(temp_dir, filter="data")
65
+ else:
66
+ tar.extractall(temp_dir) # noqa: S202 — manual checks above
67
+ except Exception:
68
+ shutil.rmtree(temp_dir, ignore_errors=True)
69
+ raise
70
+
71
+ # Locate inner directory (the archive wraps a single top-level dir)
72
+ children = list(temp_dir.iterdir())
73
+ if len(children) == 1 and children[0].is_dir():
74
+ source_dir = children[0]
75
+ else:
76
+ source_dir = temp_dir
77
+ elif bundle_path.is_dir():
78
+ source_dir = bundle_path
79
+ temp_dir = None
80
+ else:
81
+ raise FileNotFoundError(f"Bundle not found or unsupported format: {bundle_path}")
82
+
83
+ try:
84
+ # 2. Read apm.lock from bundle
85
+ lockfile_path = source_dir / "apm.lock"
86
+ lockfile = LockFile.read(lockfile_path)
87
+ if lockfile is None:
88
+ if not lockfile_path.exists():
89
+ raise FileNotFoundError(
90
+ "apm.lock not found in the bundle — the bundle may be incomplete."
91
+ )
92
+ raise FileNotFoundError(
93
+ "apm.lock in the bundle could not be parsed — the bundle may be corrupt."
94
+ )
95
+
96
+ # Collect all deployed_files from lockfile
97
+ all_deployed: list[str] = []
98
+ for dep in lockfile.get_all_dependencies():
99
+ all_deployed.extend(dep.deployed_files)
100
+
101
+ # Deduplicate
102
+ seen: set[str] = set()
103
+ unique_files: list[str] = []
104
+ for f in all_deployed:
105
+ if f not in seen:
106
+ seen.add(f)
107
+ unique_files.append(f)
108
+
109
+ # 3. Verify completeness
110
+ verified = True
111
+ if not skip_verify:
112
+ missing = [
113
+ f for f in unique_files if not (source_dir / f).exists()
114
+ ]
115
+ if missing:
116
+ raise ValueError(
117
+ "Bundle verification failed — the following deployed files "
118
+ "are missing from the bundle:\n"
119
+ + "\n".join(f" - {m}" for m in missing)
120
+ )
121
+
122
+ if skip_verify:
123
+ verified = False
124
+
125
+ # Dry-run: return file list without writing
126
+ if dry_run:
127
+ return UnpackResult(
128
+ extracted_dir=bundle_path,
129
+ files=unique_files,
130
+ verified=verified,
131
+ )
132
+
133
+ # 4. Copy target files to output_dir (additive, no deletes)
134
+ output_dir = Path(output_dir)
135
+ output_dir_resolved = output_dir.resolve()
136
+ for rel_path in unique_files:
137
+ # Guard against absolute paths or path-traversal entries in deployed_files
138
+ p = Path(rel_path)
139
+ if p.is_absolute() or ".." in p.parts:
140
+ raise ValueError(
141
+ f"Refusing to unpack unsafe path from bundle lockfile: {rel_path!r}"
142
+ )
143
+ dest = output_dir / rel_path
144
+ if not dest.resolve().is_relative_to(output_dir_resolved):
145
+ raise ValueError(
146
+ f"Refusing to unpack path that escapes output directory: {rel_path!r}"
147
+ )
148
+ src = source_dir / rel_path
149
+ if not src.exists():
150
+ continue # skip_verify may allow missing files
151
+ if src.is_dir():
152
+ shutil.copytree(src, dest, dirs_exist_ok=True)
153
+ else:
154
+ dest.parent.mkdir(parents=True, exist_ok=True)
155
+ shutil.copy2(src, dest)
156
+
157
+ return UnpackResult(
158
+ extracted_dir=bundle_path,
159
+ files=unique_files,
160
+ verified=verified,
161
+ )
162
+ finally:
163
+ # Clean up temp dir if we created one
164
+ if cleanup_temp and temp_dir is not None:
165
+ shutil.rmtree(temp_dir, ignore_errors=True)