Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Added

- Added `cloudsmith upgrade` (alias: `update`). The command polls the release manifests the CLI already publishes to the public Cloudsmith raw repository, detects how the CLI was installed, and acts accordingly: a standalone binary downloads the new archive, verifies its checksum and replaces itself in place, while pip, pipx, uv, Homebrew, Docker and aqua installs get the exact upgrade command for that channel printed instead.
- The CLI now tells you when a newer version exists. At most once a day it fetches the latest released version in a background thread (waiting at most one second at exit for the result) and prints a short notice on stderr. The notice only appears on an interactive terminal with human-readable output, and never in CI, in `-F json` modes, or under `cloudsmith mcp`. Set `CLOUDSMITH_NO_UPDATE_CHECK=1` to turn it off entirely.

- Added `cloudsmith repos gpg` for managing the GPG key a repository signs its package indexes with. `get` shows the active key and its armored public block, `upload` installs a key you supply, and `regenerate` replaces the current key with a freshly generated Cloudsmith one. Key material and passphrases are only ever read from a file, stdin, or a hidden prompt, never from a command-line value, and `--debug` is refused on `upload` so the request body can't be logged. Both mutating subcommands accept `-n/--dry-run`, which checks the inputs and the key currently in place - naming the fingerprint that would be replaced - then stops before the request, so a mistyped repository or a stale credential fails there rather than on the real attempt. `regenerate` asks you to type `regenerate` to confirm - with no terminal attached it fails instead of blocking, so pass `-y/--yes` for unattended runs. There's no `delete` subcommand because the API has no way to remove a repository's key.
- Added `cloudsmith repos privileges` for managing explicit repository access from the terminal. `list` shows the teams, users and service accounts that were granted access explicitly; `set` grants access to any number of them and leaves everyone else untouched, asking first if it would lower access someone already has; `revoke` takes access away from the ones named, skipping any that had none; and `replace` makes a JSON file (or stdin) the complete truth for the repository. `revoke` and `replace` ask for confirmation first unless `-y` is passed.

Expand Down
3 changes: 3 additions & 0 deletions cloudsmith_cli/cli/commands/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import click

from ...core import update_check
from ...core.api.version import get_version as get_api_version
from ...core.utils import get_github_website, get_help_website
from ...core.version import get_version as get_cli_version
Expand Down Expand Up @@ -63,6 +64,8 @@ def main(ctx, opts, version):
"""Handle entrypoint to CLI."""
# pylint: disable=unused-argument

update_check.arm(ctx, opts)

if version:
print_version(opts)
elif ctx.invoked_subcommand is None:
Expand Down
2 changes: 2 additions & 0 deletions cloudsmith_cli/cli/commands/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"status": f"{_PACKAGE}.status",
"tags": f"{_PACKAGE}.tags",
"tokens": f"{_PACKAGE}.tokens",
"upgrade": f"{_PACKAGE}.upgrade",
"upstream": f"{_PACKAGE}.upstream",
"vulnerabilities": f"{_PACKAGE}.vulnerabilities",
"whoami": f"{_PACKAGE}.whoami",
Expand All @@ -56,4 +57,5 @@
"quarantine": ["block"],
"repositories": ["repos"],
"tags": ["tag"],
"upgrade": ["update"],
}
92 changes: 92 additions & 0 deletions cloudsmith_cli/cli/commands/upgrade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""CLI/Commands - Upgrade the CLI."""

import click
import requests

from ...core import installation, self_update, update_check
from ...core.version import get_version, parse_version
from .. import decorators, utils
from ..utils import maybe_spinner
from .main import main


def _fetch_manifest(opts, target):
try:
with maybe_spinner(opts):
return update_check.fetch_latest_manifest(target=target)
except (requests.RequestException, ValueError) as exc:
raise click.ClickException(f"Failed to fetch the latest version: {exc}")


def _is_up_to_date(latest, current):
try:
return parse_version(latest) <= parse_version(current)
except ValueError as exc:
raise click.ClickException(f"Cannot compare versions: {exc}")


def _run_self_update(opts, manifest, data):
use_stderr = utils.should_use_stderr(opts)
click.echo(
f"Downloading and installing version {manifest['version']} ... ",
nl=False,
err=use_stderr,
)
try:
with maybe_spinner(opts):
self_update.perform_self_update(manifest)
except (self_update.SelfUpdateError, requests.RequestException, OSError) as exc:
click.secho("ERROR", fg="red", err=use_stderr)
raise click.ClickException(str(exc))
click.secho("OK", fg="green", err=use_stderr)
data["upgraded"] = True
if utils.maybe_print_as_json(opts, data):
return
click.echo(f"The Cloudsmith CLI is now at version {manifest['version']}.")


@main.command(aliases=["update"])
@decorators.common_cli_config_options
@decorators.common_cli_output_options
@click.pass_context
def upgrade(ctx, opts):
"""Upgrade the Cloudsmith CLI to the latest released version.

