From fc54f5ed47786a190c2c2808cc6350593d464247 Mon Sep 17 00:00:00 2001 From: Morten Enemark Lund Date: Sat, 11 Apr 2026 16:37:27 +0200 Subject: [PATCH 1/4] feat: implement self-update policy controls and notifications --- .../content/docs/reference/cli-commands.md | 3 ++ src/apm_cli/commands/_helpers.py | 7 +++- src/apm_cli/commands/update.py | 6 +++ src/apm_cli/update_policy.py | 39 +++++++++++++++++++ tests/unit/test_command_helpers.py | 9 +++++ tests/unit/test_update_command.py | 22 +++++++++++ 6 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/apm_cli/update_policy.py diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 438ded4f..ba644e7c 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -586,6 +586,7 @@ apm update - Downloads and runs the official platform installer (`install.sh` on macOS/Linux, `install.ps1` on Windows) - Preserves existing configuration and projects - Shows progress and success/failure status +- Some package-manager distributions can disable self-update at build time. In those builds, `apm update` prints a distributor-defined guidance message (for example, a `pixi update` command) and exits without running the installer. **Version Checking:** APM automatically checks for updates (at most once per day) when running any command. If a newer version is available, you'll see a yellow warning: @@ -597,6 +598,8 @@ Run apm update to upgrade This check is non-blocking and cached to avoid slowing down the CLI. +In distributions that disable self-update at build time, this startup update notification is skipped. + **Manual Update:** If the automatic update fails, you can always update manually: diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py index 6f220455..11fba0e6 100644 --- a/src/apm_cli/commands/_helpers.py +++ b/src/apm_cli/commands/_helpers.py @@ -21,6 +21,7 @@ GITIGNORE_FILENAME, ) from ..utils.console import _rich_echo, _rich_info, _rich_warning +from ..update_policy import get_update_hint_message, is_self_update_enabled from ..version import get_build_sha, get_version from ..utils.version_checker import check_for_updates @@ -240,6 +241,10 @@ def print_version(ctx, param, value): def _check_and_notify_updates(): """Check for updates and notify user non-blockingly.""" try: + # Skip notifications when self-update is disabled by distribution policy. + if not is_self_update_enabled(): + return + # Skip version check in E2E test mode to avoid interfering with tests if os.environ.get("APM_E2E_TESTS", "").lower() in ("1", "true", "yes"): return @@ -260,7 +265,7 @@ def _check_and_notify_updates(): ) # Show update command using helper for consistency - _rich_echo("Run apm update to upgrade", color="yellow", bold=True) + _rich_echo(get_update_hint_message(), color="yellow", bold=True) # Add a blank line for visual separation click.echo() diff --git a/src/apm_cli/commands/update.py b/src/apm_cli/commands/update.py index 569c88b3..9b7b137f 100644 --- a/src/apm_cli/commands/update.py +++ b/src/apm_cli/commands/update.py @@ -7,6 +7,7 @@ import click from ..core.command_logger import CommandLogger +from ..update_policy import get_self_update_disabled_message, is_self_update_enabled from ..version import get_version @@ -65,6 +66,11 @@ def update(check): import tempfile logger = CommandLogger("update") + + if not is_self_update_enabled(): + logger.warning(get_self_update_disabled_message()) + return + current_version = get_version() # Skip check for development versions diff --git a/src/apm_cli/update_policy.py b/src/apm_cli/update_policy.py new file mode 100644 index 00000000..066bdea8 --- /dev/null +++ b/src/apm_cli/update_policy.py @@ -0,0 +1,39 @@ +"""Build-time policy for APM self-update behavior. + +Package maintainers can patch this module during build to disable self-update +and show users a package-manager-specific update command. +""" + +# Default guidance when self-update is disabled. +DEFAULT_SELF_UPDATE_DISABLED_MESSAGE = ( + "Self-update is disabled for this APM distribution. " + "Update APM using your package manager." +) + +# Build-time policy values. +# +# Packagers can patch these constants during build, for example: +# - SELF_UPDATE_ENABLED = False +# - SELF_UPDATE_DISABLED_MESSAGE = "Update with: conda update apm" +SELF_UPDATE_ENABLED = True +SELF_UPDATE_DISABLED_MESSAGE = DEFAULT_SELF_UPDATE_DISABLED_MESSAGE + + +def is_self_update_enabled() -> bool: + """Return True when this build allows self-update.""" + return bool(SELF_UPDATE_ENABLED) + + +def get_self_update_disabled_message() -> str: + """Return the guidance message shown when self-update is disabled.""" + message = str(SELF_UPDATE_DISABLED_MESSAGE).strip() + if message: + return message + return DEFAULT_SELF_UPDATE_DISABLED_MESSAGE + + +def get_update_hint_message() -> str: + """Return the update hint used in startup notifications.""" + if is_self_update_enabled(): + return "Run apm update to upgrade" + return get_self_update_disabled_message() diff --git a/tests/unit/test_command_helpers.py b/tests/unit/test_command_helpers.py index 5a2f3249..7f26705c 100644 --- a/tests/unit/test_command_helpers.py +++ b/tests/unit/test_command_helpers.py @@ -260,6 +260,15 @@ def test_returns_empty_for_empty_dir(self, tmp_path): class TestCheckAndNotifyUpdates: """Tests for _check_and_notify_updates.""" + def test_skips_when_self_update_disabled(self): + """Returns immediately when distribution disables self-update.""" + with patch( + "apm_cli.commands._helpers.is_self_update_enabled", return_value=False + ): + with patch("apm_cli.commands._helpers.check_for_updates") as mock_check: + _check_and_notify_updates() + mock_check.assert_not_called() + def test_skips_in_e2e_test_mode(self): """Returns immediately when APM_E2E_TESTS=1 is set.""" with patch.dict(os.environ, {"APM_E2E_TESTS": "1"}): diff --git a/tests/unit/test_update_command.py b/tests/unit/test_update_command.py index ac951120..2b672ff8 100644 --- a/tests/unit/test_update_command.py +++ b/tests/unit/test_update_command.py @@ -23,6 +23,28 @@ def test_manual_update_command_uses_windows_installer(self): self.assertIn("aka.ms/apm-windows", command) self.assertIn("powershell", command.lower()) + @patch("apm_cli.commands.update.is_self_update_enabled", return_value=False) + @patch( + "apm_cli.commands.update.get_self_update_disabled_message", + return_value="Update with: conda update -c conda-forge apm", + ) + @patch("subprocess.run") + @patch("requests.get") + def test_update_command_respects_disabled_policy( + self, + mock_get, + mock_run, + mock_message, + mock_enabled, + ): + """Disabled self-update policy should print guidance and skip installer.""" + result = self.runner.invoke(cli, ["update"]) + + self.assertEqual(result.exit_code, 0) + self.assertIn("Update with: conda update -c conda-forge apm", result.output) + mock_get.assert_not_called() + mock_run.assert_not_called() + @patch("requests.get") @patch("subprocess.run") @patch("apm_cli.commands.update.get_version", return_value="0.6.3") From 58cf6191414b8b031df0e6ba029175e685974876 Mon Sep 17 00:00:00 2001 From: Morten Enemark Lund Date: Sat, 11 Apr 2026 17:22:11 +0200 Subject: [PATCH 2/4] test: address copilot review comments --- .../.apm/skills/apm-usage/commands.md | 2 +- src/apm_cli/update_policy.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 029891bb..b25d1adf 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -83,4 +83,4 @@ | `apm config` | Show current configuration | -- | | `apm config get [KEY]` | Get a config value | -- | | `apm config set KEY VALUE` | Set a config value | -- | -| `apm update` | Update APM itself | `--check` only check | +| `apm update` | Update APM itself (or show distributor guidance when self-update is disabled at build time) | `--check` only check | diff --git a/src/apm_cli/update_policy.py b/src/apm_cli/update_policy.py index 066bdea8..c3f35c50 100644 --- a/src/apm_cli/update_policy.py +++ b/src/apm_cli/update_policy.py @@ -19,6 +19,11 @@ SELF_UPDATE_DISABLED_MESSAGE = DEFAULT_SELF_UPDATE_DISABLED_MESSAGE +def _is_printable_ascii(value: str) -> bool: + """Return True when value contains only printable ASCII characters.""" + return all(" " <= char <= "~" for char in value) + + def is_self_update_enabled() -> bool: """Return True when this build allows self-update.""" return bool(SELF_UPDATE_ENABLED) @@ -26,10 +31,17 @@ def is_self_update_enabled() -> bool: def get_self_update_disabled_message() -> str: """Return the guidance message shown when self-update is disabled.""" + if SELF_UPDATE_DISABLED_MESSAGE is None: + return DEFAULT_SELF_UPDATE_DISABLED_MESSAGE + message = str(SELF_UPDATE_DISABLED_MESSAGE).strip() - if message: - return message - return DEFAULT_SELF_UPDATE_DISABLED_MESSAGE + if not message: + return DEFAULT_SELF_UPDATE_DISABLED_MESSAGE + + if not _is_printable_ascii(message): + return DEFAULT_SELF_UPDATE_DISABLED_MESSAGE + + return message def get_update_hint_message() -> str: From 34cfc997baf32ac3b58a2f6da02d2aea6a6480be Mon Sep 17 00:00:00 2001 From: Morten Enemark Lund Date: Sat, 11 Apr 2026 17:29:49 +0200 Subject: [PATCH 3/4] Update example strings for self update guides --- docs/src/content/docs/reference/cli-commands.md | 4 +++- src/apm_cli/update_policy.py | 2 +- tests/unit/test_update_command.py | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index ba644e7c..edaa0b73 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -586,7 +586,9 @@ apm update - Downloads and runs the official platform installer (`install.sh` on macOS/Linux, `install.ps1` on Windows) - Preserves existing configuration and projects - Shows progress and success/failure status -- Some package-manager distributions can disable self-update at build time. In those builds, `apm update` prints a distributor-defined guidance message (for example, a `pixi update` command) and exits without running the installer. +- Some package-manager distributions can disable self-update at build time. + In those builds, `apm update` prints a distributor-defined guidance message + (for example, a `brew upgrade` command) and exits without running the installer. **Version Checking:** APM automatically checks for updates (at most once per day) when running any command. If a newer version is available, you'll see a yellow warning: diff --git a/src/apm_cli/update_policy.py b/src/apm_cli/update_policy.py index c3f35c50..a15f2c4d 100644 --- a/src/apm_cli/update_policy.py +++ b/src/apm_cli/update_policy.py @@ -14,7 +14,7 @@ # # Packagers can patch these constants during build, for example: # - SELF_UPDATE_ENABLED = False -# - SELF_UPDATE_DISABLED_MESSAGE = "Update with: conda update apm" +# - SELF_UPDATE_DISABLED_MESSAGE = "Update with: pixi update apm-cli" SELF_UPDATE_ENABLED = True SELF_UPDATE_DISABLED_MESSAGE = DEFAULT_SELF_UPDATE_DISABLED_MESSAGE diff --git a/tests/unit/test_update_command.py b/tests/unit/test_update_command.py index 2b672ff8..13825635 100644 --- a/tests/unit/test_update_command.py +++ b/tests/unit/test_update_command.py @@ -26,7 +26,7 @@ def test_manual_update_command_uses_windows_installer(self): @patch("apm_cli.commands.update.is_self_update_enabled", return_value=False) @patch( "apm_cli.commands.update.get_self_update_disabled_message", - return_value="Update with: conda update -c conda-forge apm", + return_value="Update with: pixi update apm-cli", ) @patch("subprocess.run") @patch("requests.get") @@ -41,7 +41,7 @@ def test_update_command_respects_disabled_policy( result = self.runner.invoke(cli, ["update"]) self.assertEqual(result.exit_code, 0) - self.assertIn("Update with: conda update -c conda-forge apm", result.output) + self.assertIn("Update with: pixi update apm-cli", result.output) mock_get.assert_not_called() mock_run.assert_not_called() From a0054d62af42e42f434959696bcba4e481b912ba Mon Sep 17 00:00:00 2001 From: Morten Enemark Lund Date: Sun, 12 Apr 2026 06:56:53 +0200 Subject: [PATCH 4/4] use identity test for trueness. --- src/apm_cli/update_policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apm_cli/update_policy.py b/src/apm_cli/update_policy.py index a15f2c4d..e8bff1a8 100644 --- a/src/apm_cli/update_policy.py +++ b/src/apm_cli/update_policy.py @@ -26,7 +26,7 @@ def _is_printable_ascii(value: str) -> bool: def is_self_update_enabled() -> bool: """Return True when this build allows self-update.""" - return bool(SELF_UPDATE_ENABLED) + return SELF_UPDATE_ENABLED is True def get_self_update_disabled_message() -> str: