Skip to content

feat(install): apm install <local-bundle> + lockfile-embedded plugin packs (#1098)#1099

Merged
danielmeppiel merged 5 commits intomainfrom
feat/install-local-bundle
May 2, 2026
Merged

feat(install): apm install <local-bundle> + lockfile-embedded plugin packs (#1098)#1099
danielmeppiel merged 5 commits intomainfrom
feat/install-local-bundle

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

feat(install): apm install <local-bundle> + lockfile-embedded plugin packs

TL;DR

apm install <path/to/bundle> now restores a previously packed plugin bundle (directory or .tar.gz) without touching the network or the dependency resolver — matching the pip install ./wheel / cargo install --path mental model. To make this safe by default, apm pack now embeds the project's apm.lock.yaml inside the bundle with a pack.bundle_files: path -> sha256 manifest, so installs verify integrity and detect target mismatches before writing a single file. apm unpack is [Deprecated] and points at the unified entrypoint. Closes #1098.

Note

Three maintainer-decision items are surfaced in Trade-offs below (pre-0.12 bundle handling, --as semantics, integrate_package_primitives deviation). Flagged as deferrable, not blocking.

Problem (WHY)

  • Two commands for one job. Restoring a packed bundle required apm unpack; restoring a registry package required apm install. Same intent ("materialize this artifact onto disk"), different verbs — diverging from the package-manager convention every developer already has cached. The PROSE principle is explicit: "Favor small, chainable primitives over monolithic frameworks." — but small primitives only compose if their interface is consistent.
  • Plugin bundles shipped without a lockfile. Since the v0.11 plugin-format flip, apm.lock.yaml was actively excluded from plugin bundles. That means the bundle on disk had no SBOM, no per-file integrity manifest, and no provenance trail — every downstream unpack was an unverified file copy.
  • No path-traversal defense at unpack/install time. A crafted bundle entry (key like ../../../etc/passwd) would happily be deployed by the existing copy loop. There was no validate_path_segments guard at the install boundary.
  • [!] apm install had a silent footgun. Passing a path that looked like a bundle (./bundle.tar.gz) fell through to the registry resolver, which would try to clone it as org/repo. The error surfaced was about Git, not the user's actual intent.

Approach (WHAT)

# Change
1 apm install <ARG> early-detects local bundles when Path(ARG).exists() and routes to a new install_local_bundle() orchestrator. Registry path unchanged.
2 apm pack (plugin format, the default) now embeds apm.lock.yaml at the bundle root with new pack.bundle_files (sha256 manifest) and pack.target fields.
3 New src/apm_cli/bundle/local_bundle.py provides 4 pure functions: detect_local_bundle, verify_bundle_integrity, check_target_mismatch, read_bundle_plugin_json — each with two-gate path-traversal defense (validate_path_segments + ensure_path_within).
4 New --as ALIAS flag for log namespacing on local installs; rejected with UsageError on registry installs.
5 Resolver/registry/MCP/policy flags rejected on local installs with one consolidated error message.
6 apm unpack marked [Deprecated], emits a one-line warning pointing at apm install <bundle-path>.
7 Local installs are an imperative deploy: written paths recorded under local_deployed_files / local_deployed_file_hashes in the project lockfile; apm.yml is never mutated.

Implementation (HOW)

  • src/apm_cli/bundle/local_bundle.py (new, ~330 LOC)LocalBundleInfo dataclass plus the four pure functions above. Python 3.10/3.11 compat: tarfile.extractall(filter="data") is gated on sys.version_info >= (3, 12); older interpreters get manual symlink/abs-path/..-segment rejection as the primary gate. Strict sha256: hash-prefix validation (unknown algorithm prefixes raise ValueError).
  • src/apm_cli/install/local_bundle_handler.py (new, ~180 LOC) — Orchestrator extracted to honor the 1800-LOC ceiling on commands/install.py. Uses click.Abort() (not sys.exit(1)) so Click owns the exit code. Dry-run uses tree_item to make the diff visible at default verbosity.
  • src/apm_cli/install/services.py — Adds integrate_local_bundle() (~150 LOC). Two critical correctness fixes landed here: (a) if not scope: was always False (enum members are truthy) → fixed to if scope == InstallScope.USER: so project-scope installs record relative paths, restoring portability and apm prune/apm uninstall; (b) deployed-file hashes now read from the destination file post-shutil.copy2, not the expected hash from source — defense-in-depth for any future content transform.
  • src/apm_cli/bundle/plugin_exporter.py — Removed apm.lock.yaml from the per-package exclusion list and added a post-walk pass that sha256-hashes every file in the bundle, then writes the enriched lockfile to the bundle root before archiving.
  • src/apm_cli/bundle/lockfile_enrichment.pyenrich_lockfile_for_pack accepts bundle_files: dict[str, str] and writes a canonical comma-joined pack.target string.
  • src/apm_cli/commands/install.py--as ALIAS Click option + early-exit branch; targeted UsageError for tarball-but-not-bundle paths (bare directories still flow to the local-source-install path because that's a supported pre-existing form).
  • src/apm_cli/commands/pack.pyapm unpack docstring prefixed [Deprecated]; runtime warning via the existing logger.

Diagrams

Legend: the install-time decision tree — every path either errors clearly, falls through to the unchanged registry flow, or runs verify-then-deploy. No silent fall-throughs.

flowchart LR
    U["apm install ARG"] --> D{"Path(ARG)<br/>exists?"}
    D -- "no" --> R["registry / git path<br/>(unchanged)"]
    D -- "yes" --> B{"plugin.json at<br/>bundle root?"}
    B -- "no, .tar.gz" --> E1["UsageError:<br/>not a valid bundle"]
    B -- "no, dir" --> R
    B -- "yes" --> V["verify_bundle_integrity<br/>(sha256 + path guards)"]
    V -- "fail" --> E2["Abort: integrity error"]
    V -- "ok" --> I["integrate_local_bundle<br/>copy + record paths"]
    I --> L["write project<br/>apm.lock.yaml"]
Loading

Legend: pack-time SBOM embedding — the lockfile is generated, hashed against the bundle, and written into the bundle so the install side has something to verify against.

sequenceDiagram
    participant Author
    participant Pack as apm pack
    participant LE as lockfile_enrichment
    participant PE as plugin_exporter
    participant Bundle as plugin bundle
    Author->>Pack: apm pack (default = plugin)
    Pack->>PE: export_plugin_directory()
    PE->>PE: walk + sha256 every file
    PE->>LE: enrich_lockfile_for_pack(bundle_files, target)
    LE-->>PE: lockfile with pack.bundle_files + pack.target
    PE->>Bundle: write apm.lock.yaml at root
    PE->>Bundle: write plugin.json + skills/ + agents/ + ...
    Bundle-->>Author: ready to ship (SBOM embedded)
Loading

Trade-offs

  • Embedded apm.lock.yaml in plugin bundles vs. separate sidecar. Chose embedded because (a) the bundle becomes self-describing (one artifact, one provenance file), (b) Claude Code's plugin format ignores unknown root files so there's no schema collision, and (c) it matches every other package format that ships its own manifest (PKG-INFO, .cargo_vcs_info.json, package.json). Rejected sidecar because two-file artifacts are easy to drift out of sync.
  • Imperative local installs vs. mutating apm.yml. Chose imperative — local installs record their footprint in the lockfile but never touch the declarative source of truth. Rejected apm.yml mutation because that would force the user's declared dependency list to absorb side-channel artifacts, breaking reproducibility.
  • apm unpack deprecated, not removed. One release of overlap so CI workflows can migrate. Removal scheduled for v0.13.
  • Maintainer-decision items deferred. (1) Pre-0.12 bundles (no pack.bundle_files) currently warn-and-proceed; design called for hard-reject. (2) --as ALIAS currently surfaces only as a verbose log line — could be promoted to lockfile namespacing later. (3) integrate_local_bundle does a raw file-copy loop; the architect's design called for routing through integrate_package_primitives. Documented as TODO(#1098-v0.13).

Benefits

  1. One verb to learn. apm install is now the single restore primitive — registry, local bundle, or local source path. Removes the cognitive cost of remembering which command to use for which artifact.
  2. Bundles are now SBOMs. Every plugin pack since this PR carries a sha256 manifest of its own contents. Downstream consumers can audit without re-walking the tree.
  3. Path-traversal defense at the install boundary. Crafted bundle entries are rejected by validate_path_segments + ensure_path_within before any file is written.
  4. Air-gapped restore. Verified by an integration test that patches urllib, socket.create_connection, socket.socket, httpx, requests, and subprocess — local installs do zero network I/O.
  5. CI workflows simpler. apm pack followed by apm install ./bundle.tar.gz is now a one-line restore in any GitHub Actions / Azure DevOps job.

Validation

Lint (CI-mirror, must be silent) — silent:

$ uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/
All checks passed!
620 files already formatted

Targeted test surface — 69 passed, no skips:

$ uv run --extra dev pytest tests/integration/test_install_local_bundle_e2e.py \
                              tests/unit/bundle \
                              tests/unit/install/test_install_local_bundle.py \
                              tests/unit/commands/test_unpack_deprecation.py -q
.....................................................................   [100%]
69 passed in 4.28s

Two-round review-panel convergence (5 specialists in round 1, 2 specialists in round 2):

  • Round 1 flagged 7 Critical + 11 Important findings across supply-chain, test-coverage, devx-ux, python-architecture, cli-logging-ux. All Critical and 10/11 Important addressed in commit 0e240941.
  • Round 2 (supply-chain + test-coverage re-check): both verdicts SHIP. Path-traversal guards verified at both verify and deploy sites; air-gap socket sentinels verified in place; all 6 TDD stubs materialized into 29 active tests; multi-target pack.target schema pinned.

Scenario Evidence

# Scenario (user promise) Principle(s) Test(s) proving it Type
1 "I packed a bundle and want to install it on a different machine" Portability / DevX tests/integration/test_install_local_bundle_e2e.py::TestLocalInstallRoundTrip integration
2 "Installing a local bundle does zero network I/O" Secure by default tests/integration/test_install_local_bundle_e2e.py::TestLocalInstallAirGap integration
3 "A tampered bundle entry is rejected before any file is written" Secure by default tests/unit/bundle/test_local_bundle.py::test_bundle_files_path_traversal_rejected unit
4 "An unlisted file in the bundle is flagged at verify time" Secure by default tests/unit/bundle/test_local_bundle.py::test_unlisted_bundle_file_flagged unit
5 "Project-scope local installs record relative paths so the lockfile is portable" Portability tests/unit/bundle/test_local_bundle.py::test_bundle_files_absolute_path_rejected + round-trip fidelity unit
6 "apm install ./bundle.tar.gz errors clearly when the tarball isn't an APM bundle" DevX tests/unit/install/test_install_local_bundle.py::test_invalid_tarball_raises_usage_error unit
7 "--as ALIAS is rejected on registry installs" DevX / consistency tests/unit/install/test_install_local_bundle.py::test_as_rejected_on_registry_install unit
8 "Resolver/registry/MCP/policy flags are rejected on local installs" DevX tests/unit/install/test_install_local_bundle.py parametrized rejected-flags matrix (14 cells) unit
9 "apm pack embeds a multi-target lockfile schema authors can rely on" Multi-harness tests/unit/bundle/test_plugin_exporter_lockfile.py::test_plugin_export_lockfile_multi_target unit
10 "apm unpack warns and points at the new entrypoint" DevX migration tests/unit/commands/test_unpack_deprecation.py unit
Full pre-push verification transcript
$ git log --oneline main..HEAD
0e240941 fix(install-local-bundle): apply review-panel findings (CR1-CR7, IM1-IM11)
797ebe8c feat: apm install <local-bundle> + lockfile-embedded plugin packs (#1098)
4f82a66f test: TDD harness for apm install <local-bundle> (#1098)

$ uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/
All checks passed!
620 files already formatted

$ uv run --extra dev pytest tests/integration/test_install_local_bundle_e2e.py tests/unit/bundle tests/unit/install/test_install_local_bundle.py tests/unit/commands/test_unpack_deprecation.py -q
.....................................................................   [100%]
69 passed in 4.28s

How to test

  • Pack a project: apm pack --format plugin -o /tmp/myplugin → confirm /tmp/myplugin/apm.lock.yaml exists with a pack.bundle_files map.
  • Install the directory: apm install /tmp/myplugin → confirm files land in .claude-plugin/ (or target dir) and project apm.lock.yaml records local_deployed_files.
  • Re-pack as tarball: apm pack --format plugin -o /tmp/myplugin.tar.gz then apm install /tmp/myplugin.tar.gz → same outcome.
  • Try an obvious footgun: apm install ./does-not-exist.tar.gz → Click UsageError with bundle-not-found wording (no Git clone attempt).
  • Confirm legacy: apm unpack /tmp/myplugin.tar.gz → still works, prints [Deprecated] warning pointing at apm install.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Copilot and others added 3 commits May 1, 2026 21:34
Implements the test-coverage plan for issue #1098 before any production
code: 6 files, ~1,132 LOC of failing tests + plan doc.

- tests/unit/bundle/test_local_bundle.py: detection, integrity,
  target-mismatch, plugin.json reader.
- tests/unit/install/test_install_local_bundle.py: duck-type contract,
  rejected/allowed flag matrix, --as derivation, apm.yml guard.
- tests/integration/test_install_local_bundle_e2e.py: round-trip,
  collision, dry-run, force, apm.yml side effects, air-gap sentinel
  (urllib + httpx + subprocess: git/gh).
- tests/unit/bundle/test_plugin_exporter_lockfile.py: lockfile present
  in plugin output; pack.target and pack.bundle_files populated.
- tests/unit/commands/test_unpack_deprecation.py: deprecation warning
  surface contract.
- scripts/test-integration.sh: wire E2E into CI.

Status: 60 skipped (module not yet created), 2 failed (lockfile in
plugin export + deprecation), 1 passed (unpack baseline). Existing
7,410 tests still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
)

Implement unified local-bundle install matching pip/cargo/npm:
'apm install ./bundle' or 'apm install bundle.tar.gz' now deploys
plugin bundles directly into the project layout via the existing
integration pipeline -- no need for 'apm unpack'.

Plugin packs now embed apm.lock.yaml at the bundle root with a
per-file SHA-256 manifest (pack.bundle_files) and the pack target
list (pack.target). The lockfile in plugin bundles serves as
SBOM/integrity proof, NOT as a routing manifest -- routing is
delegated to the integration pipeline.

New module: src/apm_cli/bundle/local_bundle.py
  - detect_local_bundle: identifies plugin bundles (dir or .tar.gz)
  - verify_bundle_integrity: SHA-256 verify against pack.bundle_files
  - check_target_mismatch: warn on cross-target install
  - LocalBundleInfo: frozen dataclass; tarballs use filter='data'

New module: src/apm_cli/install/local_bundle_handler.py
  - install_local_bundle orchestrator extracted to honor the
    1800-LOC architecture invariant on commands/install.py.

CLI changes:
  - 'apm install <path>' early-exits to local-bundle handler when
    Path(packages[0]).exists() and detect_local_bundle returns truthy.
  - New '--as ALIAS' flag for slug override (precedence:
    --as > plugin.json:id > bundle dirname).
  - Rejected flags raise UsageError listing all offending options.

Lockfile semantics:
  - Project apm.lock.yaml records local installs in
    local_deployed_files / local_deployed_file_hashes.
  - apm.yml is NOT mutated by local-bundle install (matches
    'pip install ./foo.whl' semantics).

Pack-side:
  - plugin_exporter.py walks bundle, computes per-file SHA-256,
    writes enriched apm.lock.yaml at bundle root before archiving.
  - lockfile_enrichment.enrich_lockfile_for_pack accepts optional
    bundle_files dict and pack target.

Deprecation: 'apm unpack' marked [Deprecated] with redirect to
'apm install <bundle-path>'. Behavior unchanged in v0.12 (alias-
soft-notice). Hard deprecation slated for v0.13.

E2E coverage (tests/integration/test_install_local_bundle_e2e.py):
  - directory + tarball install
  - multi-target + auto-detect target
  - pack -> install round-trip fidelity
  - collision: managed/locally-modified, with and without --force
  - dry-run produces no filesystem mutation
  - apm.yml byte-for-byte unchanged
  - local lockfile records deployed files + SHA-256 hashes
  - air-gap: monkeypatches urllib/httpx/subprocess for git/gh,
    proves zero network I/O on local install

Verification:
  - 7024 unit tests + 12 new E2E tests pass
  - ruff check + ruff format --check both silent
  - architecture invariant satisfied (commands/install.py 1743/1800)

Closes #1098

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…IM11)

CRITICAL fixes:
- CR1: validate bundle_files keys against path traversal (verify+deploy paths)
       and ensure each deploy destination resolves within the deploy_root.
- CR2: split tarfile.extractall() with filter='data' for Python 3.12+
       (3.10/3.11 fall back to plain extract; argument is unsupported).
- CR3: switch DiagnosticCollector.warning() -> warn() (correct API name).
- CR4: replace 'if not scope:' (always False -- enum) with explicit
       InstallScope.USER branch so global installs use the right deploy root.
- CR5: add InstallLogger fallback for collision warnings when diagnostics
       collector is absent.
- CR6: materialize 6 TDD-stub unit tests (rejected/allowed flags, --as
       alias derivation, apm.yml mutation guard, IM7/IM8 negative cases).
- CR7: document apm install <local-bundle> + --as flag in CLI reference,
       deprecate apm unpack section in favour of install.

IMPORTANT fixes:
- IM1: verify_bundle_integrity() flags unlisted bundle files (allowing only
       apm.lock.yaml and plugin.json).
- IM2: extend air-gap E2E patches with socket.create_connection / socket.socket.
- IM3: replace sys.exit(1) with click.Abort() in install_local_bundle handler.
- IM4: hash deployed file post-copy instead of trusting bundle's expected_hash.
- IM5: dry-run file listing uses logger.tree_item() so it surfaces in default mode.
- IM6: add 3 local-bundle examples to install command docstring.
- IM7: when path is a tarball but not a recognised bundle, raise UsageError
       instead of silently falling through to the registry resolver.
- IM8: reject --as on registry installs (it is meaningful only for local bundles).
- IM10: add test_collision_managed_file_overwritten_with_force regression test.
- IM11: add test_plugin_export_lockfile_multi_target asserting pack.target
        is a comma-joined STRING (canonical per lockfile_enrichment.py:175).

MINOR fixes:
- M-arch-1: add NOTE/TODO explaining why local-bundle deploy bypasses
            integrate_package_primitives (deferred unification).
- M-cli-1: drop verbose-gate around verbose_detail (logger self-gates).
- M-cli-3: preserve dict insertion order for rejected-flags listing.
- M-scs-1: harden _normalize_hash to reject non-sha256 algo prefixes.

Verification:
- ruff check src/ tests/ + ruff format --check src/ tests/ -> silent.
- pytest tests/unit -> 7076 passed (no regressions).
- pytest tests/unit/bundle tests/unit/install
  tests/integration/test_install_local_bundle_e2e.py -> 482 passed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 1, 2026 20:38
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an offline, integrity-verified install path for previously packed plugin bundles by teaching apm install to detect and deploy local bundle directories/archives, and by embedding an enriched apm.lock.yaml (with pack.bundle_files) into plugin-format packs.

Changes:

  • Route apm install <ARG> to a new local-bundle install handler when <ARG> is a detected bundle (dir or .tar.gz/.tgz), including --as support and consolidated rejected-flag validation.
  • Embed apm.lock.yaml into plugin bundles with pack.target + pack.bundle_files (sha256 manifest) for install-time integrity verification.
  • Deprecate apm unpack, add extensive unit + integration coverage, wire the new integration test into scripts/test-integration.sh, and update docs/changelog.
Show a summary per file
File Description
tests/unit/test_plugin_exporter.py Updates expectations/comments around plugin bundle contents now that apm.lock.yaml is embedded.
tests/unit/install/test_install_local_bundle.py Unit coverage for install routing, rejected flags, and --as behavior.
tests/unit/commands/test_unpack_deprecation.py Verifies apm unpack emits a deprecation warning and still functions.
tests/unit/bundle/test_plugin_exporter_lockfile.py Pins apm.lock.yaml + pack.* metadata in plugin export output.
tests/unit/bundle/test_local_bundle.py Unit coverage for bundle detection, integrity checks, and target mismatch warnings.
tests/unit/bundle/init.py Establishes/adjusts unit-test package structure for bundle tests.
tests/integration/test_install_local_bundle_e2e.py E2E coverage for offline local-bundle install (dir/tar), dry-run, collisions, and air-gap proof.
src/apm_cli/install/services.py Adds integrate_local_bundle() imperative deploy loop and lockfile recording support.
src/apm_cli/install/local_bundle_handler.py New orchestrator that validates flags, verifies integrity, resolves targets, deploys, and persists lockfile fields.
src/apm_cli/commands/pack.py Marks apm unpack deprecated (help + runtime warning).
src/apm_cli/commands/install.py Adds --as and early local-bundle detection/dispatch; rejects --as on non-bundle installs.
src/apm_cli/bundle/plugin_exporter.py Computes per-file sha256 manifest and writes enriched lockfile into plugin bundle root.
src/apm_cli/bundle/lockfile_enrichment.py Extends lockfile enrichment to include pack.bundle_files (deterministic ordering).
src/apm_cli/bundle/local_bundle.py New local-bundle detection (dir/tar), integrity verification, and target mismatch warning helpers.
scripts/test-integration.sh Adds the new local-bundle E2E test file to CI’s explicit integration test list.
packages/apm-guide/.apm/skills/apm-usage/commands.md Updates command reference for local-bundle install + unpack deprecation.
docs/src/content/docs/reference/cli-commands.md Documents --as and local-bundle install usage + apm unpack deprecation note.
CHANGELOG.md Adds Unreleased entries for local-bundle install and lockfile-embedded plugin packs.

Copilot's findings

Comments suppressed due to low confidence (2)

src/apm_cli/bundle/local_bundle.py:187

  • The tar member safety check only rejects names starting with '/' or containing '..' path parts. This can miss other absolute-path forms (e.g., Windows drive paths) and backslash-separated traversal in crafted tar headers. Consider reusing validate_path_segments(member.name, ...) (it normalizes backslashes and percent-decodes) and rejecting both PurePosixPath and PureWindowsPath absolute paths before extraction.
                # Reject member symlinks/hardlinks and absolute / parent paths
                # for safety (analogous to the pack-side filter).
                for member in tar.getmembers():
                    if member.issym() or member.islnk():
                        return None
                    if member.name.startswith("/") or ".." in Path(member.name).parts:
                        return None

docs/src/content/docs/reference/cli-commands.md:231

  • This example comment says --as overrides the "recorded slug", but the lockfile currently doesn't record a slug for local-bundle installs (only local_deployed_files/hashes). Please update the example wording to avoid implying lockfile namespacing that doesn't exist yet.
# Deploy a local APM bundle (directory or .tar.gz produced by `apm pack`).
# Bundles are an imperative, air-gapped deploy: no apm.yml mutation,
# no network, no policy / MCP / dependency-resolver involvement.
apm install ./build/my-bundle
apm install ./my-bundle.tar.gz
apm install ./my-bundle --as custom-slug   # override the recorded slug

  • Files reviewed: 17/18 changed files
  • Comments generated: 6

Comment thread CHANGELOG.md Outdated
Comment thread src/apm_cli/commands/install.py Outdated
Comment thread src/apm_cli/commands/install.py Outdated
Comment thread src/apm_cli/bundle/local_bundle.py
Comment thread docs/src/content/docs/reference/cli-commands.md Outdated
Comment thread packages/apm-guide/.apm/skills/apm-usage/commands.md Outdated
Address all 6 actionable findings from copilot-pull-request-reviewer
on PR #1099, plus 1 low-confidence finding worth landing:

- local_bundle.py: tighten tar member safety (use validate_path_segments
  to catch backslash traversal + percent-decoded forms; reject Windows
  drive-letter absolute paths via PureWindowsPath); ensure temp_dir is
  cleaned on every early-return path (was leaking on malicious archives).
- install.py: --as help text now says 'Only valid for local-bundle
  installs' (matches actual UsageError behavior); example updated to
  'custom log label' (alias only affects display, not lockfile).
- docs/cli-commands.md: --as description and example clarified -- the
  alias is a log/display label, not lockfile namespacing.
- packages/apm-guide/.../commands.md: tighten 'any existing path triggers
  local-bundle' wording -- only paths recognized as bundles (plugin.json
  at root) route to local-bundle mode; other paths still flow through
  the normal local-path dependency-resolver pipeline.
- CHANGELOG: condense the two new entries to single concise sentences
  per Keep a Changelog convention.

Adds regression test test_detect_cleans_temp_dir_on_malicious_archive
to pin the tempdir-leak fix.

Verification:
- ruff check + ruff format --check: silent
- 70 passed (was 69; +1 regression test)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

apm-action PR #31 compatibility assessment

Short answer: #31 is sufficient as a defensive measure on its own; once #1099 ships in apm 0.12, a follow-up apm-action change can additionally light up plugin-format restore and remove the current rejection.

Why #31 already works after #1099 lands

apm-action mode (after #31 merges) After apm 0.12 ships #1099 Status
bundle-format: 'apm' (default) Restores via apm unpack (still works) OK
bundle-format: 'plugin' (opt-in pack) Pack succeeds; restore intentionally rejected by extractBundle with operator-friendly error OK (rejection is correct today but stale once 0.12 is out)
setup-only: 'true' Independent of pack/restore OK

The bundle-format: 'apm' defensive default in #31 means no apm-action consumer breaks when apm flips its pack default to plugin upstream.

What #1099 unlocks for apm-action (follow-up, not blocking)

The reasoning #31 cites for rejecting plugin restore -- "apm unpack itself rejects plugin tarballs (different deployment contract -- no lockfile to drive deployed_files). That belongs upstream in apm unpack, not here." -- is exactly what #1099 fixes upstream:

  1. apm pack --format plugin now embeds apm.lock.yaml with a pack.bundle_files sha256 manifest.
  2. apm install <bundle-path> accepts plugin tarballs (and directories), verifies integrity against the embedded lockfile, and records local_deployed_files in the project lockfile.

So once a consumer's apm-version is >= 0.12, plugin-format restore is technically possible -- the action just needs to call apm install <path> instead of apm unpack <path>.

Recommended follow-up apm-action PR (post 0.12 release)

Open after apm 0.12 ships:

  • Detect installed apm version; on >= 0.12, route restore through apm install <bundle-path> for both formats.
  • On < 0.12, keep the current apm unpack path (with the bundle-format: 'plugin' rejection intact for backwards compatibility).
  • Optionally flip the bundle-format default to 'plugin' once the floor is 0.12+, aligning apm-action's default with the upstream CLI default.
  • The apm unpack deprecation warning will start appearing in CI logs for bundle-format: 'apm' users -- worth migrating off.

TL;DR

- Add _looks_like_legacy_apm_bundle() to local_bundle.py to distinguish
  legacy --format apm tarballs (apm.lock.yaml present, plugin.json absent)
  from arbitrary non-bundle archives.

- Improve install.py IM7 error path: when a tarball is a legacy apm-format
  bundle, emit a specific UsageError guiding the user to either repack
  with --format plugin or use apm unpack. Generic tarballs retain the
  existing error message.

- Unit tests (test_local_bundle.py): 6 new tests covering
  detect_local_bundle returns None for legacy bundles, and
  _looks_like_legacy_apm_bundle correctly classifies legacy/plugin/junk
  tarballs and non-archives.

- Unit tests (test_install_local_bundle.py): 1 new test asserting the
  install command rejects legacy apm-format tarballs with actionable
  error wording.

- Integration tests (test_install_local_bundle_e2e.py): 2 new tests
  covering the legacy tarball rejection end-to-end and verifying that
  legacy directories (non-tarball) fall through to the resolver as
  expected.
@danielmeppiel danielmeppiel merged commit 1162240 into main May 2, 2026
11 checks passed
@danielmeppiel danielmeppiel deleted the feat/install-local-bundle branch May 2, 2026 08:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin bundles should embed apm.lock.yaml (supply-chain integrity + restores apm pack/unpack round-trip)

2 participants