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.
- {apm_cli-0.7.5/src/apm_cli.egg-info → apm_cli-0.7.7}/PKG-INFO +5 -3
- {apm_cli-0.7.5 → apm_cli-0.7.7}/README.md +4 -2
- {apm_cli-0.7.5 → apm_cli-0.7.7}/pyproject.toml +1 -1
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/codex.py +9 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/copilot.py +13 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/vscode.py +12 -0
- apm_cli-0.7.7/src/apm_cli/bundle/__init__.py +6 -0
- apm_cli-0.7.7/src/apm_cli/bundle/lockfile_enrichment.py +41 -0
- apm_cli-0.7.7/src/apm_cli/bundle/packer.py +175 -0
- apm_cli-0.7.7/src/apm_cli/bundle/unpacker.py +165 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/cli.py +30 -786
- apm_cli-0.7.7/src/apm_cli/commands/pack.py +110 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/target_detection.py +17 -7
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/plugin_parser.py +193 -12
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/__init__.py +2 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/agent_integrator.py +4 -4
- apm_cli-0.7.7/src/apm_cli/integration/mcp_integrator.py +1036 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/skill_integrator.py +31 -4
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/models/plugin.py +7 -1
- {apm_cli-0.7.5 → apm_cli-0.7.7/src/apm_cli.egg-info}/PKG-INFO +5 -3
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/SOURCES.txt +6 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/AUTHORS +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/LICENSE +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/setup.cfg +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/client/base.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/package_manager/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/package_manager/base.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/adapters/package_manager/default_manager.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/commands/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/commands/deps.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/agents_compiler.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/claude_formatter.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/constants.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/constitution.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/constitution_block.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/context_optimizer.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/distributed_compiler.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/injector.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/link_resolver.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/compilation/template_builder.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/config.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/conflict_detector.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/docker_args.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/operations.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/safe_installer.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/script_runner.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/core/token_manager.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/aggregator.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/apm_resolver.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/collection_parser.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/dependency_graph.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/github_downloader.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/lockfile.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/package_validator.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/deps/verifier.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/factory.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/base_integrator.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/command_integrator.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/hook_integrator.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/instruction_integrator.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/prompt_integrator.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/skill_transformer.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/integration/utils.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/models/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/models/apm_package.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/output/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/output/formatters.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/output/models.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/output/script_formatters.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/primitives/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/primitives/discovery.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/primitives/models.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/primitives/parser.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/registry/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/registry/client.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/registry/integration.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/registry/operations.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/base.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/codex_runtime.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/copilot_runtime.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/factory.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/llm_runtime.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/runtime/manager.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/console.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/github_host.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/helpers.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/utils/version_checker.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/version.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/workflow/__init__.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/workflow/discovery.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/workflow/parser.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli/workflow/runner.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/dependency_links.txt +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/entry_points.txt +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/requires.txt +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/src/apm_cli.egg-info/top_level.txt +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_apm_package_models.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_apm_resolver.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_codex_docker_args_fix.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_codex_empty_string_and_defaults.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_collision_integration.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_console.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_distributed_compilation.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_empty_string_and_defaults.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_enhanced_discovery.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_github_downloader.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_github_downloader_token_precedence.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_lockfile.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_runnable_prompts.py +0 -0
- {apm_cli-0.7.5 → apm_cli-0.7.7}/tests/test_runtime_manager_token_precedence.py +0 -0
- {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.
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
161
|
+
|
|
162
|
+
→ [Full CLI Reference](docs/cli-reference.md)
|
|
161
163
|
|
|
162
164
|
## Configuration
|
|
163
165
|
|
|
@@ -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,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)
|