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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Windows antivirus file-lock errors (`WinError 32`) during `apm install`: new `file_ops` retry utility with exponential backoff for `rmtree`/`copytree`/`copy2` operations (#453)
- `install.sh` now falls back to pip when binary fails in devcontainers with older glibc (#456)
- Skills now deploy to all active targets (`.opencode/`, `.cursor/`) instead of only `.github/` (#456)
- `apm install` no longer rewrites `apm.lock.yaml` when dependencies are unchanged, eliminating `generated_at` churn in version control (#456)
Expand Down
13 changes: 10 additions & 3 deletions docs/src/content/docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,17 @@ mkdir -p ~/bin

### Authentication errors when installing packages

If `apm install` fails with authentication errors for private repositories, ensure you have a valid GitHub token configured:
See [Authentication -- Troubleshooting](../authentication/#troubleshooting) for token setup, SSO authorization, and diagnosing auth failures.

```bash
curl -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/user
### File access errors on Windows (antivirus / endpoint protection)

If `apm install` fails with `The process cannot access the file because it is being used by another process`, your antivirus or endpoint protection software is likely scanning temp files during installation.

APM retries file operations automatically with exponential backoff to handle transient locks. If the issue persists, set `APM_DEBUG=1` to see retry diagnostics:

```powershell
$env:APM_DEBUG = "1"
apm install <package>
```

## Next steps
Expand Down
42 changes: 15 additions & 27 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,27 +85,11 @@ def _close_repo(repo) -> None:
def _rmtree(path) -> None:
"""Remove a directory tree, handling read-only files and brief Windows locks.

Git pack/index files are often read-only, and on Windows git processes may
hold brief locks even after the Repo object is closed. This wrapper uses
an onerror callback for read-only files and a single retry for lock races.
Delegates to :func:`robust_rmtree` which retries with exponential backoff
on transient lock errors (e.g. antivirus scanning on Windows).
"""
def _on_readonly(func, fpath, _exc_info):
"""onerror callback: make read-only files writable and retry."""
try:
os.chmod(fpath, stat.S_IWRITE)
func(fpath)
except OSError:
pass

try:
shutil.rmtree(path, onerror=_on_readonly)
except PermissionError:
if sys.platform == 'win32':
# Single retry after a brief wait for lingering git handles
time.sleep(0.5)
shutil.rmtree(path, ignore_errors=True)
# On all platforms: don't raise from cleanup — just leave the
# temp dir behind (the OS will clean it up eventually).
from ..utils.file_ops import robust_rmtree
robust_rmtree(path, ignore_errors=True)


class GitProgressReporter(RemoteProgress):
Expand Down Expand Up @@ -1626,14 +1610,16 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
_rmtree(target_path)
target_path.mkdir(parents=True, exist_ok=True)

# Copy subdirectory contents to target
# Copy subdirectory contents to target (retry on transient
# file-lock errors caused by antivirus scanning on Windows).
from ..utils.file_ops import robust_copytree, robust_copy2
for item in source_subdir.iterdir():
src = source_subdir / item.name
dst = target_path / item.name
if src.is_dir():
shutil.copytree(src, dst)
robust_copytree(src, dst)
else:
shutil.copy2(src, dst)
robust_copy2(src, dst)

# Capture commit SHA; close the Repo object immediately so its file
# handles are released before _rmtree() runs in the finally block.
Expand Down Expand Up @@ -1723,16 +1709,17 @@ def _download_subdirectory_from_artifactory(
f"Artifactory ({host}/{prefix}/{owner}/{repo}#{ref})"
)
target_path.mkdir(parents=True, exist_ok=True)
from ..utils.file_ops import robust_rmtree, robust_copytree, robust_copy2
if target_path.exists() and any(target_path.iterdir()):
shutil.rmtree(target_path)
robust_rmtree(target_path)
target_path.mkdir(parents=True, exist_ok=True)
for item in source_subdir.iterdir():
src = source_subdir / item.name
dst = target_path / item.name
if src.is_dir():
shutil.copytree(src, dst)
robust_copytree(src, dst)
else:
shutil.copy2(src, dst)
robust_copy2(src, dst)

if progress_obj and progress_task_id is not None:
progress_obj.update(progress_task_id, completed=80, total=100)
Expand Down Expand Up @@ -1771,7 +1758,8 @@ def _download_package_from_artifactory(

_debug(f"Downloading from Artifactory: {host}/{prefix}/{owner}/{repo}#{ref}")
if target_path.exists() and any(target_path.iterdir()):
shutil.rmtree(target_path)
from ..utils.file_ops import robust_rmtree
robust_rmtree(target_path)
target_path.mkdir(parents=True, exist_ok=True)
if progress_obj and progress_task_id is not None:
progress_obj.update(progress_task_id, total=100, completed=10)
Expand Down
Loading
Loading