Skip to content

Commit 40bbf7c

Browse files
feat(CENG-683): Add logout command to the CLI (#263)
* Add logout command to the CLI
1 parent 8624c4d commit 40bbf7c

File tree

9 files changed

+454
-4
lines changed

9 files changed

+454
-4
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111

1212
- Added `CLOUDSMITH_NO_KEYRING` environment variable to disable keyring usage globally. Set `CLOUDSMITH_NO_KEYRING=1` to skip system keyring operations.
1313
- Added `--request-api-key` flag to `cloudsmith auth` command for fully automated, non-interactive API token retrieval. Auto-creates a token if none exists, or auto-rotates (with warning) if one already exists. Compatible with `--save-config` and `CLOUDSMITH_NO_KEYRING`.
14+
- Added `cloudsmith logout` command to clear stored authentication credentials and SSO tokens.
15+
- Clears credentials from `credentials.ini` and SSO tokens from the system keyring
16+
- `--keyring-only` to only clear SSO tokens from the system keyring
17+
- `--config-only` to only clear credentials from `credentials.ini`
18+
- `--dry-run` to preview what would be removed without making changes
19+
- Supports `--output-format json` for programmatic usage
1420

1521
### Deprecation Notices
1622

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ The CLI currently supports the following commands (and sub-commands):
4747
- `packages`: List packages for a repository. (Aliases `repos list`)
4848
- `repos`: List repositories for a namespace (owner).
4949
- `login`|`token`: Retrieve your API authentication token/key via login.
50+
- `logout`: Clear stored authentication credentials and SSO tokens (Keyring, API key from credential file and emit warning when `$CLOUDSMITH_API_KEY` is still set).
5051
- `metrics`: Metrics and statistics for a repository.
5152
- `tokens`: Retrieve bandwidth usage for entitlement tokens.
5253
- `packages`: Retrieve package usage for repository.

cloudsmith_cli/cli/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
help_,
1313
list_,
1414
login,
15+
logout,
1516
mcp,
1617
metrics,
1718
move,
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Copyright 2026 Cloudsmith Ltd
2+
"""CLI/Commands - Log out and clear authentication state."""
3+
4+
import os
5+
6+
import click
7+
import cloudsmith_api
8+
9+
from ...core import keyring
10+
from .. import decorators, utils
11+
from ..config import CredentialsReader
12+
from .main import main
13+
14+
15+
def _clear_credentials(dry_run, use_stderr):
16+
"""Clear credential files. Returns result dict."""
17+
creds_files = CredentialsReader.find_existing_files()
18+
if not creds_files:
19+
click.echo("No credentials file found.", err=use_stderr)
20+
return {"action": "not_found", "files": []}
21+
22+
if not dry_run:
23+
for path in creds_files:
24+
CredentialsReader.clear_api_key(path)
25+
26+
verb = "Would remove" if dry_run else "Removed"
27+
for path in creds_files:
28+
click.echo(
29+
f"{verb} credentials from: " + click.style(path, bold=True),
30+
err=use_stderr,
31+
)
32+
action = "would_remove" if dry_run else "removed"
33+
return {"action": action, "files": list(creds_files)}
34+
35+
36+
def _clear_keyring(api_host, dry_run, use_stderr):
37+
"""Clear SSO tokens from keyring. Returns result dict."""
38+
if not keyring.should_use_keyring():
39+
click.secho(
40+
"Keyring is disabled (CLOUDSMITH_NO_KEYRING is set).",
41+
fg="yellow",
42+
err=use_stderr,
43+
)
44+
return {"action": "disabled"}
45+
46+
if not keyring.has_sso_tokens(api_host):
47+
click.echo("No SSO tokens found in system keyring.", err=use_stderr)
48+
return {"action": "not_found"}
49+
50+
if dry_run:
51+
click.echo("Would remove SSO tokens from system keyring.", err=use_stderr)
52+
return {"action": "would_remove"}
53+
54+
deleted = keyring.delete_sso_tokens(api_host)
55+
action = "removed" if deleted else "failed"
56+
msg = f"{'Removed' if deleted else 'Failed to remove'} SSO tokens from system keyring."
57+
click.secho(msg, fg=None if deleted else "red", err=use_stderr)
58+
return {"action": action} if deleted else {"action": action, "message": msg}
59+
60+
61+
def _env_api_key_status():
62+
"""Return structured status for the CLOUDSMITH_API_KEY env var."""
63+
is_set = bool(os.environ.get("CLOUDSMITH_API_KEY"))
64+
return {
65+
"is_set": is_set,
66+
"action": "unset CLOUDSMITH_API_KEY" if is_set else "none",
67+
}
68+
69+
70+
def _collect_warnings(keyring_only, config_only):
71+
"""Collect advisory warnings based on flags and environment."""
72+
warnings = []
73+
if config_only:
74+
warnings.append("SSO tokens were not modified (--config-only).")
75+
if keyring_only:
76+
warnings.append("credentials.ini was not modified (--keyring-only).")
77+
if os.environ.get("CLOUDSMITH_API_KEY"):
78+
warnings.append(
79+
"CLOUDSMITH_API_KEY is set in your environment. "
80+
"Run: unset CLOUDSMITH_API_KEY"
81+
)
82+
return warnings
83+
84+
85+
@main.command()
86+
@click.option(
87+
"--api-host",
88+
envvar="CLOUDSMITH_API_HOST",
89+
default=None,
90+
help="The API host to clear keyring tokens for.",
91+
)
92+
@click.option(
93+
"--keyring-only",
94+
is_flag=True,
95+
default=False,
96+
help="Only clear SSO tokens from the system keyring.",
97+
)
98+
@click.option(
99+
"--config-only",
100+
is_flag=True,
101+
default=False,
102+
help="Only clear credentials from credentials.ini.",
103+
)
104+
@click.option(
105+
"--dry-run",
106+
is_flag=True,
107+
default=False,
108+
help="Show what would be removed without removing anything.",
109+
)
110+
@decorators.common_cli_config_options
111+
@decorators.common_cli_output_options
112+
@click.pass_context
113+
def logout(ctx, opts, api_host, keyring_only, config_only, dry_run):
114+
"""Clear stored authentication credentials and SSO tokens."""
115+
if keyring_only and config_only:
116+
raise click.UsageError(
117+
"--keyring-only and --config-only are mutually exclusive."
118+
)
119+
120+
if api_host is None:
121+
api_host = opts.api_host or cloudsmith_api.Configuration().host
122+
123+
use_stderr = utils.should_use_stderr(opts)
124+
125+
credential_file = (
126+
_clear_credentials(dry_run, use_stderr)
127+
if not keyring_only
128+
else {"action": "skipped", "files": []}
129+
)
130+
keyring_result = (
131+
_clear_keyring(api_host, dry_run, use_stderr)
132+
if not config_only
133+
else {"action": "skipped"}
134+
)
135+
warnings = _collect_warnings(keyring_only, config_only)
136+
137+
for warning in warnings:
138+
click.secho(f"Note: {warning}", fg="yellow", err=use_stderr)
139+
140+
utils.maybe_print_as_json(
141+
opts,
142+
{
143+
"api_host": api_host,
144+
"dry_run": dry_run,
145+
"sources": {
146+
"credential_file": credential_file,
147+
"keyring": keyring_result,
148+
"environment_api_key": _env_api_key_status(),
149+
},
150+
},
151+
)

cloudsmith_cli/cli/config.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,44 @@ class CredentialsReader(ConfigReader):
208208
config_searchpath = list(_CFG_SEARCH_PATHS)
209209
config_section_schemas = [CredentialsSchema.Default, CredentialsSchema.Profile]
210210

211+
@classmethod
212+
def find_existing_files(cls):
213+
"""Return a list of existing credentials file paths."""
214+
paths = []
215+
seen = set()
216+
for filename in cls.config_files:
217+
for searchpath in cls.config_searchpath:
218+
path = os.path.join(searchpath, filename)
219+
if os.path.exists(path) and path not in seen:
220+
paths.append(path)
221+
seen.add(path)
222+
return paths
223+
224+
@classmethod
225+
def _set_api_key(cls, path, api_key=""):
226+
"""Write api_key value in a credentials file, preserving structure."""
227+
with open(path) as f:
228+
content = f.read()
229+
replacement = rf"\1 = {api_key}" if api_key else r"\1 ="
230+
content = re.sub(
231+
r"^(api_key)\s*=\s*.*$",
232+
replacement,
233+
content,
234+
flags=re.MULTILINE,
235+
)
236+
with open(path, "w") as f:
237+
f.write(content)
238+
239+
@classmethod
240+
def clear_api_key(cls, path):
241+
"""Clear api_key values in a credentials file, preserving structure."""
242+
cls._set_api_key(path)
243+
244+
@classmethod
245+
def update_api_key(cls, path, api_key):
246+
"""Update api_key value in an existing credentials file, preserving structure."""
247+
cls._set_api_key(path, api_key)
248+
211249

212250
class Options:
213251
"""Options object that holds config for the application."""
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import json
2+
import os
3+
from unittest.mock import patch
4+
5+
import click.testing
6+
import pytest
7+
8+
from ...commands.logout import logout
9+
10+
HOST = "https://api.example.com"
11+
CREDS_PATH = "/home/user/.config/cloudsmith/credentials.ini"
12+
13+
14+
@pytest.fixture()
15+
def runner():
16+
return click.testing.CliRunner()
17+
18+
19+
@pytest.fixture
20+
def mock_no_keyring_env():
21+
"""Ensure CLOUDSMITH_NO_KEYRING and CLOUDSMITH_API_KEY are not set."""
22+
env = os.environ.copy()
23+
env.pop("CLOUDSMITH_NO_KEYRING", None)
24+
env.pop("CLOUDSMITH_API_KEY", None)
25+
with patch.dict(os.environ, env, clear=True):
26+
yield
27+
28+
29+
@pytest.fixture
30+
def mock_deps(mock_no_keyring_env):
31+
"""Patch keyring and CredentialsReader with sensible defaults."""
32+
with (
33+
patch("cloudsmith_cli.cli.commands.logout.keyring") as mk,
34+
patch("cloudsmith_cli.cli.commands.logout.CredentialsReader") as mc,
35+
):
36+
mc.find_existing_files.return_value = [CREDS_PATH]
37+
mk.should_use_keyring.return_value = True
38+
mk.has_sso_tokens.return_value = True
39+
yield mc, mk
40+
41+
42+
class TestLogoutCommand:
43+
"""Tests for the cloudsmith logout command."""
44+
45+
def test_full_logout(self, runner, mock_deps):
46+
mock_creds, mock_keyring = mock_deps
47+
48+
result = runner.invoke(logout, ["--api-host", HOST])
49+
50+
assert result.exit_code == 0
51+
mock_creds.clear_api_key.assert_called_once_with(CREDS_PATH)
52+
mock_keyring.delete_sso_tokens.assert_called_once_with(HOST)
53+
assert "Removed credentials from:" in result.output
54+
assert "Removed SSO tokens from system keyring" in result.output
55+
56+
def test_dry_run(self, runner, mock_deps):
57+
mock_creds, mock_keyring = mock_deps
58+
59+
result = runner.invoke(logout, ["--dry-run", "--api-host", HOST])
60+
61+
assert result.exit_code == 0
62+
mock_creds.clear_api_key.assert_not_called()
63+
mock_keyring.delete_sso_tokens.assert_not_called()
64+
assert "Would remove" in result.output
65+
66+
def test_keyring_only_and_config_only_are_mutually_exclusive(self, runner):
67+
result = runner.invoke(
68+
logout, ["--keyring-only", "--config-only", "--api-host", HOST]
69+
)
70+
71+
assert result.exit_code != 0
72+
assert "mutually exclusive" in result.output
73+
74+
@pytest.mark.parametrize(
75+
"flag, skipped_attr, note_fragment",
76+
[
77+
(
78+
"--keyring-only",
79+
"find_existing_files",
80+
"credentials.ini was not modified",
81+
),
82+
("--config-only", "has_sso_tokens", "SSO tokens were not modified"),
83+
],
84+
)
85+
def test_scoped_flags(self, runner, mock_deps, flag, skipped_attr, note_fragment):
86+
mock_creds, mock_keyring = mock_deps
87+
88+
result = runner.invoke(logout, [flag, "--api-host", HOST])
89+
90+
assert result.exit_code == 0
91+
# The skipped source should not have been touched
92+
target = mock_creds if hasattr(mock_creds, skipped_attr) else mock_keyring
93+
getattr(target, skipped_attr).assert_not_called()
94+
assert note_fragment in result.output
95+
96+
@pytest.mark.parametrize(
97+
"env, expect_warning",
98+
[
99+
({"CLOUDSMITH_API_KEY": "secret"}, True),
100+
({}, False),
101+
],
102+
)
103+
def test_env_api_key_warning(self, runner, mock_deps, env, expect_warning):
104+
with patch.dict(os.environ, env):
105+
result = runner.invoke(logout, ["--api-host", HOST])
106+
107+
assert result.exit_code == 0
108+
if expect_warning:
109+
assert "unset CLOUDSMITH_API_KEY" in result.output
110+
else:
111+
assert "unset CLOUDSMITH_API_KEY" not in result.output
112+
113+
def test_json_output(self, runner, mock_deps):
114+
"""--output-format json emits structured JSON with expected keys."""
115+
_, mock_keyring = mock_deps
116+
mock_keyring.delete_sso_tokens.return_value = True
117+
118+
result = runner.invoke(
119+
logout,
120+
["--output-format", "json", "--api-host", HOST],
121+
catch_exceptions=False,
122+
)
123+
124+
assert result.exit_code == 0
125+
# Human messages go to stderr; extract the JSON line from stdout.
126+
json_line = [
127+
line for line in result.output.splitlines() if line.startswith("{")
128+
]
129+
assert json_line, f"No JSON found in output: {result.output!r}"
130+
payload = json.loads(json_line[0])
131+
data = payload["data"]
132+
assert data["api_host"] == HOST
133+
assert "dry_run" in data
134+
sources = data["sources"]
135+
assert "credential_file" in sources
136+
assert "keyring" in sources
137+
assert "environment_api_key" in sources
138+
139+
def test_keyring_delete_failure(self, runner, mock_deps):
140+
"""When delete_sso_tokens returns False, report failure."""
141+
_, mock_keyring = mock_deps
142+
mock_keyring.delete_sso_tokens.return_value = False
143+
144+
result = runner.invoke(logout, ["--api-host", HOST])
145+
146+
assert result.exit_code == 0
147+
assert "Failed to remove SSO tokens" in result.output

0 commit comments

Comments
 (0)