diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f641722..ca1263bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `apm lock`: new command that resolves all dependencies and writes + `apm.lock.yaml` **without** deploying any files to agent targets. + Mirrors the lockfile-only ergonomics of `cargo generate-lockfile` and + `pnpm lock`. Use it to bootstrap or refresh the lockfile before + reviewing or applying changes. Accepts `--update` (re-resolve to latest + SHAs), `--verbose`, `--global`, `--no-policy`, `--target`, and + `--parallel-downloads`. (#1639) - JetBrains users (IntelliJ IDEA, PyCharm, GoLand, WebStorm, and other IDEs) can now install MCP servers with `apm install --mcp --runtime intellij ` -- no manual `mcp.json` editing required. GitHub Copilot for diff --git a/docs/src/content/docs/reference/cli/lock.md b/docs/src/content/docs/reference/cli/lock.md new file mode 100644 index 000000000..163556cdb --- /dev/null +++ b/docs/src/content/docs/reference/cli/lock.md @@ -0,0 +1,97 @@ +--- +title: apm lock +description: Resolve all dependencies and write apm.lock.yaml without deploying any files to agent targets. +sidebar: + order: 5 +--- + +Resolve all dependencies declared in `apm.yml` and write `apm.lock.yaml` with pinned commit SHAs -- without copying any files to agent targets. + +## Synopsis + +```bash +apm lock [OPTIONS] +``` + +## Description + +`apm lock` runs the full resolver and downloader so every dependency SHA is pinned, then writes `apm.lock.yaml`. It skips the targets, cleanup, post-deps-local, and audit phases. The integrate phase still runs but deploys nothing because the target set is empty in lockfile-only mode -- no files are copied to `.github/`, `.agents/`, or any other harness directory. + +Use `apm lock` to: + +- **Bootstrap a lockfile** before the first `apm install` run in a new project or CI environment. +- **Refresh the lockfile** after editing `apm.yml` without triggering a full deployment, so you can review the new lockfile before applying it. +- **Verify that `apm.yml` resolves cleanly** (useful in PR checks). + +This mirrors the ergonomics of `cargo generate-lockfile` and `pnpm lock`. + +## Options + +| Flag | Default | Description | +| --- | --- | --- | +| `--verbose`, `-v` | off | Show per-dependency resolution details. | +| `--global`, `-g` | off | Operate on `~/.apm/apm.yml` instead of the current project (mirrors `apm install -g`). | +| `--update` | off | Re-resolve deps to their latest matching SHAs before writing the lockfile (like `apm install --update`). | +| `--no-policy` | off | Skip policy enforcement during resolution. | +| `--target TARGET`, `-t TARGET` | none | Agent target for policy enforcement during resolution. No files are deployed regardless of this value. Accepts a single target (`claude`, `copilot`, etc.) or comma-separated list. | +| `--parallel-downloads N` | `4` | Max concurrent package downloads. `0` disables parallelism. | + +## Examples + +Resolve from `apm.yml` and write the lockfile: + +```bash +apm lock +``` + +Re-resolve to the latest SHAs and update the lockfile: + +```bash +apm lock --update +``` + +Resolve and write the lockfile for user-scope dependencies: + +```bash +apm lock -g +``` + +Show resolution details while writing the lockfile: + +```bash +apm lock --verbose +``` + +## Behavior + +- **Resolve and download.** Every dependency in `apm.yml` is resolved and, if not already cached, downloaded. Fresh downloads pin the commit SHA and compute a content hash. +- **Write `apm.lock.yaml`.** The lockfile records every pinned ref, resolved commit, and content hash. `deployed_files` entries are empty because no files are deployed. +- **No files deployed.** The targets, cleanup, post-deps-local, and audit phases are skipped. The integrate phase runs but deploys nothing because the target set is empty. Running `apm lock` is safe to run before you are ready to install. +- **Idempotent.** If the lockfile already matches the resolution result, it is overwritten with the same content. + +## CI integration + +Add `apm lock` to your CI workflow to keep the lockfile in sync with `apm.yml`: + +```yaml +- name: Refresh APM lockfile + run: apm lock + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +- name: Commit updated lockfile + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add apm.lock.yaml + git diff --cached --quiet || git commit -m "chore: update apm lockfile" +``` + +To verify the lockfile is up to date in a PR check (and fail if it drifts), use [`apm install --frozen`](../install/) instead. + +## Related + +- [`apm install`](../install/) -- install dependencies and deploy files to agent targets. +- [`apm install --frozen`](../install/) -- reproduce the lockfile exactly; fails on drift. Use this in CI. +- [`apm update`](../update/) -- re-resolve, show a plan, prompt for consent, then install. +- [`apm outdated`](../outdated/) -- report which dependencies have newer refs available. diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index bf79d97b6..6a3a75e6b 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -200,6 +200,7 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning, | `apm config get [KEY]` | Get a config value (`auto-integrate`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- | | `apm config set KEY VALUE` | Set a config value (`auto-integrate`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `mcp-registry-url`; `copilot-cowork-skills-dir` requires `apm experimental enable copilot-cowork`) | -- | | `apm config unset KEY` | Remove a stored config value (`temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- | +| `apm lock` | Resolve all dependencies in `apm.yml` and write `apm.lock.yaml` **without** deploying any files to agent targets. Mirrors `cargo generate-lockfile` / `pnpm lock`. Use to bootstrap or refresh the lockfile before reviewing and applying changes. | `--update` re-resolve to latest SHAs, `--verbose`, `-g/--global`, `--no-policy`, `--target` (comma-separated), `--parallel-downloads N` | | `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against the latest refs, prints a structured plan (added/updated/removed/unchanged), and prompts before mutating anything (default `[y/N]`). Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Strict superset of the deprecated `apm deps update`. Skips the prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) | | `apm self-update` | Update the APM CLI itself (or show distributor guidance when self-update is disabled at build time). | `--check` only check | diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index d1e92325e..11eeee7b7 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -28,6 +28,7 @@ from apm_cli.commands.init import init from apm_cli.commands.install import install from apm_cli.commands.list_cmd import list as list_cmd +from apm_cli.commands.lock import lock from apm_cli.commands.marketplace import marketplace from apm_cli.commands.marketplace import search as marketplace_search from apm_cli.commands.mcp import mcp @@ -51,6 +52,7 @@ " apm init Scaffold a new project\n" " apm install Install dependencies from apm.yml\n" " apm install --frozen Reproduce lockfile exactly (CI-safe)\n" + " apm lock Resolve deps and write lockfile only\n" " apm outdated See what's drifted from upstream\n" " apm update Refresh refs and rewrite the lockfile\n" " apm audit --ci Validate lockfile integrity for CI gates\n" @@ -109,6 +111,7 @@ def cli(ctx): cli.add_command(publish_cmd, name="publish") cli.add_command(init) cli.add_command(install) +cli.add_command(lock) cli.add_command(uninstall) cli.add_command(prune) cli.add_command(update) diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py index fa301c98e..9cb193d5f 100644 --- a/src/apm_cli/commands/_helpers.py +++ b/src/apm_cli/commands/_helpers.py @@ -725,3 +725,23 @@ def _create_minimal_apm_yml(config, plugin=False, target_path=None): content = content.replace("dependencies:", skeleton + "\ndependencies:", 1) out_file.write_text(content, encoding="utf-8") + + +def _find_apm_yml(start: Path | None = None) -> Path | None: + """Walk parent directories from ``start`` (or cwd) to find ``apm.yml``. + + Matches the npm / cargo / poetry ergonomic: a developer running + an ``apm`` command from a subdirectory of their project (``src/``, + ``docs/``, ``scripts/``) finds the manifest and operates on it. + + The walk stops at the filesystem root or when an ``apm.yml`` is + found, whichever comes first. Returns the absolute path to the + ``apm.yml`` file when found; ``None`` when no project root is + discoverable from ``start`` upward. + """ + cwd = (start or Path.cwd()).resolve() + for candidate in (cwd, *cwd.parents): + manifest = candidate / "apm.yml" + if manifest.is_file(): + return manifest + return None diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 3b0891770..d37f45060 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -2039,6 +2039,7 @@ def _install_apm_dependencies( # noqa: PLR0913 frozen: bool = False, plan_callback=None, refresh: bool = False, + lockfile_only: bool = False, ): """Thin wrapper -- builds an :class:`InstallRequest` and delegates to :class:`apm_cli.install.service.InstallService`. @@ -2078,5 +2079,6 @@ def _install_apm_dependencies( # noqa: PLR0913 frozen=frozen, plan_callback=plan_callback, refresh=refresh, + lockfile_only=lockfile_only, ) return InstallService().run(request) diff --git a/src/apm_cli/commands/lock.py b/src/apm_cli/commands/lock.py new file mode 100644 index 000000000..a92f41ee0 --- /dev/null +++ b/src/apm_cli/commands/lock.py @@ -0,0 +1,206 @@ +"""``apm lock`` -- resolve dependencies and write ``apm.lock.yaml`` without deploying files. + +Mirrors the lockfile-generation ergonomics of ``cargo generate-lockfile`` +and ``pnpm lock``: run the full resolver and downloader so every commit +SHA is pinned, then write ``apm.lock.yaml`` -- **without** copying any +primitives into agent targets (no ``.github/``, no ``.agents/``, etc.). + +Use ``apm lock`` to: + +* Bootstrap a lockfile before the first ``apm install`` run in CI. +* Refresh the lockfile after editing ``apm.yml`` without triggering a + full deployment (useful when you want to review changes before + applying them). +* Verify that the current ``apm.yml`` resolves cleanly. + +What it does +------------ +1. Parses ``apm.yml``. +2. Runs the resolve + download phases (network required for fresh deps). +3. Writes ``apm.lock.yaml`` with all pinned SHAs and content hashes. +4. Skips the targets, cleanup, post-deps-local, and audit phases. + The integrate phase still runs but deploys nothing because the + target set is empty in lockfile-only mode. + +Flags +----- +* ``--verbose``/``-v`` -- show per-dependency resolution details. +* ``--global``/``-g`` -- operate on ``~/.apm/apm.yml`` instead of the + current project (mirrors ``apm install -g``). +* ``--update`` -- re-resolve refs to their latest SHAs (like + ``apm install --update``) before writing the lockfile. +* ``--no-policy`` -- skip policy enforcement during resolution. +* ``--target``/``-t`` -- scope policy enforcement to a specific agent + target during resolution; no files are deployed. +* ``--parallel-downloads`` -- max concurrent package downloads. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +from ..core.command_logger import InstallLogger +from ..core.target_detection import TargetParamType +from ..install.errors import ( + AuthenticationError, + DirectDependencyError, + PolicyViolationError, +) +from ..utils.console import _rich_echo, _rich_error, _rich_info, _rich_success +from ._helpers import _find_apm_yml + + +def _handle_lock_error(e: Exception, verbose: bool) -> None: + """Render a pipeline error and exit. + + Centralises error rendering for the ``apm lock`` command so the + except block below stays short and the pattern stays distinct from + the similar handler in ``apm update``. + """ + if isinstance(e, AuthenticationError): + _rich_error(str(e)) + if e.diagnostic_context: + _rich_echo(e.diagnostic_context) + _rich_info("Tip: run 'apm doctor' to diagnose auth and connectivity.", symbol="info") + elif isinstance(e, (DirectDependencyError, PolicyViolationError)): + _rich_error(str(e)) + else: + _rich_error(f"Error generating lockfile: {e}") + if not verbose: + _rich_info("Run with --verbose for detailed diagnostics.", symbol="info") + sys.exit(1) + + +@click.command( + name="lock", + help="Resolve dependencies and write apm.lock.yaml without deploying files", +) +@click.option( + "--verbose", + "-v", + is_flag=True, + default=False, + help="Show per-dependency resolution details", +) +@click.option( + "--global", + "-g", + "global_", + is_flag=True, + default=False, + help="Operate on ~/.apm/apm.yml instead of the current project", +) +@click.option( + "--update", + "update_refs", + is_flag=True, + default=False, + help="Re-resolve refs to their latest SHAs before writing the lockfile", +) +@click.option( + "--no-policy", + is_flag=True, + default=False, + help="Skip policy enforcement during resolution", +) +@click.option( + "--target", + "-t", + type=TargetParamType(), + default=None, + help=( + "Agent target(s) to scope policy enforcement during resolution " + "(e.g. claude, copilot, cursor). " + "No files are deployed regardless of this value." + ), +) +@click.option( + "--parallel-downloads", + type=int, + default=4, + show_default=True, + help="Max concurrent package downloads (0 to disable parallelism)", +) +def lock( + verbose: bool, + global_: bool, + update_refs: bool, + no_policy: bool, + target: str | list[str] | None, + parallel_downloads: int, +) -> None: + """Resolve dependencies and write apm.lock.yaml without deploying files. + + Examples: + apm lock # Resolve from apm.yml, write apm.lock.yaml + apm lock --update # Re-resolve to latest SHAs, write lockfile + apm lock -g # Operate on the user-scope (~/.apm/) manifest + apm lock --verbose # Show resolution details + """ + import os + + from apm_cli.core.scope import InstallScope, get_apm_dir + from apm_cli.models.apm_package import APMPackage + + if global_: + scope = InstallScope.USER + manifest_path = get_apm_dir(scope) / "apm.yml" + if not manifest_path.is_file(): + _rich_error( + "No apm.yml found in ~/.apm/. Run 'apm install -g ' to create one." + ) + sys.exit(1) + project_root = manifest_path.parent + else: + scope = InstallScope.PROJECT + manifest_path = _find_apm_yml() + if manifest_path is None: + _rich_error( + "No apm.yml found in this directory or any parent directory. " + "Run 'apm init' to create one." + ) + sys.exit(1) + project_root = manifest_path.parent + if project_root != Path.cwd().resolve(): + _rich_info( + f"Using apm.yml at {manifest_path} (project root: {project_root})", + symbol="info", + ) + + if project_root != Path.cwd().resolve(): + os.chdir(project_root) + + try: + apm_package = APMPackage.from_apm_yml(Path("apm.yml")) + except (FileNotFoundError, ValueError) as e: + _rich_error(f"Failed to parse apm.yml: {e}") + sys.exit(1) + + logger = InstallLogger(verbose=verbose) + + try: + from apm_cli.commands.install import _install_apm_dependencies + + _install_apm_dependencies( + apm_package, + update_refs=update_refs, + verbose=verbose, + scope=scope, + parallel_downloads=parallel_downloads, + logger=logger, + no_policy=no_policy, + target=target, + lockfile_only=True, + ) + except click.UsageError: + raise + except Exception as e: + _handle_lock_error(e, verbose) + + _rich_success("Lockfile written to apm.lock.yaml", symbol="check") + + +__all__ = ["lock"] diff --git a/src/apm_cli/commands/update.py b/src/apm_cli/commands/update.py index e84b6cde7..0ed235971 100644 --- a/src/apm_cli/commands/update.py +++ b/src/apm_cli/commands/update.py @@ -64,29 +64,7 @@ ) from ..install.plan import UpdatePlan, render_plan_text from ..utils.console import _rich_echo, _rich_error, _rich_info, _rich_success, _rich_warning -from ._helpers import UnknownPackageError, resolve_requested_packages - - -def _find_apm_yml(start: Path | None = None) -> Path | None: - """Walk parent directories from ``start`` (or cwd) to find ``apm.yml``. - - Matches the npm / cargo / poetry ergonomic: a developer running - ``apm update`` from a subdirectory of their project (``src/``, - ``docs/``, ``scripts/``) finds the manifest and operates on it, - rather than getting silently misrouted to the deprecated - self-update shim. - - The walk stops at the filesystem root or when an ``apm.yml`` is - found, whichever comes first. Returns the absolute path to the - ``apm.yml`` file when found; ``None`` when no project root is - discoverable from ``start`` upward. - """ - cwd = (start or Path.cwd()).resolve() - for candidate in (cwd, *cwd.parents): - manifest = candidate / "apm.yml" - if manifest.is_file(): - return manifest - return None +from ._helpers import UnknownPackageError, _find_apm_yml, resolve_requested_packages def _stdin_is_tty() -> bool: diff --git a/src/apm_cli/install/context.py b/src/apm_cli/install/context.py index 9106357b4..b5d8e70b1 100644 --- a/src/apm_cli/install/context.py +++ b/src/apm_cli/install/context.py @@ -58,6 +58,7 @@ class InstallContext: allow_insecure_hosts: tuple[str, ...] = () dry_run: bool = False + lockfile_only: bool = False force: bool = False verbose: bool = False refresh: bool = False diff --git a/src/apm_cli/install/pipeline.py b/src/apm_cli/install/pipeline.py index baef72acf..cbd171bda 100644 --- a/src/apm_cli/install/pipeline.py +++ b/src/apm_cli/install/pipeline.py @@ -339,6 +339,7 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 legacy_skill_paths: bool = False, plan_callback=None, refresh: bool = False, + lockfile_only: bool = False, ): """Install APM package dependencies. @@ -465,6 +466,7 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 early_lockfile=_early_lockfile, legacy_skill_paths=legacy_skill_paths, refresh=refresh, + lockfile_only=lockfile_only, ) # ------------------------------------------------------------------ @@ -540,19 +542,18 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 raise # re-raise through the outer except -> RuntimeError wrapper # -------------------------------------------------------------- - # Phase 2: Target detection + integrator initialization + # Phase 2: Target detection + integrator initialization. + # Skipped in lockfile_only mode -- no primitives are deployed. # -------------------------------------------------------------- - from .phases import targets as _targets_phase + if not lockfile_only: + from .phases import targets as _targets_phase - _run_phase("targets", _targets_phase, ctx) + _run_phase("targets", _targets_phase, ctx) # -------------------------------------------------------------- # Phase 2.5: Post-targets target-aware policy check (#827) - # Target/compilation policy rules need the effective target - # which is only known after targets.run(). Dependency checks - # already ran in policy_gate; this phase filters to - # compilation-target checks only. - # PolicyViolationError halts the pipeline cleanly. + # Runs even in lockfile_only mode so that --target policy + # constraints are enforced during resolution-only runs. # -------------------------------------------------------------- from .phases import policy_target_check as _policy_target_check_phase @@ -563,12 +564,9 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 # -------------------------------------------------------------- # Phase 1.75: Auth pre-flight for --update mode (#1015) - # When update_refs is set we are about to overwrite apm.yml, - # apm.lock.yaml, and apm_modules/. If any remote host rejects - # auth we must abort BEFORE any write phase to avoid partial - # file corruption. One git ls-remote per distinct (host, org). + # Skipped in lockfile_only mode -- no writes to apm.yml occur. # -------------------------------------------------------------- - if update_refs and ctx.deps_to_install: + if update_refs and ctx.deps_to_install and not lockfile_only: # Use ctx.auth_resolver: resolve phase guarantees it is set # (resolve.py:91-92), whereas the local ``auth_resolver`` # parameter can still be None for callers that omit it. @@ -699,33 +697,33 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 "One or more direct dependencies failed validation. Run with --verbose for details." ) - # Update .gitignore only for project-scoped installs (#1577). - # Global installs (InstallScope.USER) must not touch the CWD. - if scope == InstallScope.PROJECT: + # Update .gitignore only for project-scoped installs, not in lockfile_only mode. + if scope == InstallScope.PROJECT and not lockfile_only: from apm_cli.commands._helpers import _update_gitignore_for_apm_modules _update_gitignore_for_apm_modules(logger=logger) - elif verbose and logger is not None: + elif verbose and logger is not None and not lockfile_only: logger.verbose_detail("Skipping .gitignore update (global scope install).") # ------------------------------------------------------------------ - # Phase: Orphan cleanup + intra-package stale-file cleanup - # All deletions routed through integration/cleanup.py (#762). + # Phase: Orphan cleanup + intra-package stale-file cleanup. + # Skipped in lockfile_only mode -- no files were deployed. # ------------------------------------------------------------------ - from .phases import cleanup as _cleanup_phase + if not lockfile_only: + from .phases import cleanup as _cleanup_phase - _run_phase("cleanup", _cleanup_phase, ctx) + _run_phase("cleanup", _cleanup_phase, ctx) # ------------------------------------------------------------------ - # Phase: Skill path auto-migration (#737) - # After integrate wrote new .agents/skills/ files and cleanup - # removed orphans, migrate any legacy per-client skill paths - # still recorded in the lockfile (e.g. .github/skills/ -> - # .agents/skills/). Mutates existing_lockfile.deployed_files - # in place so the downstream lockfile phase persists the new paths. - # Skipped when --legacy-skill-paths is active (opt-out). + # Phase: Skill path auto-migration (#737). + # Skipped in lockfile_only mode. # ------------------------------------------------------------------ - if not ctx.legacy_skill_paths and ctx.existing_lockfile and not ctx.dry_run: + if ( + not ctx.legacy_skill_paths + and ctx.existing_lockfile + and not ctx.dry_run + and not lockfile_only + ): from apm_cli.utils.console import _rich_info, _rich_warning from .skill_path_migration import ( @@ -787,29 +785,25 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 LockfileBuilder(ctx).build_and_save() # ------------------------------------------------------------------ - # Phase: Post-deps local .apm/ content -- stale cleanup + - # lockfile persistence for the project's own .apm/ primitives. - # Runs after the dep lockfile so it can read-modify-write the - # lockfile with local_deployed_files / hashes. All deletions - # routed through integration/cleanup.py (#762). + # Phase: Post-deps local .apm/ content. + # Skipped in lockfile_only mode -- no file deployment occurred. # ------------------------------------------------------------------ - from .phases import post_deps_local as _post_deps_local_phase + if not lockfile_only: + from .phases import post_deps_local as _post_deps_local_phase - _run_phase("post_deps_local", _post_deps_local_phase, ctx) + _run_phase("post_deps_local", _post_deps_local_phase, ctx) # ------------------------------------------------------------------ # Phase: Optional install-time content audit (external_scanners flag). - # Runs after the lockfile + local content are persisted so deployed - # files are enumerable, and before finalize. No-op unless config / - # policy opt in (default off). A ``block`` decision raises - # PolicyViolationError, re-raised by the outer handler below. + # Skipped in lockfile_only mode -- no files were deployed. # ------------------------------------------------------------------ - from .phases import audit as _audit_phase + if not lockfile_only: + from .phases import audit as _audit_phase - try: - _run_phase("audit", _audit_phase, ctx) - except PolicyViolationError: - raise + try: + _run_phase("audit", _audit_phase, ctx) + except PolicyViolationError: + raise # Emit verbose integration stats + bare-success fallback + return result from .phases import finalize as _finalize_phase diff --git a/src/apm_cli/install/request.py b/src/apm_cli/install/request.py index 68c9ed7f1..31270dbea 100644 --- a/src/apm_cli/install/request.py +++ b/src/apm_cli/install/request.py @@ -53,6 +53,11 @@ class InstallRequest: # the pipeline, so the failure surfaces without running resolve. frozen: bool = False + # --lockfile-only: resolve and download deps to get commit SHAs, then + # write apm.lock.yaml WITHOUT deploying any files to targets. Set + # internally by the ``apm lock`` command (mirrors cargo generate-lockfile / pnpm lock). + lockfile_only: bool = False + # --refresh: re-resolve all refs against upstream (bypass lockfile # pins). Unlike --update (which restructures the whole graph), # --refresh only forces re-resolution without discarding orphans. diff --git a/src/apm_cli/install/service.py b/src/apm_cli/install/service.py index e4ab06e10..f01b75247 100644 --- a/src/apm_cli/install/service.py +++ b/src/apm_cli/install/service.py @@ -92,6 +92,7 @@ def run(self, request: InstallRequest) -> InstallResult: legacy_skill_paths=request.legacy_skill_paths, plan_callback=request.plan_callback, refresh=request.refresh, + lockfile_only=request.lockfile_only, ) @staticmethod diff --git a/src/apm_cli/install/sources.py b/src/apm_cli/install/sources.py index 000b7e5d4..4f8a81d81 100644 --- a/src/apm_cli/install/sources.py +++ b/src/apm_cli/install/sources.py @@ -424,7 +424,9 @@ def acquire(self) -> Materialization | None: # write the empty deployed_files entry on its own (single source # of truth), so we just signal "skip integration" via # package_info=None. - if not ctx.targets: + # In lockfile_only mode, skip this early return so installed_packages + # is populated before we return without deploying any files. + if not ctx.targets and not ctx.lockfile_only: return Materialization( package_info=None, install_path=install_path, @@ -529,6 +531,15 @@ def acquire(self) -> Materialization | None: if cached_package_info.package_type: ctx.package_types[dep_key] = cached_package_info.package_type.value + # Return without deploying integration files when the target set is empty. + if not ctx.targets: + return Materialization( + package_info=None, + install_path=install_path, + dep_key=dep_key, + deltas=deltas, + ) + return Materialization( package_info=cached_package_info, install_path=install_path, diff --git a/tests/integration/test_lock_e2e.py b/tests/integration/test_lock_e2e.py new file mode 100644 index 000000000..844b61efa --- /dev/null +++ b/tests/integration/test_lock_e2e.py @@ -0,0 +1,193 @@ +"""Integration tests for the ``apm lock`` command. + +Issue: https://github.com/microsoft/apm/issues/975 + +Validates the end-to-end behaviour of ``apm lock``: + +* A project with no dependencies produces a valid ``apm.lock.yaml`` and + exits 0. +* ``apm lock`` with a local-path dependency writes the dependency entry + to the lockfile without deploying any files. +* The lockfile is written to the project root and is idempotent + (running again with unchanged deps overwrites with the same content). + +These tests are hermetic: no network access is required. Local-path +dependencies are used wherever a real dependency is needed. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +pytestmark = pytest.mark.requires_apm_binary + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def apm_command() -> str: + apm_on_path = shutil.which("apm") + if apm_on_path: + return apm_on_path + venv_apm = Path(__file__).parent.parent.parent / ".venv" / "bin" / "apm" + if venv_apm.exists(): + return str(venv_apm) + return "apm" + + +def _run_apm( + apm_command: str, + args: list[str], + cwd: Path, + timeout: int = 60, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [apm_command, *args], + cwd=str(cwd), + capture_output=True, + text=True, + timeout=timeout, + ) + + +def _write_apm_yml(project_dir: Path, deps: list[str] | None = None) -> None: + config: dict = { + "name": "lock-test-project", + "version": "1.0.0", + "dependencies": {"apm": deps or [], "mcp": []}, + } + (project_dir / "apm.yml").write_text( + yaml.dump(config, default_flow_style=False), encoding="utf-8" + ) + + +def _make_local_package(pkg_dir: Path, name: str) -> None: + pkg_dir.mkdir(parents=True, exist_ok=True) + (pkg_dir / "apm.yml").write_text( + yaml.dump( + { + "name": name, + "version": "1.0.0", + "description": f"Local test package {name}", + } + ), + encoding="utf-8", + ) + instructions = pkg_dir / ".apm" / "instructions" + instructions.mkdir(parents=True, exist_ok=True) + (instructions / "test.instructions.md").write_text( + "---\napplyTo: '**'\n---\n# Test\n", encoding="utf-8" + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestLockEmptyDeps: + def test_no_deps_creates_lockfile_and_exits_zero( + self, apm_command: str, tmp_path: Path + ) -> None: + """A project with no dependencies should produce apm.lock.yaml and + exit 0 -- the core promise of the lockfile-only path.""" + project = tmp_path / "project" + project.mkdir() + _write_apm_yml(project) + + result = _run_apm(apm_command, ["lock"], project) + + assert result.returncode == 0, ( + f"apm lock exited {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + lockfile = project / "apm.lock.yaml" + assert lockfile.exists(), "apm.lock.yaml must be created even with no dependencies" + + def test_no_deps_lockfile_is_valid_yaml(self, apm_command: str, tmp_path: Path) -> None: + """The produced lockfile must be parseable YAML.""" + project = tmp_path / "project" + project.mkdir() + _write_apm_yml(project) + + _run_apm(apm_command, ["lock"], project) + + lockfile = project / "apm.lock.yaml" + content = lockfile.read_text(encoding="utf-8") + parsed = yaml.safe_load(content) + assert isinstance(parsed, dict), "apm.lock.yaml must be a YAML mapping" + + +class TestLockLocalDep: + def test_local_dep_writes_lockfile_without_deploying( + self, apm_command: str, tmp_path: Path + ) -> None: + """apm lock with a local-path dep must write the lockfile but NOT + copy any files to .github/, .agents/, or similar harness dirs.""" + project = tmp_path / "project" + project.mkdir() + + pkg_dir = tmp_path / "my-skills" + _make_local_package(pkg_dir, "my-skills") + + _write_apm_yml(project, deps=[f"local:{pkg_dir}"]) + + result = _run_apm(apm_command, ["lock"], project) + + assert result.returncode == 0, ( + f"apm lock exited {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + lockfile = project / "apm.lock.yaml" + assert lockfile.exists(), "apm.lock.yaml must be written for local deps" + + github_dir = project / ".github" + assert not github_dir.exists(), ( + "apm lock must NOT deploy files to .github/ -- only the lockfile should be written" + ) + agents_dir = project / ".agents" + assert not agents_dir.exists(), ( + "apm lock must NOT deploy files to .agents/ -- only the lockfile should be written" + ) + + def test_local_dep_lockfile_records_dependency(self, apm_command: str, tmp_path: Path) -> None: + """The lockfile written by apm lock must record the local dependency.""" + project = tmp_path / "project" + project.mkdir() + + pkg_dir = tmp_path / "my-skills" + _make_local_package(pkg_dir, "my-skills") + + _write_apm_yml(project, deps=[f"local:{pkg_dir}"]) + _run_apm(apm_command, ["lock"], project) + + lockfile = project / "apm.lock.yaml" + content = lockfile.read_text(encoding="utf-8") + parsed = yaml.safe_load(content) + assert isinstance(parsed, dict) + deps = parsed.get("dependencies", {}) + assert len(deps) > 0, "lockfile must record at least one dependency entry for the local dep" + + def test_lock_is_idempotent(self, apm_command: str, tmp_path: Path) -> None: + """Running apm lock twice with unchanged deps produces the same lockfile.""" + project = tmp_path / "project" + project.mkdir() + + pkg_dir = tmp_path / "my-skills" + _make_local_package(pkg_dir, "my-skills") + _write_apm_yml(project, deps=[f"local:{pkg_dir}"]) + + _run_apm(apm_command, ["lock"], project) + first = (project / "apm.lock.yaml").read_text(encoding="utf-8") + + _run_apm(apm_command, ["lock"], project) + second = (project / "apm.lock.yaml").read_text(encoding="utf-8") + + assert first == second, "apm lock must be idempotent -- same lockfile on second run" diff --git a/tests/unit/commands/test_lock_command.py b/tests/unit/commands/test_lock_command.py new file mode 100644 index 000000000..f6a7d4130 --- /dev/null +++ b/tests/unit/commands/test_lock_command.py @@ -0,0 +1,255 @@ +"""Unit tests for the ``apm lock`` Click command. + +Issue: https://github.com/microsoft/apm/issues/975 + +Focus areas: +* lockfile_only=True is forwarded to _install_apm_dependencies. +* No apm.yml -> error + exit 1. +* Empty deps -> pipeline called (no-op resolution), exits 0. Lockfile + write is not asserted at the unit tier (integration test covers it). +* Auth errors and policy violations surface correctly. +* --global scope wires up the user-scope manifest path. +* --update forwarded as update_refs=True. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from apm_cli.cli import cli + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _make_apm_yml(project_dir: Path, *, with_deps: bool = True) -> None: + if with_deps: + content = "name: test\nversion: 1.0.0\ndependencies:\n apm:\n - microsoft/apm\n" + else: + content = "name: test\nversion: 1.0.0\n" + (project_dir / "apm.yml").write_text(content) + + +def _make_install_result(): + from apm_cli.models.results import InstallResult + + return InstallResult(installed_count=1) + + +# --------------------------------------------------------------------------- +# Basic invocation +# --------------------------------------------------------------------------- + + +class TestLockBasic: + def test_lock_passes_lockfile_only_flag(self, runner, tmp_path): + """_install_apm_dependencies must receive lockfile_only=True.""" + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd()) + captured = {} + + def fake_install(_apm, **kwargs): + captured["lockfile_only"] = kwargs.get("lockfile_only") + return _make_install_result() + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + side_effect=fake_install, + ): + result = runner.invoke(cli, ["lock"]) + + assert result.exit_code == 0, result.output + assert captured["lockfile_only"] is True + + def test_lock_success_prints_message(self, runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd()) + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + return_value=_make_install_result(), + ): + result = runner.invoke(cli, ["lock"]) + + assert result.exit_code == 0, result.output + assert "apm.lock.yaml" in result.output + + def test_lock_no_apm_yml_exits_with_error(self, runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path): + result = runner.invoke(cli, ["lock"]) + assert result.exit_code == 1 + assert "No apm.yml" in result.output or "apm.yml" in result.output + + +# --------------------------------------------------------------------------- +# Flag forwarding +# --------------------------------------------------------------------------- + + +class TestLockFlagForwarding: + def test_verbose_forwarded(self, runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd()) + captured = {} + + def fake_install(_apm, **kwargs): + captured["verbose"] = kwargs.get("verbose") + return _make_install_result() + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + side_effect=fake_install, + ): + result = runner.invoke(cli, ["lock", "--verbose"]) + + assert result.exit_code == 0, result.output + assert captured["verbose"] is True + + def test_update_forwarded_as_update_refs(self, runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd()) + captured = {} + + def fake_install(_apm, **kwargs): + captured["update_refs"] = kwargs.get("update_refs") + return _make_install_result() + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + side_effect=fake_install, + ): + result = runner.invoke(cli, ["lock", "--update"]) + + assert result.exit_code == 0, result.output + assert captured["update_refs"] is True + + def test_no_policy_forwarded(self, runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd()) + captured = {} + + def fake_install(_apm, **kwargs): + captured["no_policy"] = kwargs.get("no_policy") + return _make_install_result() + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + side_effect=fake_install, + ): + result = runner.invoke(cli, ["lock", "--no-policy"]) + + assert result.exit_code == 0, result.output + assert captured["no_policy"] is True + + def test_parallel_downloads_forwarded(self, runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd()) + captured = {} + + def fake_install(_apm, **kwargs): + captured["parallel_downloads"] = kwargs.get("parallel_downloads") + return _make_install_result() + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + side_effect=fake_install, + ): + result = runner.invoke(cli, ["lock", "--parallel-downloads", "2"]) + + assert result.exit_code == 0, result.output + assert captured["parallel_downloads"] == 2 + + +# --------------------------------------------------------------------------- +# Empty deps +# --------------------------------------------------------------------------- + + +class TestLockEmptyDeps: + def test_no_deps_still_exits_zero(self, runner, tmp_path): + """A project with no APM deps should write an empty lockfile and exit 0.""" + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd(), with_deps=False) + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + return_value=_make_install_result(), + ): + result = runner.invoke(cli, ["lock"]) + + assert result.exit_code == 0, result.output + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestLockErrorHandling: + def test_auth_error_exits_one(self, runner, tmp_path): + from apm_cli.install.errors import AuthenticationError + + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd()) + err = AuthenticationError("bad token") + err.diagnostic_context = None + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + side_effect=err, + ): + result = runner.invoke(cli, ["lock"]) + + assert result.exit_code == 1 + + def test_policy_violation_exits_one(self, runner, tmp_path): + from apm_cli.install.errors import PolicyViolationError + + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd()) + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + side_effect=PolicyViolationError("blocked"), + ): + result = runner.invoke(cli, ["lock"]) + + assert result.exit_code == 1 + + def test_generic_error_exits_one(self, runner, tmp_path): + with runner.isolated_filesystem(temp_dir=tmp_path): + _make_apm_yml(Path.cwd()) + + with patch( + "apm_cli.commands.install._install_apm_dependencies", + side_effect=RuntimeError("network failure"), + ): + result = runner.invoke(cli, ["lock"]) + + assert result.exit_code == 1 + assert "Error generating lockfile" in result.output + + +# --------------------------------------------------------------------------- +# Global scope +# --------------------------------------------------------------------------- + + +class TestLockGlobalScope: + def test_global_no_apm_yml_exits_one(self, runner, tmp_path): + """--global with no ~/.apm/apm.yml should exit 1.""" + with runner.isolated_filesystem(temp_dir=tmp_path): + with patch("apm_cli.core.scope.get_apm_dir") as mock_dir: + fake_dir = tmp_path / "dot-apm" + fake_dir.mkdir() + mock_dir.return_value = fake_dir + result = runner.invoke(cli, ["lock", "--global"]) + + assert result.exit_code == 1 + assert "apm.yml" in result.output diff --git a/tests/unit/install/test_cached_source_lockfile_only.py b/tests/unit/install/test_cached_source_lockfile_only.py new file mode 100644 index 000000000..4353d13ca --- /dev/null +++ b/tests/unit/install/test_cached_source_lockfile_only.py @@ -0,0 +1,98 @@ +"""Regression test for CachedDependencySource.acquire() lockfile_only guard. + +Regression for #975 (PR #1639): +When ctx.lockfile_only=True and ctx.targets=[], the first early-return +guard was ``if not ctx.targets:`` which fired before installed_packages +was populated. The fix adds ``and not ctx.lockfile_only`` so the SHA +recording proceeds before the second guard returns without deploying. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from apm_cli.install.sources import CachedDependencySource + + +def _make_source( + *, + lockfile_only: bool, + install_path: Path, +) -> CachedDependencySource: + ctx = MagicMock() + ctx.targets = [] + ctx.lockfile_only = lockfile_only + ctx.logger = MagicMock() + ctx.callback_downloaded = {} + ctx.existing_lockfile = None + ctx.dependency_graph = MagicMock() + ctx.dependency_graph.dependency_tree.get_node.return_value = None + ctx.registry_config = None + ctx.git_semver_resolutions = {} + ctx.installed_packages = [] + ctx.package_hashes = {} + ctx.package_types = {} + + dep_ref = MagicMock() + dep_ref.is_virtual = False + dep_ref.is_local = False + dep_ref.local_path = None + dep_ref.repo_url = "https://github.com/owner/repo" + dep_ref.reference = "v1.0.0" + dep_ref.source = "github" + + dep_locked_chk = MagicMock() + dep_locked_chk.resolved_commit = "abcd1234deadbeef1234" + dep_locked_chk.registry_prefix = None + + return CachedDependencySource( + ctx=ctx, + dep_ref=dep_ref, + install_path=install_path, + dep_key="owner/repo@v1.0.0", + resolved_ref=None, + dep_locked_chk=dep_locked_chk, + fetched_this_run=False, + ) + + +class TestCachedSourceLockfileOnly: + def test_lockfile_only_populates_installed_packages(self, tmp_path: Path) -> None: + """In lockfile_only mode, acquire() must record in installed_packages + even when targets=[] (regression for the guard bug in #975).""" + install_path = tmp_path / "pkg" + install_path.mkdir() + + src = _make_source(lockfile_only=True, install_path=install_path) + + with ( + patch("apm_cli.models.validation.detect_package_type", return_value=(None, None)), + patch("apm_cli.utils.content_hash.compute_package_hash", return_value="fakehash"), + patch( + "apm_cli.install.sources._rebuild_cached_semver_resolution", + return_value=None, + ), + ): + src.acquire() + + assert len(src.ctx.installed_packages) == 1, ( + "lockfile_only=True with empty targets must populate installed_packages " + "before the second early-return guard fires" + ) + + def test_non_lockfile_only_empty_targets_exits_before_installed_packages( + self, tmp_path: Path + ) -> None: + """Without lockfile_only, empty targets must early-return before + recording installed_packages (pre-existing behaviour verified).""" + install_path = tmp_path / "pkg" + install_path.mkdir() + + src = _make_source(lockfile_only=False, install_path=install_path) + src.acquire() + + assert len(src.ctx.installed_packages) == 0, ( + "non-lockfile_only with empty targets should exit early " + "without populating installed_packages" + )