Skip to content
9 changes: 7 additions & 2 deletions capture_tests/cbrain_cli_commands
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ cbrain --json version
cbrain whoami
cbrain --json whoami

# Named session management
cbrain session list

# Bourreaux
cbrain remote-resource list
cbrain --json remote-resource list
Expand Down Expand Up @@ -120,6 +123,7 @@ cbrain task operation hold # missing --task-id / --batch-id

# ToolConfigs, as admin user
./switch_session admin
cbrain session list
cbrain tool-config list
cbrain --json tool-config list
cbrain --jsonl tool-config list
Expand All @@ -128,8 +132,9 @@ cbrain tool-config show 19 # not visible to normal user norm
cbrain --json tool-config show 19
cbrain --jsonl tool-config show 19

# ToolConfigs, as normal user
./switch_session norm
# ToolConfigs, as normal user (CLI switch_session; both sessions already planted)
cbrain switch_session norm
cbrain session list
cbrain tool-config list
cbrain --json tool-config list
cbrain --jsonl tool-config list
Expand Down
59 changes: 54 additions & 5 deletions capture_tests/expected_captures.txt
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ Stdout:
Stderr:
(No output)

############################
Command: cbrain session list
Status: 0
Stdout: 337 bytes
Stderr: 0 bytes

Stdout:
# SESSION USERNAME USER ID SERVER TIMESTAMP
------------------------------------------------------------------------------------------
*1 norm norm 2 http://localhost:3000 2222-22-22T44:44

Active session: norm (* = active)
Stderr:
(No output)

############################
Command: cbrain remote-resource list
Status: 0
Expand Down Expand Up @@ -1073,6 +1088,22 @@ Stdout:
Stderr:
(No output)

############################
Command: cbrain session list
Status: 0
Stdout: 448 bytes
Stderr: 0 bytes

Stdout:
# SESSION USERNAME USER ID SERVER TIMESTAMP
------------------------------------------------------------------------------------------
1 norm norm 2 http://localhost:3000 2222-22-22T44:44
*2 admin admin 1 http://localhost:3000 2222-22-22T44:44

Active session: admin (* = active)
Stderr:
(No output)

############################
Command: cbrain tool-config list
Status: 0
Expand Down Expand Up @@ -1179,13 +1210,29 @@ Stderr:
(No output)

############################
Command: ./switch_session norm
Command: cbrain switch_session norm
Status: 0
Stdout: 0 bytes
Stdout: 71 bytes
Stderr: 0 bytes

Stdout:
Switched to session 'norm'. All future commands will use this session.
Stderr:
(No output)

############################
Command: cbrain session list
Status: 0
Stdout: 447 bytes
Stderr: 0 bytes

Stdout:
# SESSION USERNAME USER ID SERVER TIMESTAMP
------------------------------------------------------------------------------------------
*1 norm norm 2 http://localhost:3000 2222-22-22T44:44
2 admin admin 1 http://localhost:3000 2222-22-22T44:44

Active session: norm (* = active)
Stderr:
(No output)

Expand Down Expand Up @@ -1720,12 +1767,14 @@ Stderr:
############################
Command: cbrain logout
Status: 0
Stdout: 116 bytes
Stdout: 266 bytes
Stderr: 0 bytes

Stdout:
Successfully logged out from CBRAIN server.
Local session removed from /home/runner/.config/cbrain/credentials.json
Successfully logged out from CBRAIN server as norm.
Local session 'norm' removed from /home/runner/.config/cbrain/credentials.json.
Successfully logged out from CBRAIN server as admin.
Local session 'admin' removed from /home/runner/.config/cbrain/credentials.json.
Stderr:
(No output)

Expand Down
43 changes: 39 additions & 4 deletions capture_tests/switch_session
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ DEL_TOKEN="0123456789abcdefffffffffffffffff";

# cbrain client JSON file with credentials to update
cbrain_cred_file=$HOME/.config/cbrain/credentials.json
mkdir -p "$(dirname "$cbrain_cred_file")"

# Timestamp as expected by the cbrain client program
timestamp=$(date +"%Y-%m-%dT%H:%M:%S")
Expand All @@ -27,8 +28,15 @@ timestamp=$(date +"%Y-%m-%dT%H:%M:%S")
# thus logging out the cbrain command.
testses="$1"