The command detects how the CLI was installed. A standalone binary
replaces itself with the latest release. Every other install channel
gets the correct upgrade command for that channel.
"""
current = get_version()
channel = installation.detect_channel()
target = installation.detect_target()
if channel == installation.CHANNEL_STANDALONE and target is None:
raise click.ClickException(
"Cannot detect a supported platform for the standalone binary."
)

manifest = _fetch_manifest(opts, target)
latest = manifest["version"]
up_to_date = _is_up_to_date(latest, current)
update_check.store_latest_version(latest)

data = {"current_version": current, "latest_version": latest, "channel": channel}

if up_to_date:
data["up_to_date"] = True
if not utils.maybe_print_as_json(opts, data):
click.echo(f"The Cloudsmith CLI is up to date (version {current}).")
return

instruction = installation.upgrade_instruction(channel)
if instruction is not None:
data["upgrade_command"] = instruction
if utils.maybe_print_as_json(opts, data):
return
click.echo(
f"A new version of the Cloudsmith CLI is available: {current} → {latest}"
)
click.echo(f"The CLI was installed via {channel}. To upgrade, run:")
click.echo(f" {instruction}")
return

_run_self_update(opts, manifest, data)
127 changes: 127 additions & 0 deletions cloudsmith_cli/cli/tests/commands/test_upgrade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""CLI/Commands - Upgrade - Tests."""

import contextlib
import json
from unittest import mock

import requests

from ....core import installation, self_update, update_check
from ...commands import main as main_module


def make_manifest(version="9.9.9"):
return {
"version": version,
"url": "https://dl.cloudsmith.io/example.tar.gz",
"archive": "cloudsmith-9.9.9-macos-arm64.tar.gz",
"sha256": "abc123",
}


@contextlib.contextmanager
def upgrade_context(channel=installation.CHANNEL_PIP, manifest=None, target=None):
manifest = manifest or make_manifest()
with (
mock.patch.object(
update_check, "fetch_latest_manifest", return_value=manifest
) as fetch_mock,
mock.patch.object(update_check, "write_cached_state") as cache_mock,
mock.patch.object(installation, "detect_channel", return_value=channel),
mock.patch.object(installation, "detect_target", return_value=target),
mock.patch.object(self_update, "perform_self_update") as perform_mock,
):
yield {"fetch": fetch_mock, "cache": cache_mock, "perform": perform_mock}


def invoke(runner, args=("upgrade",)):
return runner.invoke(main_module.main, list(args), catch_exceptions=False)


def test_reports_up_to_date(runner):
with upgrade_context(manifest=make_manifest(version="0.0.1")):
result = invoke(runner)
assert result.exit_code == 0
assert "up to date" in result.output


def test_prints_instruction_for_managed_channel(runner):
with upgrade_context():
result = invoke(runner)
assert result.exit_code == 0
assert "9.9.9" in result.output
assert "pip install --upgrade cloudsmith-cli" in result.output


def test_self_updates_standalone(runner):
manifest = make_manifest()
with upgrade_context(
channel=installation.CHANNEL_STANDALONE,
manifest=manifest,
target="macos-arm64",
) as mocks:
result = invoke(runner)
assert result.exit_code == 0
mocks["perform"].assert_called_once_with(manifest)
mocks["fetch"].assert_called_once_with(target="macos-arm64")
assert "9.9.9" in result.output


def test_standalone_without_target_fails(runner):
with upgrade_context(channel=installation.CHANNEL_STANDALONE, target=None):
result = invoke(runner)
assert result.exit_code != 0


def test_self_update_failure_reports_error(runner):
with upgrade_context(
channel=installation.CHANNEL_STANDALONE, target="macos-arm64"
) as mocks:
mocks["perform"].side_effect = self_update.SelfUpdateError("disk full")
result = invoke(runner)
assert result.exit_code != 0
assert "disk full" in result.output


def test_fetch_failure_reports_error(runner):
with upgrade_context() as mocks:
mocks["fetch"].side_effect = requests.ConnectionError("offline")
result = invoke(runner)
assert result.exit_code != 0


def test_json_output(runner):
with upgrade_context():
result = invoke(runner, ("upgrade", "-F", "json"))
assert result.exit_code == 0
data = json.loads(result.stdout)["data"]
assert data["latest_version"] == "9.9.9"
assert data["channel"] == "pip"


def test_update_alias(runner):
with upgrade_context(manifest=make_manifest(version="0.0.1")):
result = invoke(runner, ("update",))
assert result.exit_code == 0
assert "up to date" in result.output


def test_refreshes_notifier_cache(runner):
with upgrade_context() as mocks:
invoke(runner)
mocks["cache"].assert_called_once_with("9.9.9")


def test_invalid_manifest_version_reports_error(runner):
with upgrade_context(manifest=make_manifest(version="not-semver")) as mocks:
result = invoke(runner)
assert result.exit_code != 0
assert "Cannot compare versions" in result.output
mocks["cache"].assert_not_called()


def test_cache_write_failure_is_ignored(runner):
with upgrade_context() as mocks:
mocks["cache"].side_effect = OSError("read-only")
result = invoke(runner)
assert result.exit_code == 0
22 changes: 22 additions & 0 deletions cloudsmith_cli/cli/tests/test_update_notice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Update notice wiring - Tests."""

from unittest import mock

from ...core import update_check
from ..commands import main as main_module


def test_main_arms_update_check(runner):
with mock.patch.object(update_check, "arm") as arm_mock:
result = runner.invoke(main_module.main, ["--version"], catch_exceptions=False)
assert result.exit_code == 0
arm_mock.assert_called_once()


def test_update_check_stays_silent_without_tty(runner):
with mock.patch.object(
update_check, "start_background_check_if_stale"
) as start_mock:
result = runner.invoke(main_module.main, ["--version"], catch_exceptions=False)
assert result.exit_code == 0
start_mock.assert_not_called()
Loading
Loading