diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f70b75a3..9bb67de98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Windows admin lifecycle policies now resolve from `%ProgramData%` instead of
+ assuming `C:\ProgramData`, while retaining the historical fallback.
+ (by @lukiod; closes #2684) (#2686)
- Marketplace installs now materialize catalog-only LSP and MCP metadata
without requiring a package manifest in the downloaded source
(by @lkshrk, #2709).
diff --git a/docs/src/content/docs/enterprise/lifecycle-scripts.md b/docs/src/content/docs/enterprise/lifecycle-scripts.md
index 5427f9188..59c98bbe6 100644
--- a/docs/src/content/docs/enterprise/lifecycle-scripts.md
+++ b/docs/src/content/docs/enterprise/lifecycle-scripts.md
@@ -31,8 +31,10 @@ Scripts are defined in three tiers. The **project tier** uses the repository `ap
manifest under a top-level `lifecycle:` key. The **user tier** uses
`~/.apm/apm.yml` (or `$APM_HOME/apm.yml`) under the same `lifecycle:` key.
The **admin** tier uses `/etc/apm/policy.d/*.json` on POSIX systems, or
-`C:\ProgramData\APM\policy.d\*.json` on Windows. It is suited for
-machine- and fleet-managed deployment.
+`%ProgramData%\APM\policy.d\*.json` on Windows. `%ProgramData%` normally
+expands to `C:\ProgramData`; APM uses that default if the value is missing or
+is not an absolute Windows path. The tier is suited for machine- and
+fleet-managed deployment.
## Supported events
@@ -168,7 +170,7 @@ disabled; the global kill switches below suppress all lifecycle scripts.
| Priority | Path | Who controls | Format |
|--------------|-----------------------------------------------------------------------------|------------------|--------|
-| 1 (highest) | POSIX: `/etc/apm/policy.d/*.json`
Windows: `C:\ProgramData\APM\policy.d\*.json` | Platform/IT team | JSON |
+| 1 (highest) | POSIX: `/etc/apm/policy.d/*.json`
Windows: `%ProgramData%\APM\policy.d\*.json` | Platform/IT team | JSON |
| 2 | `~/.apm/apm.yml` | Individual user | YAML |
| 3 | `apm.yml` `lifecycle:` | Project | YAML |
@@ -199,7 +201,7 @@ POST body.
Lifecycle scripts from different sources are subject to different trust rules:
- **Policy scripts** (`/etc/apm/policy.d/*.json` on POSIX systems or
- `C:\ProgramData\APM\policy.d\*.json` on Windows) -- controlled by
+ `%ProgramData%\APM\policy.d\*.json` on Windows) -- controlled by
your platform/IT team. Run without any consent gate; they cannot be
individually disabled by the developer. `APM_NO_SCRIPTS=1` suppresses
all lifecycle-script tiers for that run.
@@ -234,7 +236,7 @@ policy directory to track which packages are actively used:
Create `analytics.json` in the platform admin directory:
- POSIX: `/etc/apm/policy.d/analytics.json`
-- Windows: `C:\ProgramData\APM\policy.d\analytics.json`
+- Windows: `%ProgramData%\APM\policy.d\analytics.json`
```json
{
diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md
index ffc90c4de..26c9779f3 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/commands.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md
@@ -182,7 +182,7 @@ If the install cache has not been warmed (e.g. a fresh checkout before the first
| `apm lifecycle trust` | Trust `apm.yml` `lifecycle:` at its current contents so project scripts run on install | -- |
| `apm lifecycle untrust` | Revoke trust for `apm.yml` `lifecycle:`; project scripts will stop running | -- |
-Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update`, `post-update`, `pre-uninstall`, `post-uninstall`. `post-install` fires only after success or partial success; failed and dry-run installs skip it. Script files are discovered from three sources (additive): policy (POSIX: `/etc/apm/policy.d/*.json`; Windows: `C:\ProgramData\APM\policy.d\*.json`; JSON), user (`~/.apm/apm.yml`, YAML), project (`apm.yml` `lifecycle:` at repo root, YAML). Two script types: `command` (shell via subprocess, event JSON on stdin) and `http` (HTTPS POST). Script output is appended to `~/.apm/logs/scripts.log`. See the [Lifecycle scripts](/apm/enterprise/lifecycle-scripts/) guide for full documentation.
+Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update`, `post-update`, `pre-uninstall`, `post-uninstall`. `post-install` fires only after success or partial success; failed and dry-run installs skip it. Script files are discovered from three sources (additive): policy (POSIX: `/etc/apm/policy.d/*.json`; Windows: `%ProgramData%\APM\policy.d\*.json`; JSON), user (`~/.apm/apm.yml`, YAML), project (`apm.yml` `lifecycle:` at repo root, YAML). Two script types: `command` (shell via subprocess, event JSON on stdin) and `http` (HTTPS POST). Script output is appended to `~/.apm/logs/scripts.log`. See the [Lifecycle scripts](/apm/enterprise/lifecycle-scripts/) guide for full documentation.
## Distribution
diff --git a/src/apm_cli/core/lifecycle_scripts.py b/src/apm_cli/core/lifecycle_scripts.py
index d73ca35f0..569f8492a 100644
--- a/src/apm_cli/core/lifecycle_scripts.py
+++ b/src/apm_cli/core/lifecycle_scripts.py
@@ -4,7 +4,7 @@
update, and uninstall operations. Scripts are configured in well-known
locations discovered from three tiers:
-1. Policy -- /etc/apm/policy.d/*.json (admin-owned, JSON drop-ins, unchanged)
+1. Policy -- platform-specific admin directory (JSON drop-ins, unchanged)
2. User -- ~/.apm/apm.yml (or $APM_HOME/apm.yml) lifecycle: key
3. Project -- apm.yml lifecycle: key (repo root)
@@ -38,7 +38,7 @@
import threading
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
-from pathlib import Path
+from pathlib import Path, PureWindowsPath
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -314,9 +314,9 @@ def parse_apm_yml_lifecycle_with_fingerprint(
def parse_script_file(path: Path, source: str = "project") -> list[ScriptEntry]:
"""Parse a single JSON script file into a list of ScriptEntry.
- Used for JSON-backed sources such as the admin policy tier
- (/etc/apm/policy.d/*.json). Returns an empty list if the file is
- malformed or uses an unsupported version.
+ Used for JSON-backed sources such as the platform-specific admin policy
+ tier. Returns an empty list if the file is malformed or uses an unsupported
+ version.
"""
try:
with open(path, encoding="utf-8") as f:
@@ -344,10 +344,17 @@ def parse_project_script_file(path: Path) -> list[ScriptEntry]:
def _get_policy_scripts_dir() -> Path:
- """Return the platform-specific policy scripts directory."""
+ """Return the platform-specific policy scripts directory.
+
+ Windows normally supplies an absolute ``ProgramData`` environment value.
+ Fall back to its historical default when that value is missing or unsafe.
+ """
system = platform.system()
if system == "Windows":
- return Path(r"C:\ProgramData\APM\policy.d")
+ program_data = os.environ.get("PROGRAMDATA")
+ if not program_data or not PureWindowsPath(program_data).is_absolute():
+ program_data = r"C:\ProgramData"
+ return Path(program_data) / "APM" / "policy.d"
return Path("/etc/apm/policy.d")
@@ -385,7 +392,7 @@ def discover_scripts(
"""Discover and merge scripts from all three sources.
Load order (all additive, policy first):
- 1. Policy -- /etc/apm/policy.d/*.json (directory, JSON)
+ 1. Policy -- platform-specific admin directory (JSON)
2. User -- ~/.apm/apm.yml (or $APM_HOME/apm.yml) lifecycle: key
3. Project -- apm.yml lifecycle: key (repo root)
"""
diff --git a/tests/unit/core/test_lifecycle_scripts.py b/tests/unit/core/test_lifecycle_scripts.py
index 77efff7e4..214180ef5 100644
--- a/tests/unit/core/test_lifecycle_scripts.py
+++ b/tests/unit/core/test_lifecycle_scripts.py
@@ -3,9 +3,11 @@
from __future__ import annotations
import json
+import os
from pathlib import Path
from unittest.mock import MagicMock, patch
+import pytest
import yaml
from apm_cli.core.lifecycle_scripts import (
@@ -16,6 +18,7 @@
PackageInfo,
ScriptEntry,
_entries_from_lifecycle_map,
+ _get_policy_scripts_dir,
build_runner_from_context,
discover_scripts,
parse_apm_yml_lifecycle,
@@ -153,6 +156,65 @@ def test_parse_project_script_file_is_alias(self, tmp_path: Path) -> None:
assert parse_project_script_file(path) == parse_apm_yml_lifecycle(path, "project")
+@pytest.mark.windows_compat
+class TestGetPolicyScriptsDir:
+ def test_windows_honours_programdata_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr("platform.system", lambda: "Windows")
+ monkeypatch.setenv("PROGRAMDATA", r"D:\ProgramData")
+ assert _get_policy_scripts_dir() == Path(r"D:\ProgramData") / "APM" / "policy.d"
+
+ def test_windows_falls_back_to_c_drive_when_unset(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr("platform.system", lambda: "Windows")
+ monkeypatch.delenv("PROGRAMDATA", raising=False)
+ assert _get_policy_scripts_dir() == Path(r"C:\ProgramData") / "APM" / "policy.d"
+
+ @pytest.mark.parametrize("program_data", ["", "relative"])
+ def test_windows_falls_back_when_programdata_is_unsafe(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ program_data: str,
+ ) -> None:
+ monkeypatch.setattr("platform.system", lambda: "Windows")
+ monkeypatch.setenv("PROGRAMDATA", program_data)
+ assert _get_policy_scripts_dir() == Path(r"C:\ProgramData") / "APM" / "policy.d"
+
+ def test_windows_discovers_policy_scripts_from_programdata(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ if os.name == "nt":
+ program_data = tmp_path / "ProgramData"
+ program_data_value = str(program_data)
+ else:
+ program_data_value = r"D:\ProgramData"
+ program_data = tmp_path / program_data_value
+ monkeypatch.chdir(tmp_path)
+ script_file = program_data / "APM" / "policy.d" / "admin.json"
+ script_file.parent.mkdir(parents=True)
+ script_file.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "scripts": {"post-install": [{"type": "command", "command": "echo admin"}]},
+ }
+ ),
+ encoding="utf-8",
+ )
+ monkeypatch.setattr("platform.system", lambda: "Windows")
+ monkeypatch.setenv("PROGRAMDATA", program_data_value)
+
+ entries = discover_scripts(project_root=str(tmp_path / "project"))
+
+ assert [(entry.source, entry.command) for entry in entries] == [("policy", "echo admin")]
+
+ def test_non_windows_uses_etc(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr("platform.system", lambda: "Linux")
+ assert _get_policy_scripts_dir() == Path("/etc/apm/policy.d")
+
+
class TestDiscoverScripts:
def test_discovers_from_project_file(self, tmp_path: Path) -> None:
_write_yaml(