# Stdout from now on goes into the JSON
exec 1> $cbrain_cred_file
# Keep prior named sessions so planting admin after norm does not wipe norm.
existing="{}"
if test -s "$cbrain_cred_file" ; then
existing=$(cat "$cbrain_cred_file")
fi

# Stdout from now on goes into a temp JSON blob for this session only
tmp_cred=$(mktemp)
exec 1> "$tmp_cred"

# Session for normal user
if test "X$1" = "Xnorm" ; then
Expand All @@ -37,6 +45,7 @@ if test "X$1" = "Xnorm" ; then
"cbrain_url": "http://localhost:3000",
"api_token": "$NORMAL_TOKEN",
"user_id": 2,
"username": "norm",
"timestamp": "$timestamp"
}
CREDJSON
Expand All @@ -49,6 +58,7 @@ if test "X$1" = "Xadmin" ; then
"cbrain_url": "http://localhost:3000",
"api_token": "$ADMIN_TOKEN",
"user_id": 1,
"username": "admin",
"timestamp": "$timestamp"
}
CREDJSON
Expand All @@ -61,6 +71,7 @@ if test "X$1" = "Xnormdel" ; then
"cbrain_url": "http://localhost:3000",
"api_token": "$DEL_TOKEN",
"user_id": 2,
"username": "norm",
"timestamp": "$timestamp"
}
CREDJSON
Expand All @@ -70,6 +81,30 @@ fi
exec 1>& -

# Remove outright if it's empty
if ! test -s "$cbrain_cred_file" ; then
rm -f "$cbrain_cred_file"
if ! test -s "$tmp_cred" ; then
rm -f "$tmp_cred" "$cbrain_cred_file"
exit 0
fi

# Merge this session into the named credentials map (multi-session).
python3 - "$cbrain_cred_file" "$testses" "$existing" "$tmp_cred" <<'PY'
import json, sys

