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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<package>` -- no manual `mcp.json` editing required. GitHub Copilot for
Expand Down
97 changes: 97 additions & 0 deletions docs/src/content/docs/reference/cli/lock.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
3 changes: 3 additions & 0 deletions src/apm_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions src/apm_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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)
206 changes: 206 additions & 0 deletions src/apm_cli/commands/lock.py
Original file line number Diff line number Diff line change
@@ -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.

Comment on lines +19 to +24
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."
),
)
Comment on lines +110 to +119
@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 <org/repo>' 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"]
Loading
Loading