diff --git a/CHANGELOG.md b/CHANGELOG.md index 2954c4d5..d10313a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Lockfile renamed from `apm.lock` to `apm.lock.yaml` for IDE syntax highlighting; existing `apm.lock` files are automatically migrated to `apm.lock.yaml` on the next `apm install` (#280) + ## [0.7.8] - 2026-03-13 ### Added diff --git a/docs/src/content/docs/enterprise/adoption-playbook.md b/docs/src/content/docs/enterprise/adoption-playbook.md index 27d57da4..45db0565 100644 --- a/docs/src/content/docs/enterprise/adoption-playbook.md +++ b/docs/src/content/docs/enterprise/adoption-playbook.md @@ -40,7 +40,7 @@ Confirm these prerequisites before kicking off Phase 1: ``` 5. Run `apm install` to deploy files. -6. Commit `apm.yml` and `apm.lock` to the repository. +6. Commit `apm.yml` and `apm.lock.yaml` to the repository. ### Verification @@ -61,7 +61,7 @@ single command. ### What to Watch - Installation friction (missing runtimes, network issues). -- Unexpected file placement -- review `apm.lock` to confirm paths. +- Unexpected file placement -- review `apm.lock.yaml` to confirm paths. - Authentication errors when pulling private packages. --- @@ -98,7 +98,7 @@ automatically. ### What to Watch -- Version pinning: confirm that `apm.lock` captures the exact version +- Version pinning: confirm that `apm.lock.yaml` captures the exact version installed. - File collisions: if the shared package deploys a file that already exists, decide whether to force-overwrite or skip. @@ -129,7 +129,7 @@ reach production. ``` 2. Make the audit step a **required status check** on pull requests. -3. Ensure `apm.lock` is committed. Any version drift will cause the audit +3. Ensure `apm.lock.yaml` is committed. Any version drift will cause the audit to fail, surfacing the problem before merge. ### Success Metric @@ -229,7 +229,7 @@ application. Maintenance cost is limited to updating package versions in ### "What if we stop using it?" -Delete `apm.yml` and `apm.lock`. The native configuration files APM +Delete `apm.yml` and `apm.lock.yaml`. The native configuration files APM deployed remain in place and continue to work exactly as they did before. There is no lock-in. @@ -246,7 +246,7 @@ go back to manual configuration. At any phase, you can reverse course: -1. Remove `apm.yml` and `apm.lock` from the repository. +1. Remove `apm.yml` and `apm.lock.yaml` from the repository. 2. The configuration files APM deployed remain on disk and continue to function. Your tools read native files, not APM-specific formats. 3. Optionally, remove APM from CI steps. diff --git a/docs/src/content/docs/enterprise/governance.md b/docs/src/content/docs/enterprise/governance.md index f53a6bde..59144cb5 100644 --- a/docs/src/content/docs/enterprise/governance.md +++ b/docs/src/content/docs/enterprise/governance.md @@ -23,13 +23,13 @@ APM addresses these by treating agent configuration as auditable infrastructure, Agent governance in APM follows a four-stage pipeline: ``` -apm.yml (declare) -> apm.lock (pin) -> apm audit (verify) -> CI gate (enforce) +apm.yml (declare) -> apm.lock.yaml (pin) -> apm audit (verify) -> CI gate (enforce) ``` | Stage | Purpose | Artifact | |-------|---------|----------| | **Declare** | Define dependencies and their sources | `apm.yml` | -| **Pin** | Resolve every dependency to an exact commit | `apm.lock` | +| **Pin** | Resolve every dependency to an exact commit | `apm.lock.yaml` | | **Verify** | Confirm on-disk state matches the lock file | `apm audit` output | | **Enforce** | Block merges when verification fails | Required status check | @@ -39,7 +39,7 @@ Each stage builds on the previous one. The lock file provides the audit trail, t ## Lock file as audit trail -The `apm.lock` file is the single source of truth for what agent configuration is deployed. Every dependency is pinned to an exact commit SHA, making the lock file a complete, point-in-time record of agent state. +The `apm.lock.yaml` file is the single source of truth for what agent configuration is deployed. Every dependency is pinned to an exact commit SHA, making the lock file a complete, point-in-time record of agent state. ### What the lock file captures @@ -77,23 +77,23 @@ Key fields for governance: ### Using git history for auditing -Because `apm.lock` is a committed file, standard git operations answer governance questions directly: +Because `apm.lock.yaml` is a committed file, standard git operations answer governance questions directly: ```bash # Full history of every agent configuration change -git log --oneline apm.lock +git log --oneline apm.lock.yaml # Who changed agent config, and when -git log --format="%h %ai %an: %s" apm.lock +git log --format="%h %ai %an: %s" apm.lock.yaml # What was the exact agent configuration at release v4.2.1 -git show v4.2.1:apm.lock +git show v4.2.1:apm.lock.yaml # Diff agent config between two releases -git diff v4.1.0..v4.2.1 -- apm.lock +git diff v4.1.0..v4.2.1 -- apm.lock.yaml # Find the commit that introduced a specific dependency -git log -p --all -S 'contoso/agent-standards' -- apm.lock +git log -p --all -S 'contoso/agent-standards' -- apm.lock.yaml ``` No additional tooling is required. The lock file turns git into an agent configuration audit log. @@ -122,7 +122,7 @@ on: pull_request: paths: - 'apm.yml' - - 'apm.lock' + - 'apm.lock.yaml' - '.github/agents/**' - '.github/skills/**' - '.copilot/agents/**' @@ -217,7 +217,7 @@ Combine with GitHub's CODEOWNERS to require security team approval for changes t ``` # CODEOWNERS apm.yml @contoso/platform-engineering -apm.lock @contoso/platform-engineering +apm.lock.yaml @contoso/platform-engineering ``` ### Version pinning policy @@ -303,9 +303,9 @@ For detailed setup instructions, see the [CI/CD integration guide](../../integra SOC 2 audits require evidence that configuration changes are authorized and traceable. APM's lock file provides this: -- **Change authorization.** Every `apm.lock` change goes through a PR, requiring review and approval. -- **Change history.** `git log apm.lock` produces a complete, tamper-evident history of every agent configuration change with author, timestamp, and diff. -- **Point-in-time state.** `git show :apm.lock` reconstructs the exact agent configuration active at any release. +- **Change authorization.** Every `apm.lock.yaml` change goes through a PR, requiring review and approval. +- **Change history.** `git log apm.lock.yaml` produces a complete, tamper-evident history of every agent configuration change with author, timestamp, and diff. +- **Point-in-time state.** `git show :apm.lock.yaml` reconstructs the exact agent configuration active at any release. Link auditors directly to the lock file history in your repository. No separate audit system is needed. @@ -315,13 +315,13 @@ When a security review requires understanding what instructions agents were foll ```bash # What agent configuration was active at the time of the incident -git show :apm.lock +git show :apm.lock.yaml # What files were deployed by a specific package -grep -A 10 'contoso/agent-standards' apm.lock +grep -A 10 'contoso/agent-standards' apm.lock.yaml # Full diff of agent config changes in the last 90 days -git log --since="90 days ago" -p -- apm.lock +git log --since="90 days ago" -p -- apm.lock.yaml ``` The lock file answers "what was running" without requiring access to the original package repositories. The `resolved_commit` field points to the exact source code that was deployed. @@ -331,9 +331,9 @@ The lock file answers "what was running" without requiring access to the origina APM enforces change management by design: 1. **Declaration.** Changes start in `apm.yml`, which is a committed, reviewable file. -2. **Resolution.** `apm install` resolves declarations to exact commits in `apm.lock`. +2. **Resolution.** `apm install` resolves declarations to exact commits in `apm.lock.yaml`. 3. **Review.** Both files are included in the PR diff for peer review. -4. **Verification.** `apm audit --ci` confirms consistency before merge (planned — currently achieved through PR review of `apm.lock` diffs). +4. **Verification.** `apm audit --ci` confirms consistency before merge (planned — currently achieved through PR review of `apm.lock.yaml` diffs). 5. **Traceability.** Git history provides a permanent record of who changed what and when. No agent configuration change can reach a protected branch without passing through this pipeline. @@ -344,8 +344,8 @@ No agent configuration change can reach a protected branch without passing throu | Capability | Mechanism | Status | |---|---|---| -| Dependency pinning | `apm.lock` with exact commit SHAs | Available | -| Audit trail | Git history of `apm.lock` | Available | +| Dependency pinning | `apm.lock.yaml` with exact commit SHAs | Available | +| Audit trail | Git history of `apm.lock.yaml` | Available | | Constitution injection | `memory/constitution.md` with hash verification | Available | | Transitive MCP trust control | `--trust-transitive-mcp` flag | Available | | CI enforcement | `apm audit --ci` as required status check | Planned | diff --git a/docs/src/content/docs/enterprise/making-the-case.md b/docs/src/content/docs/enterprise/making-the-case.md index 9a182dc1..cf0cd78b 100644 --- a/docs/src/content/docs/enterprise/making-the-case.md +++ b/docs/src/content/docs/enterprise/making-the-case.md @@ -23,11 +23,11 @@ An internal advocacy toolkit for APM. Each section is self-contained and designe - **Developer productivity.** Eliminate manual setup of AI agent configurations. New developers run `apm install` and get a working environment in seconds instead of following multi-step setup guides. - **Consistency across teams.** A single shared package ensures every team uses the same coding standards, prompts, and tool configurations. Updates propagate with a version bump, not a Slack message. -- **Audit trail for compliance.** Every change to agent configuration is tracked through `apm.lock` and git history. You can answer "what changed, when, and why" for any audit. +- **Audit trail for compliance.** Every change to agent configuration is tracked through `apm.lock.yaml` and git history. You can answer "what changed, when, and why" for any audit. ### For Security and Compliance -- **Lock file integrity.** `apm.lock` pins exact versions and commit SHAs for every dependency. No silent updates, no supply chain surprises. +- **Lock file integrity.** `apm.lock.yaml` pins exact versions and commit SHAs for every dependency. No silent updates, no supply chain surprises. - **Dependency provenance.** Every package resolves to a specific git repository and commit. The full dependency tree is inspectable before installation. - **No code execution, no runtime.** APM is a dev-time tool only. It copies configuration files — it does not execute code, run background processes, or modify your application at runtime. - **Full audit trail.** All configuration changes are committed to git. Compliance teams can review agent setup changes through standard code review processes. @@ -76,7 +76,7 @@ Installation is a single binary with no system dependencies. Updates are a binar APM outputs native configuration formats: `.github/instructions/`, `.github/prompts/`, `.claude/`, `AGENTS.md`. These are standard files that your AI tools read directly. -If you stop using APM, delete `apm.yml` and `apm.lock`. Your configuration files remain and continue to work. Zero lock-in by design. +If you stop using APM, delete `apm.yml` and `apm.lock.yaml`. Your configuration files remain and continue to work. Zero lock-in by design. ### "We only use one AI tool, not multiple." @@ -115,7 +115,7 @@ This is a deliberate design choice. APM adds value on top of native formats rath The following is ready to copy into an internal proposal or RFC: -> We propose adopting APM (Agent Package Manager) to manage AI agent configuration across our repositories. APM is an open-source, dev-time tool that provides a declarative manifest (`apm.yml`) and lock file (`apm.lock`) for AI coding agent setup — instructions, prompts, skills, plugins, and MCP servers. It resolves dependencies, generates native configuration files for each AI platform, and produces reproducible installs from locked versions. APM has zero runtime footprint: it runs during setup and CI, outputs standard config files, and introduces no vendor lock-in. Adopting APM will eliminate manual agent setup for new developers, enforce consistent configuration across teams, and provide an auditable record of all agent configuration changes through git history. The tool is MIT-licensed, maintained under the Microsoft GitHub organization, and supports GitHub, GitLab, Bitbucket, and Azure DevOps as package sources. +> We propose adopting APM (Agent Package Manager) to manage AI agent configuration across our repositories. APM is an open-source, dev-time tool that provides a declarative manifest (`apm.yml`) and lock file (`apm.lock.yaml`) for AI coding agent setup — instructions, prompts, skills, plugins, and MCP servers. It resolves dependencies, generates native configuration files for each AI platform, and produces reproducible installs from locked versions. APM has zero runtime footprint: it runs during setup and CI, outputs standard config files, and introduces no vendor lock-in. Adopting APM will eliminate manual agent setup for new developers, enforce consistent configuration across teams, and provide an auditable record of all agent configuration changes through git history. The tool is MIT-licensed, maintained under the Microsoft GitHub organization, and supports GitHub, GitLab, Bitbucket, and Azure DevOps as package sources. --- @@ -131,7 +131,7 @@ For stakeholders familiar with existing tools, this comparison clarifies where A | Dependency resolution | Manual | None | Automatic, transitive | | CI enforcement | Custom scripts | Not available | Planned (`apm audit --ci`) | | Shared org standards | Wiki pages, copy-paste | Not available | Versioned packages | -| Audit trail | Implicit via git | Varies by vendor | Explicit via `apm.lock` | +| Audit trail | Implicit via git | Varies by vendor | Explicit via `apm.lock.yaml` | | Lock-in | To manual process | To specific vendor | None (native output files) | --- @@ -172,7 +172,7 @@ With APM, setup reduces to `apm install` (under 30 seconds). Standards updates r | New developer onboarding | Follow a setup doc, troubleshoot differences | `git clone && apm install` | | CI reproducibility | "Worked locally" debugging | Locked versions, identical environments | | Adding a new MCP server to all repos | Manual config in each repo, inconsistent rollout | Add to shared package, teams pull on next install | -| Auditing agent configuration | Grep across repos, compare manually | Review `apm.lock` diffs in git history | +| Auditing agent configuration | Grep across repos, compare manually | Review `apm.lock.yaml` diffs in git history | --- diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md index 37f77ee1..22c96b13 100644 --- a/docs/src/content/docs/enterprise/security.md +++ b/docs/src/content/docs/enterprise/security.md @@ -14,7 +14,7 @@ APM is a build-time dependency manager for AI prompts and configuration. It perf 1. **Resolves git repositories** — clones or sparse-checks-out packages from GitHub or Azure DevOps. 2. **Deploys static files** — copies markdown, JSON, and YAML files into project directories (`.github/`, `.claude/`). 3. **Generates compiled output** — produces `AGENTS.md`, `CLAUDE.md`, and similar files from templates and prompts. -4. **Records a lock file** — writes `apm.lock` with exact commit SHAs for every resolved dependency. +4. **Records a lock file** — writes `apm.lock.yaml` with exact commit SHAs for every resolved dependency. ## What APM does NOT do @@ -33,7 +33,7 @@ APM resolves dependencies directly from git repositories. There is no intermedia ### Exact commit pinning -Every resolved dependency is recorded in `apm.lock` with its full commit SHA: +Every resolved dependency is recorded in `apm.lock.yaml` with its full commit SHA: ```yaml lockfile_version: "1" @@ -55,7 +55,7 @@ APM does not use a package registry. Dependencies are specified as git repositor ### Reproducible installs -Given the same `apm.lock`, `apm install` produces identical file output regardless of when or where it runs. The lock file is the single source of truth for dependency state. +Given the same `apm.lock.yaml`, `apm install` produces identical file output regardless of when or where it runs. The lock file is the single source of truth for dependency state. ## Path security @@ -133,7 +133,7 @@ APM authenticates to git hosts using personal access tokens (PATs) read from env ### Security properties -- **Never stored in files.** Tokens are read from the environment at runtime. They are never written to `apm.yaml`, `apm.lock`, or any generated file. +- **Never stored in files.** Tokens are read from the environment at runtime. They are never written to `apm.yaml`, `apm.lock.yaml`, or any generated file. - **Never logged.** Token values are not included in console output, error messages, or debug logs. - **Scoped to their git host.** A GitHub token is only sent to GitHub. An Azure DevOps token is only sent to Azure DevOps. Tokens are never transmitted to any other endpoint. @@ -157,17 +157,17 @@ APM's design eliminates several supply chain attack vectors common in traditiona ### Auditing dependency changes -Because `apm.lock` is a plain YAML file checked into version control, standard git tooling provides a full audit trail: +Because `apm.lock.yaml` is a plain YAML file checked into version control, standard git tooling provides a full audit trail: ```bash # View all dependency changes over time -git log --oneline apm.lock +git log --oneline apm.lock.yaml # See exactly what changed in a specific commit -git diff HEAD~1 -- apm.lock +git diff HEAD~1 -- apm.lock.yaml # Find when a specific dependency was added -git log --all -p -- apm.lock | grep -A5 "owner/repo" +git log --all -p -- apm.lock.yaml | grep -A5 "owner/repo" ``` ### Pinning and updates @@ -196,7 +196,7 @@ Not by default. Transitive MCP server declarations are blocked unless you explic ### How do I audit what APM installed? -The `apm.lock` file records every dependency (with exact commit SHA) and every file deployed. It is a plain YAML file suitable for automated policy checks, diff review, and compliance tooling. +The `apm.lock.yaml` file records every dependency (with exact commit SHA) and every file deployed. It is a plain YAML file suitable for automated policy checks, diff review, and compliance tooling. ### Is the APM binary signed? diff --git a/docs/src/content/docs/enterprise/teams.md b/docs/src/content/docs/enterprise/teams.md index 9dece5a2..d0ffb619 100644 --- a/docs/src/content/docs/enterprise/teams.md +++ b/docs/src/content/docs/enterprise/teams.md @@ -6,7 +6,7 @@ sidebar: --- APM is an open-source dependency manager for AI agent configuration. -One manifest (`apm.yml`), one command (`apm install`), locked versions (`apm.lock`). +One manifest (`apm.yml`), one command (`apm install`), locked versions (`apm.lock.yaml`). Every developer gets the same agent setup. Every CI run is reproducible. Every configuration change is auditable. @@ -48,10 +48,10 @@ This file is version-controlled, reviewed in pull requests, and readable by anyo ### Lock -Running `apm install` resolves versions and writes `apm.lock`, which pins the exact version of every dependency. The lock file is committed to the repository. +Running `apm install` resolves versions and writes `apm.lock.yaml`, which pins the exact version of every dependency. The lock file is committed to the repository. ``` -# apm.lock (auto-generated) +# apm.lock.yaml (auto-generated) org-security-rules==2.1.0 team-coding-standards==1.3.4 project-context==local @@ -65,11 +65,11 @@ Two developers running `apm install` from the same lock file get identical confi ### Audit -Because `apm.lock` is a committed file, standard Git tooling answers governance questions directly: +Because `apm.lock.yaml` is a committed file, standard Git tooling answers governance questions directly: -- **What changed?** `git diff apm.lock` -- **When did it change?** `git log apm.lock` -- **What was active at a specific release?** `git show v4.2.1:apm.lock` +- **What changed?** `git diff apm.lock.yaml` +- **When did it change?** `git log apm.lock.yaml` +- **What was active at a specific release?** `git show v4.2.1:apm.lock.yaml` - **Is this environment current?** `apm audit` ## Developer stories @@ -118,8 +118,8 @@ At enterprise scale, the primary concerns shift from convenience to governance: APM addresses these through mechanisms that engineering leadership and platform teams can build on: -- **Reproducibility.** `apm.lock` guarantees that every environment — developer workstation, CI runner, staging — uses identical configuration. "Works on my machine" stops applying to agent setup. -- **Audit trail.** `git log apm.lock` provides a complete, timestamped history of every configuration change, who made it, and which pull request approved it. +- **Reproducibility.** `apm.lock.yaml` guarantees that every environment — developer workstation, CI runner, staging — uses identical configuration. "Works on my machine" stops applying to agent setup. +- **Audit trail.** `git log apm.lock.yaml` provides a complete, timestamped history of every configuration change, who made it, and which pull request approved it. - **CI enforcement.** `apm audit` in a CI pipeline fails the build if local configuration has drifted from the declared and locked state, catching unauthorized or accidental changes before they reach production. - **Centralized standards.** Organization-wide packages are published once and consumed by every repository. Updates propagate through version bumps in `apm.yml`, reviewed and approved through the normal pull request process. @@ -131,9 +131,9 @@ Each AI tool has its own plugin or extension system. APM does not replace these |---|---|---| | Install plugins for one tool | Yes | Yes | | Install across all tools, one command | No | Yes | -| Consumer-side version lock | No | Yes (`apm.lock`) | +| Consumer-side version lock | No | Yes (`apm.lock.yaml`) | | CI gate for configuration drift | No | Yes (`apm audit`) | -| Audit trail | No | Yes (`git log apm.lock`) | +| Audit trail | No | Yes (`git log apm.lock.yaml`) | | Multi-source composition | No | Yes | The distinction matters: native plugin systems solve distribution for a single tool. APM solves consistency across tools, teams, and time. diff --git a/docs/src/content/docs/getting-started/migration.md b/docs/src/content/docs/getting-started/migration.md index 7342925e..948e0f86 100644 --- a/docs/src/content/docs/getting-started/migration.md +++ b/docs/src/content/docs/getting-started/migration.md @@ -28,12 +28,12 @@ apm install microsoft/copilot-best-practices apm install your-org/team-standards ``` -Each package brings in versioned, maintained configuration instead of stale copies. Your `apm.yml` tracks these as dependencies, and `apm.lock` pins exact versions. +Each package brings in versioned, maintained configuration instead of stale copies. Your `apm.yml` tracks these as dependencies, and `apm.lock.yaml` pins exact versions. ### 3. Commit and share ```bash -git add apm.yml apm.lock +git add apm.yml apm.lock.yaml git commit -m "Add APM manifest" ``` @@ -49,7 +49,7 @@ Over time, you may choose to move manual configuration into APM packages for por If you decide APM is not for you: -1. Delete `apm.yml` and `apm.lock`. +1. Delete `apm.yml` and `apm.lock.yaml`. 2. Your original files are still there, unchanged. No uninstall script, no cleanup command. Zero risk. diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index a76369e1..d9f3f5de 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -66,7 +66,7 @@ APM downloads the package, resolves its dependencies, and deploys files directly ``` my-project/ apm.yml - apm.lock + apm.lock.yaml apm_modules/ microsoft/ apm-sample-package/ @@ -88,7 +88,7 @@ Three things happened: 1. The package was downloaded into `apm_modules/` (like `node_modules/`). 2. Instructions, prompts, and skills were deployed to `.github/` and `.claude/` -- the native directories that GitHub Copilot, Cursor, and Claude already read from. -3. A lockfile (`apm.lock`) was created, pinning the exact commit so every team member gets identical configuration. +3. A lockfile (`apm.lock.yaml`) was created, pinning the exact commit so every team member gets identical configuration. Your `apm.yml` now tracks the dependency: @@ -125,7 +125,7 @@ apm install github/awesome-copilot/skills/review-and-refactor ``` **What to commit:** -- `apm.yml` and `apm.lock` -- version-controlled, shared with the team. +- `apm.yml` and `apm.lock.yaml` -- version-controlled, shared with the team. - `apm_modules/` -- add to `.gitignore`. Rebuilt from the lockfile on install. :::note[Using Cursor, Codex, or Gemini?] diff --git a/docs/src/content/docs/guides/agent-workflows.md b/docs/src/content/docs/guides/agent-workflows.md index 61bde55a..08ee8b01 100644 --- a/docs/src/content/docs/guides/agent-workflows.md +++ b/docs/src/content/docs/guides/agent-workflows.md @@ -6,7 +6,7 @@ sidebar: --- :::caution[Experimental Feature] -APM's core value is dependency management — `apm install`, `apm.lock`, `apm audit`. The workflow execution features described on this page are experimental and may change. For most users, `apm install` is all you need. +APM's core value is dependency management — `apm install`, `apm.lock.yaml`, `apm audit`. The workflow execution features described on this page are experimental and may change. For most users, `apm install` is all you need. ::: ## What are Agent Workflows? diff --git a/docs/src/content/docs/guides/dependencies.md b/docs/src/content/docs/guides/dependencies.md index 4198b83d..629778db 100644 --- a/docs/src/content/docs/guides/dependencies.md +++ b/docs/src/content/docs/guides/dependencies.md @@ -503,13 +503,13 @@ apm deps update apm-sample-package apm install --update ``` -## Reproducible Builds with apm.lock +## Reproducible Builds with apm.lock.yaml -APM generates a lockfile (`apm.lock`) after each successful install to ensure reproducible builds across machines and CI environments. +APM generates a lockfile (`apm.lock.yaml`) after each successful install to ensure reproducible builds across machines and CI environments. -### What is apm.lock? +### What is apm.lock.yaml? -The `apm.lock` file captures the exact state of your dependency tree, including which files APM deployed: +The `apm.lock.yaml` file captures the exact state of your dependency tree, including which files APM deployed: ```yaml lockfile_version: "1.0" @@ -544,16 +544,16 @@ The `mcp_servers` field records the MCP dependency references (e.g. `io.github.g ### How It Works -1. **First install**: APM resolves dependencies, downloads packages, and writes `apm.lock` -2. **Subsequent installs**: APM reads `apm.lock` and uses locked commits for exact reproducibility. If the local checkout already matches the locked commit SHA, the download is skipped entirely. +1. **First install**: APM resolves dependencies, downloads packages, and writes `apm.lock.yaml` +2. **Subsequent installs**: APM reads `apm.lock.yaml` and uses locked commits for exact reproducibility. If the local checkout already matches the locked commit SHA, the download is skipped entirely. 3. **Updating**: Use `--update` to re-resolve dependencies and generate a fresh lockfile ### Version Control -**Commit `apm.lock`** to version control: +**Commit `apm.lock.yaml`** to version control: ```bash -git add apm.lock +git add apm.lock.yaml git commit -m "Lock dependencies" ``` @@ -578,7 +578,7 @@ apm install contoso/package-a Result: - Downloads A, B, and C -- Records all three in `apm.lock` with depth information +- Records all three in `apm.lock.yaml` with depth information - `depth: 1` = direct dependency - `depth: 2+` = transitive dependency diff --git a/docs/src/content/docs/guides/org-packages.md b/docs/src/content/docs/guides/org-packages.md index 93421aed..5aae05e8 100644 --- a/docs/src/content/docs/guides/org-packages.md +++ b/docs/src/content/docs/guides/org-packages.md @@ -214,7 +214,7 @@ dependencies: - acme-corp/apm-standards@v1 ``` -Regardless of the version specifier, `apm.lock` always pins the exact commit SHA. This guarantees reproducible installs even if the tag is moved. +Regardless of the version specifier, `apm.lock.yaml` always pins the exact commit SHA. This guarantees reproducible installs even if the tag is moved. ### When to bump versions @@ -249,7 +249,7 @@ To pick up the latest version within your pinned range: apm deps update ``` -This updates `apm.lock` to the latest commit matching your version pin and deploys the updated files. +This updates `apm.lock.yaml` to the latest commit matching your version pin and deploys the updated files. ### CI integration diff --git a/docs/src/content/docs/guides/pack-distribute.md b/docs/src/content/docs/guides/pack-distribute.md index 78d98c70..00d5b4db 100644 --- a/docs/src/content/docs/guides/pack-distribute.md +++ b/docs/src/content/docs/guides/pack-distribute.md @@ -33,7 +33,7 @@ The left side (install, pack) runs where APM is available. The right side (downl ## `apm pack` -Creates a self-contained bundle from installed dependencies. Reads the `deployed_files` manifest in `apm.lock` as the source of truth — it does not scan the disk. +Creates a self-contained bundle from installed dependencies. Reads the `deployed_files` manifest in `apm.lock.yaml` as the source of truth — it does not scan the disk. ```bash # Default: apm format, target auto-detected from apm.yml @@ -99,7 +99,7 @@ build/my-project-1.0.0/ skills/ security-scan/ skill.md - apm.lock # enriched copy (see below) + apm.lock.yaml # enriched copy (see below) ``` ### Claude target @@ -113,7 +113,7 @@ build/my-project-1.0.0/ skills/ code-analysis/ skill.md - apm.lock + apm.lock.yaml ``` ### All targets @@ -128,14 +128,14 @@ build/my-project-1.0.0/ .claude/ commands/ ... - apm.lock + apm.lock.yaml ``` -The bundle is self-describing: its `apm.lock` lists every file it contains and the dependency graph that produced them. +The bundle is self-describing: its `apm.lock.yaml` lists every file it contains and the dependency graph that produced them. ## Lockfile enrichment -The bundle includes a copy of `apm.lock` enriched with a `pack:` section. The project's own `apm.lock` is never modified. +The bundle includes a copy of `apm.lock.yaml` enriched with a `pack:` section. The project's own `apm.lock.yaml` is never modified. ```yaml pack: @@ -197,7 +197,7 @@ apm unpack ./build/my-project-1.0.0.tar.gz --dry-run - **Additive-only**: `unpack` writes files listed in the bundle's lockfile. It never deletes existing files in the target directory. - **Overwrite on conflict**: if a file already exists at the target path, the bundle file wins. - **Verification**: by default, `unpack` checks that every path in the bundle's `deployed_files` manifest exists in the bundle before extracting. Pass `--skip-verify` to skip this check for partial bundles. -- **Lockfile not copied**: the bundle's enriched `apm.lock` is metadata for verification only — it is not written to the output directory. +- **Lockfile not copied**: the bundle's enriched `apm.lock.yaml` is metadata for verification only — it is not written to the output directory. ## Consumption scenarios @@ -289,7 +289,7 @@ No APM binary, no Python runtime, no network calls. The action handles extractio `apm pack` requires two things: -1. **`apm.lock`** — the resolved lockfile produced by `apm install`. Pack reads the `deployed_files` manifest from this file to know what to include. +1. **`apm.lock.yaml`** — the resolved lockfile produced by `apm install`. Pack reads the `deployed_files` manifest from this file to know what to include. 2. **Installed files on disk** — the actual files referenced in `deployed_files` must exist at their expected paths. Pack verifies this and fails with a clear error if files are missing. The typical sequence is: @@ -299,13 +299,13 @@ apm install # resolve dependencies and deploy files apm pack # bundle the deployed files ``` -Pack reads from the lockfile, not from a disk scan. If a file exists on disk but is not listed in `apm.lock`, it will not be included. If a file is listed in `apm.lock` but missing from disk, pack will fail and prompt you to re-run `apm install`. +Pack reads from the lockfile, not from a disk scan. If a file exists on disk but is not listed in `apm.lock.yaml`, it will not be included. If a file is listed in `apm.lock.yaml` but missing from disk, pack will fail and prompt you to re-run `apm install`. ## Troubleshooting -### "apm.lock not found" +### "apm.lock.yaml not found" -Pack requires a lockfile. Run `apm install` first to resolve dependencies and generate `apm.lock`. +Pack requires a lockfile. Run `apm install` first to resolve dependencies and generate `apm.lock.yaml`. ### "deployed files are missing on disk" @@ -317,4 +317,4 @@ During unpack, verification found files listed in the bundle's lockfile that are ### Empty bundle -If `apm pack` produces zero files, check that your dependencies have `deployed_files` entries in `apm.lock`. This can happen if `apm install` completed but no integration files were deployed (e.g., the package has no prompts or agents for the active target). +If `apm pack` produces zero files, check that your dependencies have `deployed_files` entries in `apm.lock.yaml`. This can happen if `apm install` completed but no integration files were deployed (e.g., the package has no prompts or agents for the active target). diff --git a/docs/src/content/docs/guides/plugins.md b/docs/src/content/docs/guides/plugins.md index 5e730f02..778eca21 100644 --- a/docs/src/content/docs/guides/plugins.md +++ b/docs/src/content/docs/guides/plugins.md @@ -47,7 +47,7 @@ When you run `apm install owner/repo/plugin-name`: - `*.md` command files are normalized to `*.prompt.md` for prompt/command integration 4. **Synthesize** - `apm.yml` is automatically generated from plugin metadata 5. **Integrate** - The plugin is now a standard dependency with: - - Version pinning via `apm.lock` + - Version pinning via `apm.lock.yaml` - Transitive dependency resolution - Conflict detection - Everything else APM packages support @@ -242,7 +242,7 @@ dependencies: - owner/repo/plugin#abc123 ``` -Run `apm install` to download and lock versions in `apm.lock`. +Run `apm install` to download and lock versions in `apm.lock.yaml`. ## Supported Hosts @@ -252,7 +252,7 @@ Run `apm install` to download and lock versions in `apm.lock`. ## Lock File Integration -Plugin versions are automatically tracked in `apm.lock`: +Plugin versions are automatically tracked in `apm.lock.yaml`: ```yaml apm_modules: diff --git a/docs/src/content/docs/integrations/ci-cd.md b/docs/src/content/docs/integrations/ci-cd.md index 4c61eabc..187815cb 100644 --- a/docs/src/content/docs/integrations/ci-cd.md +++ b/docs/src/content/docs/integrations/ci-cd.md @@ -152,6 +152,6 @@ See the [Pack & Distribute guide](../../guides/pack-distribute/) for the full wo ## Best Practices - **Pin APM version** in CI to avoid unexpected changes: `pip install apm-cli==0.7.7` -- **Commit `apm.lock`** so CI resolves the same dependency versions as local development +- **Commit `apm.lock.yaml`** so CI resolves the same dependency versions as local development - **If using `apm compile`** (for Cursor, Codex, Gemini), run it in CI and fail the build if the output differs from what's committed - **Use `GITHUB_APM_PAT`** for private dependencies; never use the default `GITHUB_TOKEN` for cross-repo access diff --git a/docs/src/content/docs/integrations/gh-aw.md b/docs/src/content/docs/integrations/gh-aw.md index 001b5e8a..23565458 100644 --- a/docs/src/content/docs/integrations/gh-aw.md +++ b/docs/src/content/docs/integrations/gh-aw.md @@ -97,7 +97,7 @@ steps: Review the PR using the installed coding standards. ``` -The repo needs an `apm.yml` with dependencies and `apm.lock` for reproducibility. The action runs as a pre-agent step, deploying primitives to `.github/` where the agent discovers them. +The repo needs an `apm.yml` with dependencies and `apm.lock.yaml` for reproducibility. The action runs as a pre-agent step, deploying primitives to `.github/` where the agent discovers them. **When to use this over frontmatter dependencies:** diff --git a/docs/src/content/docs/integrations/github-rulesets.md b/docs/src/content/docs/integrations/github-rulesets.md index d36eea1b..1033c299 100644 --- a/docs/src/content/docs/integrations/github-rulesets.md +++ b/docs/src/content/docs/integrations/github-rulesets.md @@ -62,7 +62,7 @@ Once configured, any PR that modifies agent configuration files without a corres `apm audit --ci` detects the following issues: -- **Lock file out of sync** — `apm.lock` does not match the current state of `apm.yml`. +- **Lock file out of sync** — `apm.lock.yaml` does not match the current state of `apm.yml`. - **Undeclared config changes** — manual edits to files in `.github/instructions/` or other managed paths that bypass the manifest. - **Missing dependencies** — packages declared in `apm.yml` that cannot be resolved. - **Deleted or modified managed files** — files that APM deployed but were removed or altered outside of APM. diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 117a9423..683447c7 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -91,7 +91,7 @@ apm install microsoft/apm-sample-package **How Auto-Integration Works**: - **Zero-Config**: Always enabled, works automatically with no configuration needed -- **Auto-Cleanup**: Removes integrated files when you uninstall or prune packages (tracked via `deployed_files` in `apm.lock`) +- **Auto-Cleanup**: Removes integrated files when you uninstall or prune packages (tracked via `deployed_files` in `apm.lock.yaml`) - **Collision Detection**: If a local file has the same name as a package file, APM skips it with a warning (use `--force` to overwrite) - **Always Overwrite**: Package-owned files are always copied fresh -- no version comparison - **Link Resolution**: Context links are resolved during integration @@ -105,7 +105,7 @@ apm install microsoft/apm-sample-package 6. Copies instructions to `.github/instructions/` with their original filename (e.g., `python.instructions.md`) 7. Copies hooks to `.github/hooks/` with their original filename and copies referenced scripts 8. If a local file already exists with the same name, skips with a warning (use `--force` to overwrite) -9. Records all deployed files in `apm.lock` under `deployed_files` per package +9. Records all deployed files in `apm.lock.yaml` under `deployed_files` per package 10. VS Code automatically loads all prompts, agents, instructions, and hooks for your coding agents 11. Run `apm uninstall` to automatically remove integrated primitives (using `deployed_files` manifest) @@ -330,10 +330,10 @@ Claude Desktop can use `CLAUDE.md` as its project instructions file. Optionally APM maintains synchronization between packages and Claude primitives: -- **Install**: Adds agents, commands, and skills for new packages, tracked via `deployed_files` in `apm.lock` -- **Uninstall**: Removes only that package's agents, commands, and skill directories (as tracked in `apm.lock`). User-authored files are preserved. +- **Install**: Adds agents, commands, and skills for new packages, tracked via `deployed_files` in `apm.lock.yaml` +- **Uninstall**: Removes only that package's agents, commands, and skill directories (as tracked in `apm.lock.yaml`). User-authored files are preserved. - **Update**: Refreshes agents, commands, and skills when package version changes -- **Virtual Packages**: Individual files and skills (e.g., `github/awesome-copilot/skills/review-and-refactor`) are tracked via `apm.lock` and removed correctly on uninstall +- **Virtual Packages**: Individual files and skills (e.g., `github/awesome-copilot/skills/review-and-refactor`) are tracked via `apm.lock.yaml` and removed correctly on uninstall ## Other IDE Support diff --git a/docs/src/content/docs/introduction/what-is-apm.md b/docs/src/content/docs/introduction/what-is-apm.md index 9e0be2bc..85a62067 100644 --- a/docs/src/content/docs/introduction/what-is-apm.md +++ b/docs/src/content/docs/introduction/what-is-apm.md @@ -41,7 +41,7 @@ manager: | Without APM | With APM | |---|---| | Each dev configures agents manually | `apm install` sets up everything | -| Instructions drift across machines | `apm.lock` pins exact versions | +| Instructions drift across machines | `apm.lock.yaml` pins exact versions | | No way to share or reuse prompts | Publish and install from any git host | | MCP servers configured per-developer | Declared in manifest, installed consistently | | Onboarding requires tribal knowledge | Clone, `apm install`, done | @@ -121,7 +121,7 @@ dependencies: - community/security-audit # open-source prompt ``` -**Lock.** `apm.lock` pins every dependency to an exact commit. Two developers +**Lock.** `apm.lock.yaml` pins every dependency to an exact commit. Two developers running `apm install` on the same lock file get identical setups. **Build.** `apm compile` produces optimized output files for each AI tool — @@ -229,7 +229,7 @@ The compiled output is plain files that each tool already understands. ## Key value propositions -**Reproducibility.** `apm.lock` guarantees identical agent setups across +**Reproducibility.** `apm.lock.yaml` guarantees identical agent setups across developers, CI, and environments. No more "works on my machine" for AI configuration. diff --git a/docs/src/content/docs/introduction/why-apm.md b/docs/src/content/docs/introduction/why-apm.md index 811ea846..8b338f44 100644 --- a/docs/src/content/docs/introduction/why-apm.md +++ b/docs/src/content/docs/introduction/why-apm.md @@ -70,7 +70,7 @@ apm install | Setup steps | 5+ install commands | 1 command | | Version consistency | Hope-based | Lock file enforced | | New contributor onboarding | Read docs, follow steps, debug mismatches | `apm install` | -| CI/CD reproducibility | Fragile or nonexistent | Deterministic via `apm.lock` | +| CI/CD reproducibility | Fragile or nonexistent | Deterministic via `apm.lock.yaml` | | Cross-tool coordination | Manual per tool | Unified manifest | ## What APM Manages @@ -93,9 +93,9 @@ All declared in one manifest. All installed with one command. **Solo / Small Team (2-5 devs)** — "I use Copilot AND Claude. The project needs 5 plugins. Without APM, every new contributor runs 5 install commands and hopes they got the right versions. With APM, they run `apm install`." -**Mid-size Team (10-50 devs)** — "We have org-wide security standards, team-specific plugins, and project-level config. `apm.yml` composes all three layers through dependency resolution. `apm.lock` ensures every developer and CI runner gets the exact same setup." +**Mid-size Team (10-50 devs)** — "We have org-wide security standards, team-specific plugins, and project-level config. `apm.yml` composes all three layers through dependency resolution. `apm.lock.yaml` ensures every developer and CI runner gets the exact same setup." -**Enterprise (100+ devs)** — "When security asks 'what agent instructions were active when release 4.2.1 shipped?' — `git log apm.lock` answers that. Every change to agent configuration is versioned, auditable, and reproducible." +**Enterprise (100+ devs)** — "When security asks 'what agent instructions were active when release 4.2.1 shipped?' — `git log apm.lock.yaml` answers that. Every change to agent configuration is versioned, auditable, and reproducible." ## Design Principles @@ -126,4 +126,4 @@ APM is a dev-time tool. Run `apm install`, get your files, done. There is no run **"What if I stop using APM?"** -Delete `apm.yml` and `apm.lock`. Your `.github/` and `.claude/` config files still work exactly as they did before. APM deploys standard files in standard locations. Zero lock-in by design. +Delete `apm.yml` and `apm.lock.yaml`. Your `.github/` and `.claude/` config files still work exactly as they did before. APM deploys standard files in standard locations. Zero lock-in by design. diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index fbfa3dbb..fd17faea 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -280,14 +280,14 @@ apm uninstall microsoft/apm-sample-package --dry-run | Skill folders | `.github/skills/{folder-name}/` | | Integrated hooks | `.github/hooks/*.json` | | Claude hook settings | `.claude/settings.json` (hooks key cleaned) | -| Lockfile entries | `apm.lock` (removed packages + orphaned transitives) | +| Lockfile entries | `apm.lock.yaml` (removed packages + orphaned transitives) | **Behavior:** - Removes package from `apm.yml` dependencies - Deletes package folder from `apm_modules/` -- Removes orphaned transitive dependencies (npm-style pruning via `apm.lock`) -- Removes all deployed integration files tracked in `apm.lock` `deployed_files` -- Updates `apm.lock` (or deletes it if no dependencies remain) +- Removes orphaned transitive dependencies (npm-style pruning via `apm.lock.yaml`) +- Removes all deployed integration files tracked in `apm.lock.yaml` `deployed_files` +- Updates `apm.lock.yaml` (or deletes it if no dependencies remain) - Cleans up empty parent directories - Safe operation: only removes files tracked in the `deployed_files` manifest @@ -313,12 +313,12 @@ apm prune --dry-run **Behavior:** - Removes orphaned package directories from `apm_modules/` -- Removes deployed integration files (prompts, agents, hooks, etc.) for pruned packages using the `deployed_files` manifest in `apm.lock` -- Updates `apm.lock` to reflect the pruned state +- Removes deployed integration files (prompts, agents, hooks, etc.) for pruned packages using the `deployed_files` manifest in `apm.lock.yaml` +- Updates `apm.lock.yaml` to reflect the pruned state ### `apm pack` - Create a portable bundle -Create a self-contained bundle from installed APM dependencies using the `deployed_files` recorded in `apm.lock` as the source of truth. +Create a self-contained bundle from installed APM dependencies using the `deployed_files` recorded in `apm.lock.yaml` as the source of truth. ```bash apm pack [OPTIONS] @@ -350,9 +350,9 @@ apm pack -o dist/ ``` **Behavior:** -- Reads `apm.lock` to enumerate all `deployed_files` from installed dependencies +- Reads `apm.lock.yaml` to enumerate all `deployed_files` from installed dependencies - Copies files preserving directory structure -- Writes an enriched `apm.lock` inside the bundle with a `pack:` metadata section (the project's own `apm.lock` is never modified) +- Writes an enriched `apm.lock.yaml` inside the bundle with a `pack:` metadata section (the project's own `apm.lock.yaml` is never modified) **Target filtering:** @@ -407,10 +407,10 @@ apm unpack bundle.tar.gz --dry-run ``` **Behavior:** -- **Additive-only**: only writes files listed in the bundle's `apm.lock`; never deletes existing files +- **Additive-only**: only writes files listed in the bundle's `apm.lock.yaml`; never deletes existing files - If a local file has the same path as a bundle file, the bundle file wins (overwrite) - Verification checks that all `deployed_files` from the bundle lockfile are present in the bundle -- The bundle's `apm.lock` is metadata only — it is **not** copied to the output directory +- The bundle's `apm.lock.yaml` is metadata only — it is **not** copied to the output directory ### `apm update` - Update APM to the latest version diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md index 2d007f29..e5790ad0 100644 --- a/docs/src/content/docs/reference/lockfile-spec.md +++ b/docs/src/content/docs/reference/lockfile-spec.md @@ -1,6 +1,6 @@ --- title: "Lock File Specification" -description: "The apm.lock format — how APM pins dependencies to exact versions for reproducible installs." +description: "The apm.lock.yaml format — how APM pins dependencies to exact versions for reproducible installs." sidebar: order: 3 --- @@ -20,7 +20,7 @@ breaking changes will be gated behind a `lockfile_version` bump. ## Abstract -`apm.lock` records the exact resolved state of every dependency in an APM +`apm.lock.yaml` records the exact resolved state of every dependency in an APM project. It is the receipt of what was installed — commit SHAs, source URLs, and every file deployed into the workspace. Its role is analogous to `package-lock.json` (npm) or `.terraform.lock.hcl` (Terraform): given the same @@ -42,14 +42,14 @@ The lock file serves four goals: 2. **Provenance** — every dependency is traceable to an exact source commit. 3. **Completeness** — `deployed_files` lists every file APM placed in the project, enabling precise removal. -4. **Auditability** — `git log apm.lock` provides a full history of dependency +4. **Auditability** — `git log apm.lock.yaml` provides a full history of dependency changes across the lifetime of the project. ## 3. Lifecycle -`apm.lock` is created and updated at well-defined points: +`apm.lock.yaml` is created and updated at well-defined points: -| Event | Effect on `apm.lock` | +| Event | Effect on `apm.lock.yaml` | |-------|----------------------| | `apm install` (first run) | Created. All dependencies resolved, commits pinned, files recorded. | | `apm install` (subsequent) | Read. Locked commits reused. New dependencies appended. | @@ -156,7 +156,7 @@ produce consistent diffs in version control. When `apm pack` creates a bundle, it prepends a `pack:` section to the lock file copy included in the bundle. This section is informational and is not -written back to the project's `apm.lock`. +written back to the project's `apm.lock.yaml`. ```yaml pack: @@ -184,8 +184,8 @@ packed archive. The dependency resolver interacts with the lock file as follows: -1. **First install** — resolve all refs to commits, write `apm.lock`. -2. **Subsequent installs** — read `apm.lock`, reuse locked commits. Only +1. **First install** — resolve all refs to commits, write `apm.lock.yaml`. +2. **Subsequent installs** — read `apm.lock.yaml`, reuse locked commits. Only newly added dependencies trigger resolution. 3. **Update** (`--update` flag or `apm deps update`) — re-resolve all refs, overwrite the lock file with fresh commits. @@ -195,29 +195,35 @@ APM MUST report an error and refuse to install until the lock file is updated. ## 8. Migration -The lock file reader supports one historical migration: +The lock file reader supports the following historical migrations: - **`deployed_skills`** — renamed to `deployed_files`. If a lock file contains the legacy key, it is silently migrated on read. New lock files MUST use `deployed_files`. +- **`apm.lock` → `apm.lock.yaml`** — the lock file was renamed from `apm.lock` + to `apm.lock.yaml` (for IDE syntax highlighting). On the next `apm install`, + an existing `apm.lock` is automatically renamed to `apm.lock.yaml` when the + new file does not yet exist. The bundle unpacker also falls back to `apm.lock` + when reading older bundles. + ## 9. Auditing Patterns -Because `apm.lock` is committed to version control, standard Git operations +Because `apm.lock.yaml` is committed to version control, standard Git operations provide a complete audit trail: ```bash # Full history of dependency changes -git log --oneline apm.lock +git log --oneline apm.lock.yaml # What changed in the last commit -git diff HEAD~1 -- apm.lock +git diff HEAD~1 -- apm.lock.yaml # State of dependencies at a specific release -git show v4.2.1:apm.lock +git show v4.2.1:apm.lock.yaml # Who last modified the lock file -git log -1 --format='%an <%ae> %ai' -- apm.lock +git log -1 --format='%an <%ae> %ai' -- apm.lock.yaml ``` In CI pipelines, `apm audit --ci` verifies the lock file is in sync with the diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index b4fb4a10..c5c9739c 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -343,9 +343,9 @@ compilation: --- -## 6. Lockfile (`apm.lock`) +## 6. Lockfile (`apm.lock.yaml`) -After successful dependency resolution, a conforming resolver MUST write a lockfile capturing the exact resolved state. The lockfile MUST be a YAML file named `apm.lock` at the project root. It SHOULD be committed to version control. +After successful dependency resolution, a conforming resolver MUST write a lockfile capturing the exact resolved state. The lockfile MUST be a YAML file named `apm.lock.yaml` at the project root. It SHOULD be committed to version control. ### 6.1. Structure @@ -370,8 +370,8 @@ mcp_servers: > # MCP dependency references managed b ### 6.2. Resolver Behaviour -1. **First install** — Resolve all dependencies, write `apm.lock`. -2. **Subsequent installs** — Read `apm.lock`, use locked commit SHAs. A resolver SHOULD skip download if local checkout already matches. +1. **First install** — Resolve all dependencies, write `apm.lock.yaml`. +2. **Subsequent installs** — Read `apm.lock.yaml`, use locked commit SHAs. A resolver SHOULD skip download if local checkout already matches. 3. **`--update` flag** — Re-resolve from `apm.yml`, overwrite lockfile. --- @@ -384,7 +384,7 @@ Any runtime adopting this format (e.g. GitHub Agentic Workflows, CI systems, IDE 2. **Resolve `dependencies.apm`** — For each entry, clone/fetch the git repo (respecting `ref`), locate the `.apm/` directory (or virtual path), and extract primitives. 3. **Resolve `dependencies.mcp`** — For each entry, resolve from the MCP registry or validate self-defined transport config per §4.2.3. 4. **Transitive resolution** — Resolved packages MAY contain their own `apm.yml` with further dependencies, forming a dependency tree. Resolvers MUST resolve transitively. Conflicts are merged at instruction level (by `applyTo` pattern), not file level. -5. **Write lockfile** — Record exact commit SHAs and deployed file paths in `apm.lock` per §6. +5. **Write lockfile** — Record exact commit SHAs and deployed file paths in `apm.lock.yaml` per §6. --- diff --git a/docs/src/content/docs/reference/primitive-types.md b/docs/src/content/docs/reference/primitive-types.md index dad6b1a5..59222261 100644 --- a/docs/src/content/docs/reference/primitive-types.md +++ b/docs/src/content/docs/reference/primitive-types.md @@ -120,7 +120,7 @@ if collection.has_conflicts(): ### Dependency Declaration Order -The system reads `apm.yml` to determine the order in which direct dependencies should be processed. Transitive dependencies (resolved automatically via dependency chains) are read from `apm.lock` and appended after direct dependencies: +The system reads `apm.yml` to determine the order in which direct dependencies should be processed. Transitive dependencies (resolved automatically via dependency chains) are read from `apm.lock.yaml` and appended after direct dependencies: ```yaml # apm.yml @@ -133,7 +133,7 @@ dependencies: - user/utilities ``` -Direct dependencies are processed first, in declaration order. Transitive dependencies from `apm.lock` are appended after. If multiple dependencies provide primitives with the same name, the first one declared wins. +Direct dependencies are processed first, in declaration order. Transitive dependencies from `apm.lock.yaml` are appended after. If multiple dependencies provide primitives with the same name, the first one declared wins. ## Directory Structure diff --git a/src/apm_cli/bundle/packer.py b/src/apm_cli/bundle/packer.py index d14de3f1..a078df52 100644 --- a/src/apm_cli/bundle/packer.py +++ b/src/apm_cli/bundle/packer.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import List, Optional -from ..deps.lockfile import LockFile +from ..deps.lockfile import LockFile, get_lockfile_path, migrate_lockfile_if_needed from ..models.apm_package import APMPackage from ..core.target_detection import detect_target from .lockfile_enrichment import enrich_lockfile_for_pack @@ -47,7 +47,7 @@ def pack_bundle( """Create a self-contained bundle from installed APM dependencies. Args: - project_root: Root of the project containing ``apm.lock`` and ``apm.yml``. + project_root: Root of the project containing ``apm.lock.yaml`` and ``apm.yml``. output_dir: Directory where the bundle will be created. fmt: Bundle format -- ``"apm"`` (default) or ``"plugin"``. target: Target filter -- ``"vscode"``, ``"claude"``, ``"all"``, or *None* @@ -59,15 +59,16 @@ def pack_bundle( :class:`PackResult` describing what was (or would be) produced. Raises: - FileNotFoundError: If ``apm.lock`` is missing. + FileNotFoundError: If ``apm.lock.yaml`` is missing. ValueError: If deployed files referenced in the lockfile are missing on disk. """ - # 1. Read lockfile - lockfile_path = project_root / "apm.lock" + # 1. Read lockfile (migrate legacy apm.lock → apm.lock.yaml if needed) + migrate_lockfile_if_needed(project_root) + lockfile_path = get_lockfile_path(project_root) lockfile = LockFile.read(lockfile_path) if lockfile is None: raise FileNotFoundError( - "apm.lock not found -- run 'apm install' first to resolve dependencies." + "apm.lock.yaml not found -- run 'apm install' first to resolve dependencies." ) # 2. Read apm.yml for name / version / config target @@ -156,7 +157,7 @@ def pack_bundle( # 8. Enrich lockfile copy and write to bundle enriched_yaml = enrich_lockfile_for_pack(lockfile, fmt, effective_target) - (bundle_dir / "apm.lock").write_text(enriched_yaml, encoding="utf-8") + (bundle_dir / "apm.lock.yaml").write_text(enriched_yaml, encoding="utf-8") result = PackResult( bundle_path=bundle_dir, diff --git a/src/apm_cli/bundle/unpacker.py b/src/apm_cli/bundle/unpacker.py index d71521a6..acfc11cb 100644 --- a/src/apm_cli/bundle/unpacker.py +++ b/src/apm_cli/bundle/unpacker.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Dict, List -from ..deps.lockfile import LockFile +from ..deps.lockfile import LockFile, LOCKFILE_NAME, LEGACY_LOCKFILE_NAME @dataclass @@ -44,7 +44,7 @@ def unpack_bundle( :class:`UnpackResult` describing what was (or would be) extracted. Raises: - FileNotFoundError: If the bundle's ``apm.lock`` is missing. + FileNotFoundError: If the bundle's ``apm.lock.yaml`` is missing. ValueError: If verification finds files listed in the lockfile but absent from the bundle. """ @@ -83,16 +83,21 @@ def unpack_bundle( raise FileNotFoundError(f"Bundle not found or unsupported format: {bundle_path}") try: - # 2. Read apm.lock from bundle - lockfile_path = source_dir / "apm.lock" + # 2. Read apm.lock.yaml (or legacy apm.lock) from bundle + lockfile_path = source_dir / LOCKFILE_NAME + if not lockfile_path.exists(): + # Backward compat: older bundles used "apm.lock" + legacy_lockfile_path = source_dir / LEGACY_LOCKFILE_NAME + if legacy_lockfile_path.exists(): + lockfile_path = legacy_lockfile_path lockfile = LockFile.read(lockfile_path) if lockfile is None: if not lockfile_path.exists(): raise FileNotFoundError( - "apm.lock not found in the bundle -- the bundle may be incomplete." + f"{lockfile_path.name} not found in the bundle -- the bundle may be incomplete." ) raise FileNotFoundError( - "apm.lock in the bundle could not be parsed -- the bundle may be corrupt." + f"{lockfile_path.name} in the bundle could not be parsed -- the bundle may be corrupt." ) # Collect deployed_files per dependency and deduplicated global list diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py index 578f27ec..f9d8a8d2 100644 --- a/src/apm_cli/commands/_helpers.py +++ b/src/apm_cli/commands/_helpers.py @@ -185,11 +185,11 @@ def _check_orphaned_packages(): try: from ..models.apm_package import APMPackage - from ..deps.lockfile import LockFile + from ..deps.lockfile import LockFile, get_lockfile_path apm_package = APMPackage.from_apm_yml(Path("apm.yml")) declared_deps = apm_package.get_apm_dependencies() - lockfile = LockFile.read(Path.cwd() / "apm.lock") + lockfile = LockFile.read(get_lockfile_path(Path.cwd())) expected = _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir) except Exception: return [] diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 37447d93..e171ed1a 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -30,7 +30,7 @@ try: from ..deps.apm_resolver import APMDependencyResolver from ..deps.github_downloader import GitHubPackageDownloader - from ..deps.lockfile import LockFile + from ..deps.lockfile import LockFile, get_lockfile_path, migrate_lockfile_if_needed from ..integration import AgentIntegrator, PromptIntegrator from ..integration.mcp_integrator import MCPIntegrator from ..models.apm_package import APMPackage, DependencyReference @@ -392,13 +392,16 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo prompt_count = 0 agent_count = 0 + # Migrate legacy apm.lock → apm.lock.yaml if needed (one-time, transparent) + migrate_lockfile_if_needed(Path.cwd()) + # Capture old MCP servers and configs from lockfile BEFORE # _install_apm_dependencies regenerates it (which drops the fields). # We always read this — even when --only=apm — so we can restore the # field after the lockfile is regenerated by the APM install step. old_mcp_servers: builtins.set = builtins.set() old_mcp_configs: builtins.dict = {} - _lock_path = Path.cwd() / "apm.lock" + _lock_path = get_lockfile_path(Path.cwd()) _existing_lock = LockFile.read(_lock_path) if _existing_lock: old_mcp_servers = builtins.set(_existing_lock.mcp_servers) @@ -434,7 +437,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo # Collect transitive MCP dependencies from resolved APM packages apm_modules_path = Path.cwd() / "apm_modules" if should_install_mcp and apm_modules_path.exists(): - lock_path = Path.cwd() / "apm.lock" + lock_path = get_lockfile_path(Path.cwd()) transitive_mcp = MCPIntegrator.collect_transitive(apm_modules_path, lock_path, trust_transitive_mcp) if transitive_mcp: _rich_info(f"Collected {len(transitive_mcp)} transitive MCP dependency(ies)") @@ -530,7 +533,7 @@ def _install_apm_dependencies( if lockfile_path.exists() and not update_refs: existing_lockfile = LockFile.read(lockfile_path) if existing_lockfile and existing_lockfile.dependencies: - _rich_info(f"Using apm.lock ({len(existing_lockfile.dependencies)} locked dependencies)") + _rich_info(f"Using apm.lock.yaml ({len(existing_lockfile.dependencies)} locked dependencies)") apm_modules_dir = project_root / "apm_modules" apm_modules_dir.mkdir(exist_ok=True) @@ -1509,9 +1512,9 @@ def _collect_descendants(node, visited=None): lockfile = existing lockfile.save(lockfile_path) - _rich_info(f"Generated apm.lock with {len(lockfile.dependencies)} dependencies") + _rich_info(f"Generated apm.lock.yaml with {len(lockfile.dependencies)} dependencies") except Exception as e: - _rich_warning(f"Could not generate apm.lock: {e}") + _rich_warning(f"Could not generate apm.lock.yaml: {e}") # Show link resolution stats if any were resolved if total_links_resolved > 0: diff --git a/src/apm_cli/commands/prune.py b/src/apm_cli/commands/prune.py index d07f209c..52916786 100644 --- a/src/apm_cli/commands/prune.py +++ b/src/apm_cli/commands/prune.py @@ -10,7 +10,7 @@ from ._helpers import _build_expected_install_paths, _scan_installed_packages # APM Dependencies -from ..deps.lockfile import LockFile +from ..deps.lockfile import LockFile, get_lockfile_path from ..models.apm_package import APMPackage @@ -47,7 +47,7 @@ def prune(ctx, dry_run): try: apm_package = APMPackage.from_apm_yml(Path("apm.yml")) declared_deps = apm_package.get_apm_dependencies() - lockfile = LockFile.read(Path.cwd() / "apm.lock") + lockfile = LockFile.read(get_lockfile_path(Path.cwd())) expected_installed = _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir) except Exception as e: _rich_error(f"Failed to parse apm.yml: {e}") diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index 6fadffb6..2fa57e93 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -3,6 +3,7 @@ Provides deterministic, reproducible installs by capturing exact resolved versions. """ +import logging from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -12,6 +13,8 @@ from ..models.apm_package import DependencyReference +logger = logging.getLogger(__name__) + @dataclass class LockedDependency: @@ -270,20 +273,26 @@ def save(self, path: Path) -> None: @classmethod def installed_paths_for_project(cls, project_root: Path) -> List[str]: - """Load apm.lock from project_root and return installed paths. + """Load apm.lock.yaml from project_root and return installed paths. Returns an empty list if the lockfile is missing, corrupt, or unreadable. Args: - project_root: Path to project root containing apm.lock. + project_root: Path to project root containing apm.lock.yaml. Returns: List[str]: Relative installed paths (e.g., ['owner/repo']), ordered by depth then repo_url (no duplicates). """ try: - lockfile = cls.read(project_root / "apm.lock") + lockfile_path = get_lockfile_path(project_root) + if not lockfile_path.exists(): + # Fallback to legacy lockfile for pre-migration reads + legacy_path = project_root / LEGACY_LOCKFILE_NAME + if legacy_path.exists(): + lockfile_path = legacy_path + lockfile = cls.read(lockfile_path) if not lockfile: return [] return lockfile.get_installed_paths(project_root / "apm_modules") @@ -291,9 +300,40 @@ def installed_paths_for_project(cls, project_root: Path) -> List[str]: return [] +# Current lockfile filename (with .yaml extension for IDE syntax highlighting) +LOCKFILE_NAME = "apm.lock.yaml" +# Legacy lockfile filename used in older APM versions +LEGACY_LOCKFILE_NAME = "apm.lock" + + def get_lockfile_path(project_root: Path) -> Path: """Get the path to the lock file for a project.""" - return project_root / "apm.lock" + return project_root / LOCKFILE_NAME + + +def migrate_lockfile_if_needed(project_root: Path) -> bool: + """Migrate legacy apm.lock to apm.lock.yaml if needed. + + Renames ``apm.lock`` to ``apm.lock.yaml`` when the new file does not yet + exist. This is a one-time, transparent migration for users upgrading from + older APM versions. + + Args: + project_root: Path to the project root directory. + + Returns: + True if a migration was performed, False otherwise. + """ + new_path = get_lockfile_path(project_root) + legacy_path = project_root / LEGACY_LOCKFILE_NAME + if not new_path.exists() and legacy_path.exists(): + try: + legacy_path.rename(new_path) + except OSError: + logger.debug("Could not rename %s to %s", legacy_path, new_path, exc_info=True) + return False + return True + return False def get_lockfile_installed_paths(project_root: Path) -> List[str]: diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 787faf80..73464ab6 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -19,7 +19,7 @@ import click -from apm_cli.deps.lockfile import LockFile +from apm_cli.deps.lockfile import LockFile, get_lockfile_path from apm_cli.utils.console import ( _get_console, _rich_error, @@ -534,12 +534,12 @@ def update_lockfile( Args: mcp_server_names: Set of MCP server names to persist. - lock_path: Path to the lockfile. Defaults to ``apm.lock`` in CWD. + lock_path: Path to the lockfile. Defaults to ``apm.lock.yaml`` in CWD. mcp_configs: Keyword-only. When provided, overwrites ``mcp_configs`` in the lockfile (used for drift-detection baseline). """ if lock_path is None: - lock_path = Path.cwd() / "apm.lock" + lock_path = get_lockfile_path(Path.cwd()) if not lock_path.exists(): return try: diff --git a/tests/integration/test_deployed_files_e2e.py b/tests/integration/test_deployed_files_e2e.py index a3c2c643..ae9e9620 100644 --- a/tests/integration/test_deployed_files_e2e.py +++ b/tests/integration/test_deployed_files_e2e.py @@ -76,7 +76,7 @@ def _run_apm(apm_command, args, cwd, timeout=120): def _read_lockfile(project_dir): """Read and parse apm.lock from the project directory.""" - lock_path = project_dir / "apm.lock" + lock_path = project_dir / "apm.lock.yaml" if not lock_path.exists(): return None with open(lock_path) as f: @@ -258,7 +258,7 @@ def test_user_file_not_overwritten_on_reinstall(self, temp_project, apm_command) # Delete the lockfile to clear deployed_files tracking, then create # a user-authored file at the same path - lock_path = temp_project / "apm.lock" + lock_path = temp_project / "apm.lock.yaml" lock_path.unlink(missing_ok=True) user_content = "# User-authored content - DO NOT OVERWRITE\n" @@ -290,7 +290,7 @@ def test_force_flag_overwrites_collision(self, temp_project, apm_command): target_file = prompt_files[0] # Delete lockfile to clear tracking, then create user file - lock_path = temp_project / "apm.lock" + lock_path = temp_project / "apm.lock.yaml" lock_path.unlink(missing_ok=True) user_content = "# User-authored content\n" diff --git a/tests/integration/test_diff_aware_install_e2e.py b/tests/integration/test_diff_aware_install_e2e.py index ec176379..fb36c9cf 100644 --- a/tests/integration/test_diff_aware_install_e2e.py +++ b/tests/integration/test_diff_aware_install_e2e.py @@ -76,7 +76,7 @@ def _write_apm_yml(project_dir, packages): def _read_lockfile(project_dir): """Read and parse apm.lock from the project directory.""" - lock_path = project_dir / "apm.lock" + lock_path = project_dir / "apm.lock.yaml" if not lock_path.exists(): return None with open(lock_path, encoding="utf-8") as f: diff --git a/tests/integration/test_pack_unpack_e2e.py b/tests/integration/test_pack_unpack_e2e.py index e43f52c0..0fdad215 100644 --- a/tests/integration/test_pack_unpack_e2e.py +++ b/tests/integration/test_pack_unpack_e2e.py @@ -68,7 +68,7 @@ def test_full_round_trip(self, apm_command, temp_project, tmp_path): # 1. Install result = _run_apm(apm_command, ["install"], cwd=temp_project) assert result.returncode == 0, f"install failed: {result.stderr}" - assert (temp_project / "apm.lock").exists() + assert (temp_project / "apm.lock.yaml").exists() # 2. Pack result = _run_apm( diff --git a/tests/integration/test_plugin_e2e.py b/tests/integration/test_plugin_e2e.py index c1d54339..5025ab26 100644 --- a/tests/integration/test_plugin_e2e.py +++ b/tests/integration/test_plugin_e2e.py @@ -433,7 +433,7 @@ def test_install_real_plugin(self, apm_command, temp_project): assert (pkg_path / "apm.yml").exists(), "apm.yml should be synthesized" # Lock file created - assert (temp_project / "apm.lock").exists(), "apm.lock should be created" + assert (temp_project / "apm.lock.yaml").exists(), "apm.lock.yaml should be created" # Skills scattered to .github/skills/ skills_dir = temp_project / ".github" / "skills" @@ -578,7 +578,7 @@ def test_lockfile_preserved_on_sequential_install(self, apm_command, temp_projec # Lockfile should contain BOTH entries import yaml - lockfile = yaml.safe_load((temp_project / "apm.lock").read_text()) + lockfile = yaml.safe_load((temp_project / "apm.lock.yaml").read_text()) dep_keys = { f"{d['repo_url']}/{d.get('virtual_path', '')}" for d in lockfile["dependencies"] } @@ -704,7 +704,7 @@ def test_lockfile_records_package_type(self, apm_command, temp_project): import yaml - lockfile = yaml.safe_load((temp_project / "apm.lock").read_text()) + lockfile = yaml.safe_load((temp_project / "apm.lock.yaml").read_text()) assert "dependencies" in lockfile, "Lockfile missing dependencies" plugin_entry = None @@ -737,7 +737,7 @@ def test_idempotent_reinstall(self, apm_command, temp_project): # Capture lockfile state import yaml - lock1 = yaml.safe_load((temp_project / "apm.lock").read_text()) + lock1 = yaml.safe_load((temp_project / "apm.lock.yaml").read_text()) # Second install (should use cache) r2 = subprocess.run( @@ -747,7 +747,7 @@ def test_idempotent_reinstall(self, apm_command, temp_project): assert r2.returncode == 0, f"Second install failed:\n{r2.stderr}" # Lockfile should be identical - lock2 = yaml.safe_load((temp_project / "apm.lock").read_text()) + lock2 = yaml.safe_load((temp_project / "apm.lock.yaml").read_text()) assert len(lock1["dependencies"]) == len(lock2["dependencies"]), ( "Reinstall changed lockfile dependency count" ) diff --git a/tests/integration/test_selective_install_mcp.py b/tests/integration/test_selective_install_mcp.py index c69779bc..1db66038 100644 --- a/tests/integration/test_selective_install_mcp.py +++ b/tests/integration/test_selective_install_mcp.py @@ -94,7 +94,7 @@ def cli_env(tmp_path): ]) # Pre-seed a lockfile so the install loop treats packages as cached - _seed_lockfile(tmp_path / "apm.lock", [ + _seed_lockfile(tmp_path / "apm.lock.yaml", [ LockedDependency(repo_url="acme/squad-alpha", depth=1, resolved_by=None, resolved_commit="cached"), LockedDependency(repo_url="acme/infra-cloud", depth=2, @@ -132,7 +132,7 @@ def test_lockfile_records_transitive_mcp_servers( assert result.exit_code == 0, f"CLI failed:\n{result.output}\n{getattr(result, 'stderr', '')}" # Lockfile must contain both packages - lockfile = LockFile.read(tmp_path / "apm.lock") + lockfile = LockFile.read(tmp_path / "apm.lock.yaml") assert lockfile is not None dep_keys = set(lockfile.dependencies.keys()) assert "acme/squad-alpha" in dep_keys @@ -187,7 +187,7 @@ def test_deep_chain_mcp_in_lockfile( _make_pkg(apm_modules, "acme/pkg-c", apm_deps=["acme/pkg-d"]) _make_pkg(apm_modules, "acme/pkg-d", mcp=["ghcr.io/acme/mcp-deep"]) - _seed_lockfile(tmp_path / "apm.lock", [ + _seed_lockfile(tmp_path / "apm.lock.yaml", [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_by=None, resolved_commit="cached"), LockedDependency(repo_url="acme/pkg-b", depth=2, @@ -208,7 +208,7 @@ def test_deep_chain_mcp_in_lockfile( f"CLI failed:\n{result.output}\n{getattr(result, 'stderr', '')}" ) - lockfile = LockFile.read(tmp_path / "apm.lock") + lockfile = LockFile.read(tmp_path / "apm.lock.yaml") assert "acme/pkg-d" in lockfile.dependencies assert "ghcr.io/acme/mcp-deep" in lockfile.mcp_servers finally: @@ -240,7 +240,7 @@ def test_diamond_mcp_in_lockfile( "ghcr.io/acme/mcp-shared", ]) - _seed_lockfile(tmp_path / "apm.lock", [ + _seed_lockfile(tmp_path / "apm.lock.yaml", [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_by=None, resolved_commit="cached"), LockedDependency(repo_url="acme/pkg-b", depth=2, @@ -261,7 +261,7 @@ def test_diamond_mcp_in_lockfile( f"CLI failed:\n{result.output}\n{getattr(result, 'stderr', '')}" ) - lockfile = LockFile.read(tmp_path / "apm.lock") + lockfile = LockFile.read(tmp_path / "apm.lock.yaml") assert "ghcr.io/acme/mcp-shared" in lockfile.mcp_servers # No duplicates in lockfile assert lockfile.mcp_servers.count("ghcr.io/acme/mcp-shared") == 1 @@ -296,7 +296,7 @@ def test_multiple_packages_mcp_merged( _make_pkg(apm_modules, "acme/pkg-y", apm_deps=["acme/dep-y"]) _make_pkg(apm_modules, "acme/dep-y", mcp=["ghcr.io/acme/mcp-y"]) - _seed_lockfile(tmp_path / "apm.lock", [ + _seed_lockfile(tmp_path / "apm.lock.yaml", [ LockedDependency(repo_url="acme/pkg-x", depth=1, resolved_by=None, resolved_commit="cached"), LockedDependency(repo_url="acme/dep-x", depth=2, @@ -318,7 +318,7 @@ def test_multiple_packages_mcp_merged( f"CLI failed:\n{result.output}\n{getattr(result, 'stderr', '')}" ) - lockfile = LockFile.read(tmp_path / "apm.lock") + lockfile = LockFile.read(tmp_path / "apm.lock.yaml") assert "ghcr.io/acme/mcp-x" in lockfile.mcp_servers assert "ghcr.io/acme/mcp-y" in lockfile.mcp_servers finally: @@ -342,7 +342,7 @@ def test_full_install_collects_transitive_mcp( assert result.exit_code == 0, f"CLI failed:\n{result.output}\n{getattr(result, 'stderr', '')}" - lockfile = LockFile.read(tmp_path / "apm.lock") + lockfile = LockFile.read(tmp_path / "apm.lock.yaml") assert lockfile is not None assert "ghcr.io/acme/mcp-alpha" in lockfile.mcp_servers assert "ghcr.io/acme/mcp-beta" in lockfile.mcp_servers @@ -372,7 +372,7 @@ def test_stale_mcp_removed_on_update( ]) # Pre-existing lockfile still references both servers - _seed_lockfile(tmp_path / "apm.lock", [ + _seed_lockfile(tmp_path / "apm.lock.yaml", [ LockedDependency(repo_url="acme/infra-cloud", depth=1, resolved_by=None, resolved_commit="cached"), ], mcp_servers=["ghcr.io/acme/mcp-alpha", "ghcr.io/acme/mcp-beta"]) @@ -401,7 +401,7 @@ def test_stale_mcp_removed_on_update( assert "ghcr.io/acme/mcp-beta" in updated["servers"] # Lockfile must only list the remaining server - lockfile = LockFile.read(tmp_path / "apm.lock") + lockfile = LockFile.read(tmp_path / "apm.lock.yaml") assert "ghcr.io/acme/mcp-alpha" not in lockfile.mcp_servers assert "ghcr.io/acme/mcp-beta" in lockfile.mcp_servers @@ -421,7 +421,7 @@ def test_only_apm_preserves_mcp_servers( tmp_path, runner = cli_env # Seed lockfile with existing MCP servers - _seed_lockfile(tmp_path / "apm.lock", [ + _seed_lockfile(tmp_path / "apm.lock.yaml", [ LockedDependency(repo_url="acme/squad-alpha", depth=1, resolved_by=None, resolved_commit="cached"), LockedDependency(repo_url="acme/infra-cloud", depth=2, @@ -434,6 +434,6 @@ def test_only_apm_preserves_mcp_servers( assert result.exit_code == 0, f"CLI failed:\n{result.output}\n{getattr(result, 'stderr', '')}" # MCP servers must be preserved (not wiped) even with --only=apm - lockfile = LockFile.read(tmp_path / "apm.lock") + lockfile = LockFile.read(tmp_path / "apm.lock.yaml") assert "ghcr.io/acme/mcp-alpha" in lockfile.mcp_servers assert "ghcr.io/acme/mcp-beta" in lockfile.mcp_servers diff --git a/tests/test_enhanced_discovery.py b/tests/test_enhanced_discovery.py index bfe0c280..b4215b7d 100644 --- a/tests/test_enhanced_discovery.py +++ b/tests/test_enhanced_discovery.py @@ -436,7 +436,7 @@ def test_dependency_order_includes_transitive_from_lockfile(self): depth=3, resolved_by="rieraj/division-ime-agent-instructions", )) - lockfile.write(self.temp_dir_path / "apm.lock") + lockfile.write(self.temp_dir_path / "apm.lock.yaml") order = get_dependency_declaration_order(str(self.temp_dir_path)) @@ -474,7 +474,7 @@ def test_dependency_order_lockfile_no_duplicates(self): lockfile.add_dependency(LockedDependency(repo_url="rieraj/team-cot", depth=1)) lockfile.add_dependency(LockedDependency(repo_url="rieraj/division-ime", depth=1)) lockfile.add_dependency(LockedDependency(repo_url="rieraj/autodesk", depth=1)) - lockfile.write(self.temp_dir_path / "apm.lock") + lockfile.write(self.temp_dir_path / "apm.lock.yaml") order = get_dependency_declaration_order(str(self.temp_dir_path)) # No duplicates @@ -496,7 +496,7 @@ def test_scan_dependency_primitives_with_transitive(self): repo_url="owner/transitive-dep", depth=2, resolved_by="owner/direct-dep", )) - lockfile.write(self.temp_dir_path / "apm.lock") + lockfile.write(self.temp_dir_path / "apm.lock.yaml") # Create dependency directories with primitives direct_dep_dir = self.temp_dir_path / "apm_modules" / "owner" / "direct-dep" / ".apm" / "instructions" diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index c4106537..d49c729e 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -5,7 +5,7 @@ from unittest.mock import Mock import yaml -from apm_cli.deps.lockfile import LockedDependency, LockFile, get_lockfile_path +from apm_cli.deps.lockfile import LockedDependency, LockFile, get_lockfile_path, migrate_lockfile_if_needed from apm_cli.models.apm_package import DependencyReference @@ -159,7 +159,7 @@ def test_mcp_configs_backward_compat_null(self): assert lock.mcp_configs == {} def test_read_nonexistent(self, tmp_path): - loaded = LockFile.read(tmp_path / "apm.lock") + loaded = LockFile.read(tmp_path / "apm.lock.yaml") assert loaded is None def test_from_installed_packages(self): @@ -177,4 +177,41 @@ def test_from_installed_packages(self): class TestGetLockfilePath: def test_get_lockfile_path(self, tmp_path): path = get_lockfile_path(tmp_path) - assert path == tmp_path / "apm.lock" + assert path == tmp_path / "apm.lock.yaml" + + +class TestMigrateLockfileIfNeeded: + def test_migrates_legacy_lockfile(self, tmp_path): + """apm.lock should be renamed to apm.lock.yaml when new file is absent.""" + legacy = tmp_path / "apm.lock" + legacy.write_text("lockfile_version: '1'\ndependencies: []\n") + migrated = migrate_lockfile_if_needed(tmp_path) + assert migrated is True + assert not legacy.exists() + assert (tmp_path / "apm.lock.yaml").exists() + + def test_no_migration_when_new_file_exists(self, tmp_path): + """No migration when apm.lock.yaml already exists.""" + new_file = tmp_path / "apm.lock.yaml" + new_file.write_text("lockfile_version: '1'\ndependencies: []\n") + legacy = tmp_path / "apm.lock" + legacy.write_text("old content") + migrated = migrate_lockfile_if_needed(tmp_path) + assert migrated is False + assert legacy.exists() # untouched + assert new_file.read_text() == "lockfile_version: '1'\ndependencies: []\n" + + def test_no_migration_when_no_legacy_file(self, tmp_path): + """Returns False when neither file exists.""" + migrated = migrate_lockfile_if_needed(tmp_path) + assert migrated is False + + def test_migrated_file_is_readable(self, tmp_path): + """Migrated lockfile can be loaded by LockFile.read.""" + lock = LockFile(apm_version="1.0.0") + lock.add_dependency(LockedDependency(repo_url="owner/repo")) + lock.write(tmp_path / "apm.lock") + migrate_lockfile_if_needed(tmp_path) + loaded = LockFile.read(tmp_path / "apm.lock.yaml") + assert loaded is not None + assert loaded.has_dependency("owner/repo") diff --git a/tests/unit/test_mcp_lifecycle_e2e.py b/tests/unit/test_mcp_lifecycle_e2e.py index fb2647f1..62a09cf7 100644 --- a/tests/unit/test_mcp_lifecycle_e2e.py +++ b/tests/unit/test_mcp_lifecycle_e2e.py @@ -92,7 +92,7 @@ def test_transitive_mcp_collected_through_lockfile(self, tmp_path): ]) # Lockfile records both packages - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/squad-alpha", depth=1, resolved_by="root"), LockedDependency(repo_url="acme/infra-cloud", depth=2, resolved_by="acme/squad-alpha"), @@ -112,7 +112,7 @@ def test_orphan_pkg_mcp_not_collected(self, tmp_path): _make_pkg_dir(apm_modules, "acme/orphan-pkg", mcp=["ghcr.io/acme/orphan-server"]) # Only squad-alpha is locked - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/squad-alpha", depth=1, resolved_by="root"), ]) @@ -147,7 +147,7 @@ def test_depth_four_mcp_collected(self, tmp_path): "ghcr.io/acme/mcp-deep-server", ]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_by="root"), LockedDependency(repo_url="acme/pkg-b", depth=2, resolved_by="acme/pkg-a"), @@ -171,7 +171,7 @@ def test_mcp_at_every_level_collected(self, tmp_path): _make_pkg_dir(apm_modules, "acme/pkg-c", mcp=["ghcr.io/acme/mcp-level-3"]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_by="root"), LockedDependency(repo_url="acme/pkg-b", depth=2, resolved_by="acme/pkg-a"), @@ -210,7 +210,7 @@ def test_diamond_mcp_collected_once(self, tmp_path): "ghcr.io/acme/mcp-shared-server", ]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_by="root"), LockedDependency(repo_url="acme/pkg-b", depth=2, resolved_by="acme/pkg-a"), @@ -239,7 +239,7 @@ def test_diamond_multiple_mcp_from_branches(self, tmp_path): mcp=["ghcr.io/acme/mcp-branch-c"]) _make_pkg_dir(apm_modules, "acme/pkg-d", mcp=["ghcr.io/acme/mcp-leaf"]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_by="root"), LockedDependency(repo_url="acme/pkg-b", depth=2, resolved_by="acme/pkg-a"), @@ -295,7 +295,7 @@ def test_stale_servers_removed_from_mcp_json(self, tmp_path): def test_lockfile_mcp_list_updated_after_uninstall(self, tmp_path): os.chdir(tmp_path) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/base-lib", depth=1, resolved_by="root"), ], mcp_servers=["ghcr.io/acme/mcp-server-alpha", "ghcr.io/acme/mcp-server-beta"]) @@ -309,7 +309,7 @@ def test_lockfile_mcp_list_updated_after_uninstall(self, tmp_path): def test_lockfile_mcp_cleared_when_all_removed(self, tmp_path): os.chdir(tmp_path) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/base-lib", depth=1, resolved_by="root"), ], mcp_servers=["ghcr.io/acme/mcp-server-alpha"]) @@ -346,7 +346,7 @@ def test_rename_produces_correct_stale_set(self, tmp_path): "ghcr.io/acme/mcp-server-gamma", ]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/infra-cloud", depth=1, resolved_by="root"), ], mcp_servers=sorted(old_mcp)) @@ -398,7 +398,7 @@ def test_removed_mcp_detected_as_stale(self, tmp_path): apm_modules = tmp_path / "apm_modules" _make_pkg_dir(apm_modules, "acme/infra-cloud") # no mcp arg - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/infra-cloud", depth=1, resolved_by="root"), ], mcp_servers=sorted(old_mcp)) @@ -417,7 +417,7 @@ def test_removal_cleans_mcp_json_and_lockfile(self, tmp_path): "ghcr.io/acme/mcp-server-alpha": {"command": "npx", "args": ["alpha"]}, }) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/infra-cloud", depth=1, resolved_by="root"), ], mcp_servers=["ghcr.io/acme/mcp-server-alpha"]) @@ -445,7 +445,7 @@ def test_root_overrides_transitive_duplicate(self, tmp_path): "ghcr.io/acme/mcp-server-alpha", ]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/infra-cloud", depth=1, resolved_by="root"), ]) @@ -465,7 +465,7 @@ def test_dedup_preserves_distinct_servers(self, tmp_path): _make_pkg_dir(apm_modules, "acme/infra-cloud", mcp=["ghcr.io/acme/mcp-server-alpha"]) _make_pkg_dir(apm_modules, "acme/base-lib", mcp=["ghcr.io/acme/mcp-server-beta"]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/infra-cloud", depth=1, resolved_by="root"), LockedDependency(repo_url="acme/base-lib", depth=2, resolved_by="acme/infra-cloud"), @@ -497,7 +497,7 @@ def test_virtual_path_mcp_collected(self, tmp_path): mcp=["ghcr.io/acme/mcp-server-web"], ) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency( repo_url="acme/monorepo", @@ -523,7 +523,7 @@ def test_virtual_and_non_virtual_together(self, tmp_path): mcp=["ghcr.io/acme/mcp-api"], ) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/base-lib", depth=1, resolved_by="root"), LockedDependency( @@ -556,7 +556,7 @@ def test_self_defined_skipped_for_transitive(self, tmp_path): {"name": "private-srv", "registry": False, "transport": "http", "url": "https://private.example.com"}, ]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/infra-cloud", depth=2, resolved_by="some-dep"), ]) @@ -574,7 +574,7 @@ def test_direct_dep_self_defined_auto_trusted(self, tmp_path): {"name": "private-srv", "registry": False, "transport": "http", "url": "https://private.example.com"}, ]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/infra-cloud", depth=1, resolved_by="root"), ]) @@ -591,7 +591,7 @@ def test_self_defined_included_when_trusted(self, tmp_path): {"name": "private-srv", "registry": False, "transport": "http", "url": "https://private.example.com"}, ]) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" _write_lockfile(lock_path, [ LockedDependency(repo_url="acme/infra-cloud", depth=1, resolved_by="root"), ]) diff --git a/tests/unit/test_packer.py b/tests/unit/test_packer.py index 57a2e73f..b150cc5e 100644 --- a/tests/unit/test_packer.py +++ b/tests/unit/test_packer.py @@ -11,7 +11,7 @@ def _setup_project(tmp_path: Path, deployed_files: list[str], *, target: str | None = None) -> Path: - """Create a minimal project with apm.yml, apm.lock, and deployed files on disk.""" + """Create a minimal project with apm.yml, apm.lock.yaml, and deployed files on disk.""" project = tmp_path / "project" project.mkdir() @@ -30,7 +30,7 @@ def _setup_project(tmp_path: Path, deployed_files: list[str], *, target: str | N full.parent.mkdir(parents=True, exist_ok=True) full.write_text(f"content of {fpath}", encoding="utf-8") - # apm.lock with a single dependency containing those files + # apm.lock.yaml with a single dependency containing those files lockfile = LockFile() dep = LockedDependency( repo_url="owner/repo", @@ -38,7 +38,7 @@ def _setup_project(tmp_path: Path, deployed_files: list[str], *, target: str | N deployed_files=deployed_files, ) lockfile.add_dependency(dep) - lockfile.write(project / "apm.lock") + lockfile.write(project / "apm.lock.yaml") return project @@ -72,7 +72,7 @@ def test_pack_apm_format_vscode(self, tmp_path): for f in deployed: assert (result.bundle_path / f).exists() # Enriched lockfile present - lock_content = (result.bundle_path / "apm.lock").read_text() + lock_content = (result.bundle_path / "apm.lock.yaml").read_text() assert "pack:" in lock_content def test_pack_apm_format_claude(self, tmp_path): @@ -140,7 +140,7 @@ def test_pack_no_lockfile_errors(self, tmp_path): ) out = tmp_path / "build" - with pytest.raises(FileNotFoundError, match="apm.lock not found"): + with pytest.raises(FileNotFoundError, match="apm.lock.yaml not found"): pack_bundle(project, out) def test_pack_missing_deployed_file(self, tmp_path): @@ -156,7 +156,7 @@ def test_pack_missing_deployed_file(self, tmp_path): deployed_files=[".github/agents/ghost.md"], ) lockfile.add_dependency(dep) - lockfile.write(project / "apm.lock") + lockfile.write(project / "apm.lock.yaml") out = tmp_path / "build" with pytest.raises(ValueError, match="missing on disk"): @@ -171,7 +171,7 @@ def test_pack_empty_deployed_files(self, tmp_path): lockfile = LockFile() dep = LockedDependency(repo_url="owner/repo", deployed_files=[]) lockfile.add_dependency(dep) - lockfile.write(project / "apm.lock") + lockfile.write(project / "apm.lock.yaml") out = tmp_path / "build" result = pack_bundle(project, out) @@ -196,7 +196,7 @@ def test_pack_lockfile_enrichment(self, tmp_path): result = pack_bundle(project, out) - lock_yaml = yaml.safe_load((result.bundle_path / "apm.lock").read_text()) + lock_yaml = yaml.safe_load((result.bundle_path / "apm.lock.yaml").read_text()) assert "pack" in lock_yaml assert lock_yaml["pack"]["format"] == "apm" assert lock_yaml["pack"]["target"] == "vscode" @@ -207,22 +207,22 @@ def test_pack_lockfile_original_unchanged(self, tmp_path): project = _setup_project(tmp_path, deployed, target="vscode") out = tmp_path / "build" - original_content = (project / "apm.lock").read_text() + original_content = (project / "apm.lock.yaml").read_text() pack_bundle(project, out) - assert (project / "apm.lock").read_text() == original_content + assert (project / "apm.lock.yaml").read_text() == original_content def test_pack_rejects_embedded_traversal_in_deployed_path(self, tmp_path): """pack_bundle must reject path-traversal entries embedded in deployed_files.""" project = _setup_project(tmp_path, []) # A path that looks like it starts with .github/ but traverses out - lockfile = LockFile.read(project / "apm.lock") + lockfile = LockFile.read(project / "apm.lock.yaml") dep = LockedDependency( repo_url="owner/repo", deployed_files=[".github/../../../etc/passwd"], ) lockfile.add_dependency(dep) - lockfile.write(project / "apm.lock") + lockfile.write(project / "apm.lock.yaml") with pytest.raises(ValueError, match="unsafe path"): pack_bundle(project, tmp_path / "out") @@ -230,13 +230,13 @@ def test_pack_rejects_embedded_traversal_in_deployed_path(self, tmp_path): def test_pack_rejects_traversal_deployed_path(self, tmp_path): """pack_bundle must reject path-traversal entries in deployed_files.""" project = _setup_project(tmp_path, []) - lockfile = LockFile.read(project / "apm.lock") + lockfile = LockFile.read(project / "apm.lock.yaml") dep = LockedDependency( repo_url="owner/repo", deployed_files=[".github/agents/../../../../../../tmp/evil.sh"], ) lockfile.add_dependency(dep) - lockfile.write(project / "apm.lock") + lockfile.write(project / "apm.lock.yaml") with pytest.raises(ValueError, match="unsafe path"): pack_bundle(project, tmp_path / "out") diff --git a/tests/unit/test_transitive_deps.py b/tests/unit/test_transitive_deps.py index 9dc013de..75dae2dc 100644 --- a/tests/unit/test_transitive_deps.py +++ b/tests/unit/test_transitive_deps.py @@ -28,7 +28,7 @@ def test_returns_paths_for_regular_packages(self, tmp_path): lockfile = LockFile() lockfile.add_dependency(LockedDependency(repo_url="owner/repo-a", depth=1)) lockfile.add_dependency(LockedDependency(repo_url="owner/repo-b", depth=2)) - lockfile.write(tmp_path / "apm.lock") + lockfile.write(tmp_path / "apm.lock.yaml") paths = LockFile.installed_paths_for_project(tmp_path) assert "owner/repo-a" in paths @@ -37,7 +37,7 @@ def test_returns_paths_for_regular_packages(self, tmp_path): def test_no_duplicates(self, tmp_path): lockfile = LockFile() lockfile.add_dependency(LockedDependency(repo_url="owner/repo", depth=1)) - lockfile.write(tmp_path / "apm.lock") + lockfile.write(tmp_path / "apm.lock.yaml") paths = LockFile.installed_paths_for_project(tmp_path) assert paths.count("owner/repo") == 1 @@ -47,7 +47,7 @@ def test_ordered_by_depth_then_repo(self, tmp_path): lockfile.add_dependency(LockedDependency(repo_url="z/deep", depth=3)) lockfile.add_dependency(LockedDependency(repo_url="a/direct", depth=1)) lockfile.add_dependency(LockedDependency(repo_url="m/mid", depth=2)) - lockfile.write(tmp_path / "apm.lock") + lockfile.write(tmp_path / "apm.lock.yaml") paths = LockFile.installed_paths_for_project(tmp_path) assert paths == ["a/direct", "m/mid", "z/deep"] @@ -61,7 +61,7 @@ def test_virtual_file_package_path(self, tmp_path): virtual_path="prompts/code-review.prompt.md", depth=1, )) - lockfile.write(tmp_path / "apm.lock") + lockfile.write(tmp_path / "apm.lock.yaml") paths = LockFile.installed_paths_for_project(tmp_path) # Virtual file: owner/- → owner/repo-code-review @@ -69,7 +69,7 @@ def test_virtual_file_package_path(self, tmp_path): def test_corrupt_lockfile(self, tmp_path): """Corrupt lockfile should return empty list.""" - (tmp_path / "apm.lock").write_text("not: valid: yaml: [") + (tmp_path / "apm.lock.yaml").write_text("not: valid: yaml: [") assert LockFile.installed_paths_for_project(tmp_path) == [] @@ -93,7 +93,7 @@ def test_transitive_deps_appended_after_direct(self, tmp_path): lockfile.add_dependency(LockedDependency( repo_url="owner/transitive", depth=2, resolved_by="owner/direct", )) - lockfile.write(tmp_path / "apm.lock") + lockfile.write(tmp_path / "apm.lock.yaml") order = get_dependency_declaration_order(str(tmp_path)) assert order == ["owner/direct", "owner/transitive"] @@ -104,7 +104,7 @@ def test_direct_deps_not_duplicated(self, tmp_path): lockfile = LockFile() lockfile.add_dependency(LockedDependency(repo_url="owner/a", depth=1)) lockfile.add_dependency(LockedDependency(repo_url="owner/b", depth=1)) - lockfile.write(tmp_path / "apm.lock") + lockfile.write(tmp_path / "apm.lock.yaml") order = get_dependency_declaration_order(str(tmp_path)) assert order == ["owner/a", "owner/b"] @@ -125,7 +125,7 @@ def test_multiple_transitive_levels(self, tmp_path): repo_url="rieraj/autodesk-agent-instructions", depth=3, resolved_by="rieraj/division-ime-agent-instructions", )) - lockfile.write(tmp_path / "apm.lock") + lockfile.write(tmp_path / "apm.lock.yaml") order = get_dependency_declaration_order(str(tmp_path)) assert len(order) == 3 @@ -160,7 +160,7 @@ def _setup_project(self, tmp_path, direct_deps, lockfile_deps, installed_pkgs): lockfile = LockFile() for dep in lockfile_deps: lockfile.add_dependency(dep) - lockfile.write(tmp_path / "apm.lock") + lockfile.write(tmp_path / "apm.lock.yaml") # apm_modules directories for pkg in installed_pkgs: diff --git a/tests/unit/test_transitive_mcp.py b/tests/unit/test_transitive_mcp.py index c51b8e70..4f2fb360 100644 --- a/tests/unit/test_transitive_mcp.py +++ b/tests/unit/test_transitive_mcp.py @@ -175,7 +175,7 @@ def test_lockfile_scopes_collection_to_locked_packages(self, tmp_path): "dependencies": {"mcp": ["ghcr.io/orphan/server"]}, })) # Write lock file referencing only the locked package - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" lock_path.write_text(yaml.dump({ "lockfile_version": "1", "dependencies": [ @@ -206,7 +206,7 @@ def test_lockfile_with_virtual_path(self, tmp_path): "version": "1.0.0", "dependencies": {"mcp": ["ghcr.io/other/server"]}, })) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" lock_path.write_text(yaml.dump({ "lockfile_version": "1", "dependencies": [ @@ -229,7 +229,7 @@ def test_lockfile_paths_do_not_use_full_rglob_scan(self, tmp_path): "dependencies": {"mcp": ["ghcr.io/locked/server"]}, })) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" lock_path.write_text(yaml.dump({ "lockfile_version": "1", "dependencies": [ @@ -254,7 +254,7 @@ def test_invalid_lockfile_falls_back_to_rglob_scan(self, tmp_path): "dependencies": {"mcp": ["ghcr.io/a/server"]}, })) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" lock_path.write_text("dependencies: [") result = MCPIntegrator.collect_transitive(apm_modules, lock_path) @@ -322,7 +322,7 @@ def test_direct_dep_self_defined_auto_trusted(self, tmp_path): "transport": "http", "url": "https://private.example.com"}, ]}, })) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" lock_path.write_text(yaml.dump({ "lockfile_version": "1", "dependencies": [ @@ -346,7 +346,7 @@ def test_transitive_dep_self_defined_still_skipped(self, tmp_path): "transport": "http", "url": "https://private.example.com"}, ]}, })) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" lock_path.write_text(yaml.dump({ "lockfile_version": "1", "dependencies": [ @@ -369,7 +369,7 @@ def test_transitive_dep_trusted_with_flag(self, tmp_path): "transport": "http", "url": "https://private.example.com"}, ]}, })) - lock_path = tmp_path / "apm.lock" + lock_path = tmp_path / "apm.lock.yaml" lock_path.write_text(yaml.dump({ "lockfile_version": "1", "dependencies": [ diff --git a/tests/unit/test_uninstall_transitive_cleanup.py b/tests/unit/test_uninstall_transitive_cleanup.py index 1d85faf4..23273303 100644 --- a/tests/unit/test_uninstall_transitive_cleanup.py +++ b/tests/unit/test_uninstall_transitive_cleanup.py @@ -81,7 +81,7 @@ def test_uninstall_removes_transitive_dep(self): _make_apm_modules_dir(root, "acme/pkg-a") _make_apm_modules_dir(root, "acme/pkg-b") # transitive dep - _write_lockfile(root / "apm.lock", [ + _write_lockfile(root / "apm.lock.yaml", [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_commit="aaa"), LockedDependency(repo_url="acme/pkg-b", depth=2, resolved_by="acme/pkg-a", resolved_commit="bbb"), ]) @@ -109,7 +109,7 @@ def test_uninstall_keeps_shared_transitive_dep(self): _make_apm_modules_dir(root, "acme/pkg-c") _make_apm_modules_dir(root, "acme/shared-lib") - _write_lockfile(root / "apm.lock", [ + _write_lockfile(root / "apm.lock.yaml", [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_commit="aaa"), LockedDependency(repo_url="acme/pkg-c", depth=1, resolved_commit="ccc"), LockedDependency(repo_url="acme/shared-lib", depth=2, resolved_by="acme/pkg-a", resolved_commit="sss"), @@ -145,7 +145,7 @@ def test_uninstall_removes_deeply_nested_transitive_deps(self): _make_apm_modules_dir(root, "acme/pkg-b") _make_apm_modules_dir(root, "acme/pkg-c") - _write_lockfile(root / "apm.lock", [ + _write_lockfile(root / "apm.lock.yaml", [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_commit="aaa"), LockedDependency(repo_url="acme/pkg-b", depth=2, resolved_by="acme/pkg-a", resolved_commit="bbb"), LockedDependency(repo_url="acme/pkg-c", depth=3, resolved_by="acme/pkg-b", resolved_commit="ccc"), @@ -172,7 +172,7 @@ def test_uninstall_updates_lockfile(self): _make_apm_modules_dir(root, "acme/pkg-b") _make_apm_modules_dir(root, "acme/pkg-d") - _write_lockfile(root / "apm.lock", [ + _write_lockfile(root / "apm.lock.yaml", [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_commit="aaa"), LockedDependency(repo_url="acme/pkg-b", depth=2, resolved_by="acme/pkg-a", resolved_commit="bbb"), LockedDependency(repo_url="acme/pkg-d", depth=1, resolved_commit="ddd"), @@ -182,7 +182,7 @@ def test_uninstall_updates_lockfile(self): assert result.exit_code == 0 # Lockfile should still exist with pkg-d - updated_lock = LockFile.read(root / "apm.lock") + updated_lock = LockFile.read(root / "apm.lock.yaml") assert updated_lock is not None assert updated_lock.has_dependency("acme/pkg-d") assert not updated_lock.has_dependency("acme/pkg-a") @@ -200,14 +200,14 @@ def test_uninstall_removes_lockfile_when_no_deps_remain(self): _write_apm_yml(root / "apm.yml", ["acme/pkg-a"]) _make_apm_modules_dir(root, "acme/pkg-a") - _write_lockfile(root / "apm.lock", [ + _write_lockfile(root / "apm.lock.yaml", [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_commit="aaa"), ]) result = self.runner.invoke(cli, ["uninstall", "acme/pkg-a"]) assert result.exit_code == 0 - assert not (root / "apm.lock").exists() + assert not (root / "apm.lock.yaml").exists() finally: os.chdir(os.path.dirname(os.path.abspath(__file__))) # restore CWD before TemporaryDirectory cleanup @@ -222,7 +222,7 @@ def test_dry_run_shows_transitive_deps(self): _make_apm_modules_dir(root, "acme/pkg-a") _make_apm_modules_dir(root, "acme/pkg-b") - _write_lockfile(root / "apm.lock", [ + _write_lockfile(root / "apm.lock.yaml", [ LockedDependency(repo_url="acme/pkg-a", depth=1, resolved_commit="aaa"), LockedDependency(repo_url="acme/pkg-b", depth=2, resolved_by="acme/pkg-a", resolved_commit="bbb"), ]) diff --git a/tests/unit/test_unpacker.py b/tests/unit/test_unpacker.py index 135280e5..6bb83d56 100644 --- a/tests/unit/test_unpacker.py +++ b/tests/unit/test_unpacker.py @@ -29,7 +29,7 @@ def _build_bundle_dir(tmp_path: Path, deployed_files: list[str]) -> Path: deployed_files=deployed_files, ) lockfile.add_dependency(dep) - lockfile.write(bundle / "apm.lock") + lockfile.write(bundle / "apm.lock.yaml") return bundle @@ -93,7 +93,7 @@ def test_unpack_verify_missing_file(self, tmp_path): deployed_files=deployed, ) lockfile.add_dependency(dep) - lockfile.write(bundle_dir / "apm.lock") + lockfile.write(bundle_dir / "apm.lock.yaml") output = tmp_path / "target" output.mkdir() @@ -115,7 +115,7 @@ def test_unpack_skip_verify(self, tmp_path): deployed_files=deployed, ) lockfile.add_dependency(dep) - lockfile.write(bundle_dir / "apm.lock") + lockfile.write(bundle_dir / "apm.lock.yaml") output = tmp_path / "target" output.mkdir() @@ -179,7 +179,8 @@ def test_unpack_lockfile_not_scattered(self, tmp_path): unpack_bundle(bundle, output) - # apm.lock should NOT be copied to the output root + # lockfile should NOT be copied to the output root + assert not (output / "apm.lock.yaml").exists() assert not (output / "apm.lock").exists() def test_unpack_rejects_absolute_path_in_deployed_files(self, tmp_path): @@ -189,7 +190,7 @@ def test_unpack_rejects_absolute_path_in_deployed_files(self, tmp_path): lockfile = LockFile() dep = LockedDependency(repo_url="owner/repo", deployed_files=["/etc/passwd"]) lockfile.add_dependency(dep) - lockfile.write(bundle_dir / "apm.lock") + lockfile.write(bundle_dir / "apm.lock.yaml") output = tmp_path / "target" output.mkdir() @@ -203,7 +204,7 @@ def test_unpack_rejects_traversal_path_in_deployed_files(self, tmp_path): lockfile = LockFile() dep = LockedDependency(repo_url="owner/repo", deployed_files=["../outside.txt"]) lockfile.add_dependency(dep) - lockfile.write(bundle_dir / "apm.lock") + lockfile.write(bundle_dir / "apm.lock.yaml") output = tmp_path / "target" output.mkdir() @@ -241,7 +242,7 @@ def test_unpack_dependency_files_multiple_deps(self, tmp_path): lockfile.add_dependency( LockedDependency(repo_url="org/repo-b", deployed_files=files_b) ) - lockfile.write(bundle_dir / "apm.lock") + lockfile.write(bundle_dir / "apm.lock.yaml") output = tmp_path / "target" output.mkdir() @@ -280,7 +281,7 @@ def test_unpack_dependency_files_virtual_deps(self, tmp_path): deployed_files=files_b, ) ) - lockfile.write(bundle_dir / "apm.lock") + lockfile.write(bundle_dir / "apm.lock.yaml") output = tmp_path / "target" output.mkdir() @@ -317,7 +318,7 @@ def test_unpack_skipped_count(self, tmp_path): lockfile.add_dependency( LockedDependency(repo_url="owner/repo", deployed_files=deployed) ) - lockfile.write(bundle_dir / "apm.lock") + lockfile.write(bundle_dir / "apm.lock.yaml") output = tmp_path / "target" output.mkdir() @@ -338,6 +339,30 @@ def test_unpack_skipped_count_zero_when_all_present(self, tmp_path): assert result.skipped_count == 0 + def test_unpack_legacy_lockfile_backward_compat(self, tmp_path): + """Bundles with legacy apm.lock (no .yaml) are still readable.""" + deployed = [".github/agents/a.md"] + bundle_dir = tmp_path / "bundle" / "legacy-pkg" + bundle_dir.mkdir(parents=True) + + (bundle_dir / ".github" / "agents").mkdir(parents=True) + (bundle_dir / ".github" / "agents" / "a.md").write_text("ok") + + lockfile = LockFile() + lockfile.add_dependency( + LockedDependency(repo_url="owner/repo", deployed_files=deployed) + ) + # Write using the legacy name to simulate an old bundle + lockfile.write(bundle_dir / "apm.lock") + + output = tmp_path / "target" + output.mkdir() + + result = unpack_bundle(bundle_dir, output) + + assert set(result.files) == set(deployed) + assert (output / ".github" / "agents" / "a.md").exists() + class TestUnpackCmdLogging: """Verify CLI output for the unpack command.""" @@ -413,7 +438,7 @@ def test_unpack_cmd_logs_skipped_files(self, tmp_path): lockfile.add_dependency( LockedDependency(repo_url="owner/repo", deployed_files=deployed) ) - lockfile.write(bundle_dir / "apm.lock") + lockfile.write(bundle_dir / "apm.lock.yaml") output = tmp_path / "target" output.mkdir() @@ -455,7 +480,7 @@ def test_unpack_cmd_multi_dep_logging(self, tmp_path): lockfile.add_dependency( LockedDependency(repo_url="org/repo-b", deployed_files=files_b) ) - lockfile.write(bundle_dir / "apm.lock") + lockfile.write(bundle_dir / "apm.lock.yaml") output = tmp_path / "target" output.mkdir()