path, name, existing_raw, new_path = sys.argv[1:5]
with open(new_path) as f:
new = json.load(f)
try:
data = json.loads(existing_raw) if existing_raw.strip() else {}
except json.JSONDecodeError:
data = {}
# Promote legacy flat file to named map.
if "api_token" in data or "cbrain_url" in data:
if not any(isinstance(v, dict) and "api_token" in v for k, v in data.items() if k != "_active_session"):
data = {"default": {k: v for k, v in data.items() if k != "_active_session"}}
data[name] = new
data["_active_session"] = name
with open(path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
rm -f "$tmp_cred"
45 changes: 38 additions & 7 deletions cbrain_cli/cli_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,32 @@
from pathlib import Path

from cbrain_cli import config as cbrain_config
from cbrain_cli.config import DEFAULT_HEADERS, DEFAULT_TIMEOUT, auth_headers
from cbrain_cli.config import (
ACTIVE_SESSION_KEY,
DEFAULT_HEADERS,
DEFAULT_TIMEOUT,
auth_headers,
cli_session_not_found,
resolve_session_credentials,
)

_debug = False

# Session name priority: --session flag > _active_session in credentials > "default"
session_name = "default"
session_specified = False
for i, arg in enumerate(sys.argv):
if arg == "--session" and i + 1 < len(sys.argv):
session_name = sys.argv[i + 1]
session_specified = True
elif arg.startswith("--session="):
session_name = arg.split("=", 1)[1]
session_specified = True

if not session_specified:
_all = cbrain_config.load_credentials() or {}
session_name = _all.get(ACTIVE_SESSION_KEY, "default") or "default"


def set_debug(flag: bool) -> None:
"""Enable or disable debug output."""
Expand Down Expand Up @@ -44,7 +66,9 @@ def from_credentials(cls, timeout=None):
"""
Build a client from the saved credentials file.
"""
creds = cbrain_config.load_credentials() or {}
all_creds = cbrain_config.load_credentials() or {}
name = session_name if session_specified else None
creds = resolve_session_credentials(all_creds, name)
return cls(
creds.get("cbrain_url", ""),
creds.get("api_token", ""),
Expand Down Expand Up @@ -173,6 +197,10 @@ def is_authenticated():
"""
Check if the user is authenticated.
"""
missing = cli_session_not_found()
if missing:
print(f"Session '{missing}' not found.")
return False
client = CbrainClient.from_credentials()
if not client.token or not client.base_url or not client.user_id:
print("Not logged in. Use 'cbrain login' to login first.")
Expand Down Expand Up @@ -211,14 +239,16 @@ def get_status_code_description(status_code):
return f"HTTP error ({status_code})"


def handle_connection_error(error):
def handle_connection_error(error, server_url=None):
"""
Handle connection errors with informative messages including server URL.

Parameters
----------
error : Exception
The connection error that occurred
server_url : str, optional
Server URL for errors before credentials exist (e.g. during login)

Returns
-------
Expand Down Expand Up @@ -303,10 +333,11 @@ def handle_connection_error(error):
"Check your connection or set CBRAIN_TIMEOUT env var."
)
elif "Connection refused" in str(error):
print(
"Error: Cannot connect to CBRAIN server at "
f"{CbrainClient.from_credentials().base_url}"
)
url = server_url or CbrainClient.from_credentials().base_url
if url:
print(f"Error: Cannot connect to CBRAIN server at {url}")
else:
print("Error: Cannot connect to CBRAIN server.")
print("Please check if the CBRAIN server is running and accessible.")
else:
print(f"Connection failed: {error.reason}")
Expand Down
82 changes: 82 additions & 0 deletions cbrain_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
except ValueError:
DEFAULT_TIMEOUT = 30

# Key used inside credentials.json to track the currently active session.
# Prefixed with "_" so it is clearly not a session name.
ACTIVE_SESSION_KEY = "_active_session"

# HTTP headers.
DEFAULT_HEADERS = {
"Content-Type": "application/x-www-form-urlencoded",
Expand Down Expand Up @@ -56,6 +60,84 @@ def load_credentials():
return None


def is_flat_credentials(data):
"""True when file is single-session (api_token at top level), not named map."""
if not isinstance(data, dict) or not data:
return False
if "api_token" in data or "cbrain_url" in data:
return not any(
k != ACTIVE_SESSION_KEY
and isinstance(v, dict)
and ("api_token" in v or "cbrain_url" in v)
for k, v in data.items()
)
return False


def get_named_sessions(data):
"""Return {name: creds} for flat or multi-session files."""
if not data:
return {}
if is_flat_credentials(data):
return {"default": {k: v for k, v in data.items() if k != ACTIVE_SESSION_KEY}}
return {k: v for k, v in data.items() if k != ACTIVE_SESSION_KEY and isinstance(v, dict)}


def _cli_session_override():
"""Return --session name from argv when present."""
from cbrain_cli.cli_utils import session_name, session_specified

return session_name if session_specified else None


def cli_session_not_found():
"""Return session name when --session was given but is not saved locally."""
from cbrain_cli.cli_utils import session_name, session_specified

if not session_specified:
return None
if session_name in get_named_sessions(load_credentials() or {}):
return None
return session_name


def resolve_session_credentials(data, session_name=None):
"""Pick active session dict from flat or multi-session credentials file."""
if not data:
return {}
if session_name is None:
session_name = _cli_session_override()
if is_flat_credentials(data):
return {k: v for k, v in data.items() if k != ACTIVE_SESSION_KEY}
name = session_name or data.get(ACTIVE_SESSION_KEY) or "default"
entry = data.get(name)
return dict(entry) if isinstance(entry, dict) else {}


def update_active_credentials(updates=None, remove_keys=None, session_name=None):
"""Patch fields on the active (or named) session; supports flat + nested files."""
data = load_credentials()
if data is None:
return
updates = updates or {}
remove_keys = remove_keys or []
if session_name is None:
session_name = _cli_session_override()
if is_flat_credentials(data):
data.update(updates)
for k in remove_keys:
data.pop(k, None)
save_credentials(data)
return
name = session_name or data.get(ACTIVE_SESSION_KEY) or "default"
entry = dict(data.get(name) or {})
entry.update(updates)
for k in remove_keys:
entry.pop(k, None)
data[name] = entry
save_credentials(data)


def save_credentials(credentials):
"""
Save credentials to the session file.
Expand Down
Loading
Loading