diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 73079b66..110e4eaf 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -586,6 +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 `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: @@ -597,6 +600,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/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 47945125..48e909fc 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 (`auto-integrate`, `temp-dir`) | -- | | `apm config set KEY VALUE` | Set a config value (`auto-integrate`, `temp-dir`) | -- | -| `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/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 8011cf72..5d7d6ed3 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..e8bff1a8 --- /dev/null +++ b/src/apm_cli/update_policy.py @@ -0,0 +1,51 @@ +"""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: pixi update apm-cli" +SELF_UPDATE_ENABLED = True +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 SELF_UPDATE_ENABLED is True + + +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 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: + """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..13825635 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: pixi update apm-cli", + ) + @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: pixi update apm-cli", 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")