Skip to content

Commit 549e55a

Browse files
fix(uninstall): scan devDependencies.apm so --dev installs can be removed (closes #1549) (#1552)
* fix(uninstall): scan devDependencies.apm so --dev installs can be removed apm install --dev <pkg> writes the entry under devDependencies.apm in apm.yml, but apm uninstall <pkg> only read dependencies.apm. The result was an unconditional 'not found in apm.yml' warning and the dev entry leaking forever. Uninstall now scans both sections and writes back to whichever section the package lived in. Closes #1549 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(changelog): backfill PR number for #1549 fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(uninstall): fold dev dependency followups Update uninstall documentation and changelog wording for the devDependencies.apm fix, make verbose removal logs name the manifest section, and add coverage that prod-only manifests do not synthesize devDependencies. Addresses panel follow-ups for PR #1552. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4515f29 commit 549e55a

3 files changed

Lines changed: 137 additions & 6 deletions

File tree

docs/src/content/docs/reference/cli/uninstall.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ apm uninstall https://github.com/acme/my-package.git
7575

7676
What gets removed, in order:
7777

78-
1. The package entry in `apm.yml` under `dependencies.apm`.
78+
1. The package entry in `apm.yml` under `dependencies.apm` or `devDependencies.apm`.
7979
2. The package folder under `apm_modules/owner/repo/`.
8080
3. Transitive dependencies that no remaining package depends on (npm-style pruning, computed from `apm.lock.yaml`).
8181
4. Every file in the lockfile's `deployed_files` for the removed packages and pruned orphans, across all harness folders (`.github/`, `.claude/`, `.cursor/`, `.opencode/`, `.gemini/`, `.codex/`, `.windsurf/`).

src/apm_cli/commands/uninstall/cli.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,20 @@ def uninstall(ctx, packages, dry_run, verbose, global_):
9898
data["dependencies"] = {}
9999
if "apm" not in data["dependencies"]:
100100
data["dependencies"]["apm"] = []
101-
102-
current_deps = data["dependencies"]["apm"] or []
101+
# Track whether devDependencies was synthesised so we don't leave
102+
# an empty section behind for projects that never used --dev.
103+
had_dev_section = "devDependencies" in data
104+
if not had_dev_section:
105+
data["devDependencies"] = {}
106+
if "apm" not in data["devDependencies"]:
107+
data["devDependencies"]["apm"] = []
108+
109+
prod_deps = data["dependencies"]["apm"] or []
110+
dev_deps = data["devDependencies"]["apm"] or []
111+
# `apm install --dev <pkg>` writes under devDependencies.apm. Uninstall
112+
# must scan both sections so dev-installed packages are removable
113+
# (regression trap for #1549).
114+
current_deps = list(prod_deps) + list(dev_deps)
103115

104116
# Load lockfile early: used for marketplace ref resolution in Step 1
105117
# and reused for MCP state capture and transitive orphan cleanup below.
@@ -128,9 +140,21 @@ def uninstall(ctx, packages, dry_run, verbose, global_):
128140

129141
# Step 3: Remove from apm.yml
130142
for package in packages_to_remove:
131-
current_deps.remove(package)
132-
logger.progress(f"Removed {package} from apm.yml")
133-
data["dependencies"]["apm"] = current_deps
143+
if package in dev_deps:
144+
dev_deps.remove(package)
145+
section = "devDependencies.apm"
146+
elif package in prod_deps:
147+
prod_deps.remove(package)
148+
section = "dependencies.apm"
149+
logger.progress(f"Removed {package} from {section} in apm.yml")
150+
data["dependencies"]["apm"] = prod_deps
151+
data["devDependencies"]["apm"] = dev_deps
152+
# Drop empty devDependencies wrappers so the manifest stays clean
153+
# for projects that never used --dev.
154+
if not data["devDependencies"]["apm"]:
155+
del data["devDependencies"]["apm"]
156+
if not data["devDependencies"] and not had_dev_section:
157+
del data["devDependencies"]
134158
try:
135159
dump_yaml(data, apm_yml_path)
136160
logger.success(f"Updated {apm_yml_path} (removed {len(packages_to_remove)} package(s))")
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Tests for `apm uninstall` covering the devDependencies.apm section.
2+
3+
Regression trap for #1549: packages installed via `apm install --dev <pkg>`
4+
were stored under `devDependencies.apm` in apm.yml, but `apm uninstall <pkg>`
5+
only scanned `dependencies.apm`. The result was an unconditional
6+
"not found in apm.yml" warning and the dev entry leaking forever.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from pathlib import Path
12+
13+
import yaml
14+
from click.testing import CliRunner
15+
16+
17+
def _write_apm_yml(root: Path, *, deps: list | None = None, dev_deps: list | None = None) -> None:
18+
"""Write an apm.yml with the requested dependency sections."""
19+
data: dict = {"name": "test-project", "version": "1.0.0", "target": "copilot"}
20+
if deps is not None:
21+
data["dependencies"] = {"apm": deps}
22+
if dev_deps is not None:
23+
data["devDependencies"] = {"apm": dev_deps}
24+
(root / "apm.yml").write_text(yaml.dump(data), encoding="utf-8")
25+
26+
27+
def _read_apm_yml(root: Path) -> dict:
28+
return yaml.safe_load((root / "apm.yml").read_text(encoding="utf-8"))
29+
30+
31+
class TestUninstallDevDependencies:
32+
"""Regression trap for #1549."""
33+
34+
def test_uninstall_removes_package_from_dev_dependencies(
35+
self, tmp_path: Path, monkeypatch
36+
) -> None:
37+
"""A package recorded under devDependencies.apm must be removable."""
38+
monkeypatch.chdir(tmp_path)
39+
_write_apm_yml(tmp_path, deps=[], dev_deps=["microsoft/apm-sample-package"])
40+
41+
from apm_cli.cli import cli
42+
43+
runner = CliRunner()
44+
result = runner.invoke(cli, ["uninstall", "microsoft/apm-sample-package"])
45+
46+
assert result.exit_code == 0, result.output
47+
# "not found in apm.yml" is the bug-mode failure message.
48+
assert "not found in apm.yml" not in result.output
49+
50+
data = _read_apm_yml(tmp_path)
51+
dev_apm = (data.get("devDependencies") or {}).get("apm") or []
52+
assert "microsoft/apm-sample-package" not in dev_apm, (
53+
"package should have been removed from devDependencies.apm"
54+
)
55+
56+
def test_uninstall_dry_run_finds_dev_dependency(self, tmp_path: Path, monkeypatch) -> None:
57+
"""`--dry-run` must locate dev-only packages too."""
58+
monkeypatch.chdir(tmp_path)
59+
_write_apm_yml(tmp_path, deps=[], dev_deps=["microsoft/apm-sample-package"])
60+
61+
from apm_cli.cli import cli
62+
63+
runner = CliRunner()
64+
result = runner.invoke(cli, ["uninstall", "microsoft/apm-sample-package", "--dry-run"])
65+
66+
assert result.exit_code == 0, result.output
67+
assert "not found in apm.yml" not in result.output
68+
# apm.yml must be unchanged in dry-run mode.
69+
data = _read_apm_yml(tmp_path)
70+
assert data["devDependencies"]["apm"] == ["microsoft/apm-sample-package"]
71+
72+
def test_uninstall_preserves_unrelated_dev_dependency(
73+
self, tmp_path: Path, monkeypatch
74+
) -> None:
75+
"""Removing one dev dep must not touch other dev or prod deps."""
76+
monkeypatch.chdir(tmp_path)
77+
_write_apm_yml(
78+
tmp_path,
79+
deps=["acme/keep-prod"],
80+
dev_deps=["microsoft/apm-sample-package", "acme/keep-dev"],
81+
)
82+
83+
from apm_cli.cli import cli
84+
85+
runner = CliRunner()
86+
result = runner.invoke(cli, ["uninstall", "microsoft/apm-sample-package"])
87+
88+
assert result.exit_code == 0, result.output
89+
data = _read_apm_yml(tmp_path)
90+
assert data["dependencies"]["apm"] == ["acme/keep-prod"]
91+
assert data["devDependencies"]["apm"] == ["acme/keep-dev"]
92+
93+
def test_uninstall_prod_dependency_does_not_synthesize_dev_section(
94+
self, tmp_path: Path, monkeypatch
95+
) -> None:
96+
"""Removing a prod dep must not add devDependencies to prod-only manifests."""
97+
monkeypatch.chdir(tmp_path)
98+
_write_apm_yml(tmp_path, deps=["microsoft/apm-sample-package"])
99+
100+
from apm_cli.cli import cli
101+
102+
runner = CliRunner()
103+
result = runner.invoke(cli, ["uninstall", "microsoft/apm-sample-package"])
104+
105+
assert result.exit_code == 0, result.output
106+
data = _read_apm_yml(tmp_path)
107+
assert "devDependencies" not in data

0 commit comments

Comments
 (0)