diff --git a/capture_tests/cbrain_cli_commands b/capture_tests/cbrain_cli_commands index 028629f..ad8b116 100755 --- a/capture_tests/cbrain_cli_commands +++ b/capture_tests/cbrain_cli_commands @@ -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 @@ -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 @@ -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 diff --git a/capture_tests/expected_captures.txt b/capture_tests/expected_captures.txt index da977c9..56f7fa6 100644 --- a/capture_tests/expected_captures.txt +++ b/capture_tests/expected_captures.txt @@ -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 @@ -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 @@ -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) @@ -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) diff --git a/capture_tests/switch_session b/capture_tests/switch_session index fb0aaf2..c5afaf7 100755 --- a/capture_tests/switch_session +++ b/capture_tests/switch_session @@ -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") @@ -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 @@ -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 @@ -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 @@ -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 @@ -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" diff --git a/cbrain_cli/cli_utils.py b/cbrain_cli/cli_utils.py index c8e616d..4824380 100644 --- a/cbrain_cli/cli_utils.py +++ b/cbrain_cli/cli_utils.py @@ -11,10 +11,38 @@ 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, + 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 + +_all = cbrain_config.load_credentials() or {} +if not session_specified: + session_name = _all.get(ACTIVE_SESSION_KEY, "default") or "default" +all_credentials = dict(_all) +all_credentials.pop(ACTIVE_SESSION_KEY, None) +_resolved = resolve_session_credentials(_all, session_name if session_specified else None) +cbrain_url = _resolved.get("cbrain_url") +api_token = _resolved.get("api_token") +user_id = _resolved.get("user_id") +cbrain_timestamp = _resolved.get("timestamp") + def set_debug(flag: bool) -> None: """Enable or disable debug output.""" @@ -44,7 +72,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", ""), diff --git a/cbrain_cli/config.py b/cbrain_cli/config.py index 17af962..ae5836b 100644 --- a/cbrain_cli/config.py +++ b/cbrain_cli/config.py @@ -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", @@ -56,6 +60,62 @@ 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 resolve_session_credentials(data, session_name=None): + """Pick active session dict from flat or multi-session credentials file.""" + if not data: + return {} + 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 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. diff --git a/cbrain_cli/data/projects.py b/cbrain_cli/data/projects.py index 66b7fb4..31d6a98 100644 --- a/cbrain_cli/data/projects.py +++ b/cbrain_cli/data/projects.py @@ -3,7 +3,11 @@ CliApiError, CliValidationError, ) -from cbrain_cli.config import load_credentials, save_credentials +from cbrain_cli.config import ( + load_credentials, + resolve_session_credentials, + update_active_credentials, +) def switch_project(args): @@ -45,11 +49,13 @@ def switch_project(args): else: group_data = client.get(f"/groups/{group_id}") - credentials = load_credentials() - if credentials is not None: - credentials["current_group_id"] = group_id - credentials["current_group_name"] = group_data.get("name", "Unknown") - save_credentials(credentials) + if load_credentials() is not None: + update_active_credentials( + { + "current_group_id": group_id, + "current_group_name": group_data.get("name", "Unknown"), + } + ) return group_data @@ -73,16 +79,15 @@ def unswitch_project(args): previous_group_name = None if credentials is not None: - previous_group_id = credentials.get("current_group_id") - previous_group_name = credentials.get("current_group_name") + active = resolve_session_credentials(credentials) + previous_group_id = active.get("current_group_id") + previous_group_name = active.get("current_group_name") if previous_group_id: CbrainClient.from_credentials().send("POST", "/groups/switch") if credentials is not None: - credentials.pop("current_group_id", None) - credentials.pop("current_group_name", None) - save_credentials(credentials) + update_active_credentials(remove_keys=["current_group_id", "current_group_name"]) return { "previous_group_id": previous_group_id, @@ -122,7 +127,8 @@ def show_project(args): if credentials is None: return None - current_group_id = credentials.get("current_group_id") + active = resolve_session_credentials(credentials) + current_group_id = active.get("current_group_id") if not current_group_id: return None @@ -130,16 +136,14 @@ def show_project(args): if current_group_id == "all": return { "id": "all", - "name": credentials.get("current_group_name") or "all", + "name": active.get("current_group_name") or "all", } try: return CbrainClient.from_credentials().get(f"/groups/{current_group_id}") except CliApiError as e: if e.status == 404: - credentials.pop("current_group_id", None) - credentials.pop("current_group_name", None) - save_credentials(credentials) + update_active_credentials(remove_keys=["current_group_id", "current_group_name"]) raise CliApiError(f"Current project (ID {current_group_id}) no longer exists") from None raise diff --git a/cbrain_cli/main.py b/cbrain_cli/main.py index 18ffbfc..c260b26 100644 --- a/cbrain_cli/main.py +++ b/cbrain_cli/main.py @@ -48,7 +48,7 @@ handle_tool_list, handle_tool_show, ) -from cbrain_cli.sessions import create_session, logout_session +from cbrain_cli.sessions import create_session, list_sessions, logout_session, switch_session from cbrain_cli.users import whoami_user @@ -77,6 +77,12 @@ def build_parser(): action="store_true", help="Print sanitized request/response diagnostics to stderr", ) + parser.add_argument( + "--session", + type=str, + default="default", + help="Session name to use for multiple configurations (default: default)", + ) subparsers = parser.add_subparsers(dest="command", help="Available commands") @@ -87,17 +93,43 @@ def build_parser(): # MARK: Session commands (top-level) # Create new session. login_parser = subparsers.add_parser("login", help="Login to CBRAIN") + login_parser.add_argument("--session", type=str, help="Session name to use") + login_parser.add_argument("-u", "--username", type=str, help="CBRAIN username") + login_parser.add_argument("-p", "--password", type=str, help="CBRAIN password") + login_parser.add_argument("-s", "--server", type=str, help="CBRAIN server URL") login_parser.set_defaults(func=handle_errors(create_session)) # Logout session. logout_parser = subparsers.add_parser("logout", help="Logout from CBRAIN") + logout_parser.add_argument( + "--session", type=str, help="Session name to logout (default: all sessions)" + ) logout_parser.set_defaults(func=handle_errors(logout_session)) # Show current session. whoami_parser = subparsers.add_parser("whoami", help="Show current session") + whoami_parser.add_argument("--session", type=str, help="Session name to show") whoami_parser.add_argument("-v", "--version", action="store_true", help="Show version") whoami_parser.set_defaults(func=handle_errors(whoami_user)) + # Switch active session. + switch_session_parser = subparsers.add_parser( + "switch_session", + help="Switch the default session (e.g. cbrain switch_session prod)", + ) + switch_session_parser.add_argument( + "session_target", + type=str, + help="Name of the session to make the default", + ) + switch_session_parser.set_defaults(func=handle_errors(switch_session)) + + # Session management sub-commands. + session_parser = subparsers.add_parser("session", help="Session management") + session_subparsers = session_parser.add_subparsers(dest="action", help="Session actions") + session_list_parser = session_subparsers.add_parser("list", help="List all saved sessions") + session_list_parser.set_defaults(func=handle_errors(list_sessions)) + # MARK: Model-based commands # File commands file_parser = subparsers.add_parser("file", help="File operations") @@ -536,6 +568,7 @@ def build_parser(): "background": background_parser, "task": task_parser, "remote-resource": remote_resource_parser, + "session": session_parser, } return parser, command_parsers @@ -582,6 +615,13 @@ def main(argv=None): return handle_errors(version_info)(args) elif args.command == "whoami": return handle_errors(whoami_user)(args) + elif args.command == "switch_session": + return handle_errors(switch_session)(args) + elif args.command == "session": + if not getattr(args, "action", None): + command_parsers["session"].print_help() + return 1 + return args.func(args) # All other commands require authentication. if not is_authenticated(): diff --git a/cbrain_cli/sessions.py b/cbrain_cli/sessions.py index 6d27cec..a79726d 100644 --- a/cbrain_cli/sessions.py +++ b/cbrain_cli/sessions.py @@ -7,8 +7,82 @@ CbrainClient, CliApiError, CliValidationError, + session_name, + session_specified, ) -from cbrain_cli.config import DEFAULT_BASE_URL +from cbrain_cli.config import ( + ACTIVE_SESSION_KEY, + DEFAULT_BASE_URL, + get_named_sessions, + is_flat_credentials, + resolve_session_credentials, +) + +# MARK: Switch Session + + +def switch_session(args): + """Switch the default session used by bare commands.""" + target = getattr(args, "session_target", None) + if not target: + print("Usage: cbrain switch_session ") + return 1 + + all_creds = cbrain_config.load_credentials() + if all_creds is None: + print(f"Error: credentials file is corrupted ({cbrain_config.CREDENTIALS_FILE}).") + return 1 + + sessions = get_named_sessions(all_creds) + if target not in sessions: + available = ", ".join(sessions) or "(none)" + print(f"Session '{target}' not found. Available sessions: {available}") + return 1 + + # Promote flat file to named map so _active_session can live alongside entries. + if is_flat_credentials(all_creds): + all_creds = {ACTIVE_SESSION_KEY: target, "default": sessions["default"]} + else: + all_creds[ACTIVE_SESSION_KEY] = target + + cbrain_config.save_credentials(all_creds) + print(f"Switched to session '{target}'. All future commands will use this session.") + return 0 + + +# MARK: List Sessions + + +def list_sessions(args): + """List all saved sessions, marking the currently active one with '*'.""" + all_creds = cbrain_config.load_credentials() + if all_creds is None: + print(f"Error: credentials file is corrupted ({cbrain_config.CREDENTIALS_FILE}).") + return 1 + + active = ( + all_creds.get(ACTIVE_SESSION_KEY, "default") + if not is_flat_credentials(all_creds) + else "default" + ) + sessions = get_named_sessions(all_creds) + + if not sessions: + print("No saved sessions. Use 'cbrain login' to create one.") + return 0 + + print(f"{'#':<4} {'SESSION':<20} {'USERNAME':<16} {'USER ID':<10} {'SERVER':<35} {'TIMESTAMP'}") + print("-" * 90) + for idx, (name, c) in enumerate(sessions.items(), start=1): + marker = "*" if name == active else " " + print( + f"{marker}{idx:<3} {name:<20} {c.get('username', '(unknown)'):<16} " + f"{c.get('user_id', 'N/A')!s:<10} {c.get('cbrain_url', 'N/A'):<35} " + f"{c.get('timestamp', 'N/A')}" + ) + + print(f"\nActive session: {active} (* = active)") + return 0 # MARK: Create Session. @@ -26,42 +100,55 @@ def create_session(args): int Exit code (0 on success, 1 on failure). """ + target_session = getattr(args, "session", None) or ( + session_name if session_specified else "default" + ) if cbrain_config.CREDENTIALS_FILE.exists(): - creds = cbrain_config.load_credentials() - if creds and creds.get("api_token") and creds.get("cbrain_url"): - # File alone is not enough, probe server to detect expired tokens. - try: - CbrainClient.from_credentials().get("/session") - except CliApiError as e: - if e.status == 401: - print("Saved session expired. Please log in again.") - elif e.status >= 500: - print(f"Server returned HTTP {e.status} during session check.") - print("The server may be temporarily unavailable. Try again later.") + all_creds = cbrain_config.load_credentials() + if all_creds: + existing = resolve_session_credentials( + all_creds, target_session if not is_flat_credentials(all_creds) else None + ) + if existing.get("api_token") and existing.get("cbrain_url"): + # File alone is not enough, probe server to detect expired tokens. + try: + CbrainClient( + existing["cbrain_url"], + existing.get("api_token"), + existing.get("user_id"), + ).get("/session") + except CliApiError as e: + if e.status == 401: + print("Saved session expired. Please log in again.") + elif e.status >= 500: + print(f"Server returned HTTP {e.status} during session check.") + print("The server may be temporarily unavailable. Try again later.") + return 1 + else: + print(f"Server returned HTTP {e.status} during session check.") + print("Use 'cbrain logout' to reset local credentials.") + return 1 + except urllib.error.URLError: + print(f"Cannot reach CBRAIN server at {existing['cbrain_url']}.") + print("Check your connection. Use 'cbrain logout' to reset local credentials.") return 1 else: - print(f"Server returned HTTP {e.status} during session check.") - print("Use 'cbrain logout' to reset local credentials.") + label = f" to session '{target_session}'" if target_session != "default" else "" + print(f"Already logged in{label}. Use 'cbrain logout' to logout.") return 1 - except urllib.error.URLError: - print(f"Cannot reach CBRAIN server at {creds['cbrain_url']}.") - print("Check your connection. Use 'cbrain logout' to reset local credentials.") - return 1 - else: - print("Already logged in. Use 'cbrain logout' to logout.") - return 1 - # Get user input. - cbrain_url = input("Enter CBRAIN server base URL [default: localhost:3000]: ").strip() - if not cbrain_url: - cbrain_url = DEFAULT_BASE_URL + cbrain_url = ( + getattr(args, "server", None) + or input("Enter CBRAIN server base URL [default: localhost:3000]: ").strip() + or DEFAULT_BASE_URL + ) - username = input("Enter CBRAIN username: ").strip() + username = getattr(args, "username", None) or input("Enter CBRAIN username: ").strip() if not username: raise CliValidationError("Username is required", field="username") - password = getpass.getpass("Enter CBRAIN password: ") + password = getattr(args, "password", None) or getpass.getpass("Enter CBRAIN password: ") if not password: raise CliValidationError("Password is required", field="password") @@ -80,65 +167,130 @@ def create_session(args): "cbrain_url": cbrain_url, "api_token": cbrain_api_token, "user_id": cbrain_user_id, + "username": username, "timestamp": datetime.datetime.now().isoformat(), } - cbrain_config.save_credentials(credentials) - - print(f"Connection successful, API token saved in {cbrain_config.CREDENTIALS_FILE}") + # Named sessions → nested map; bare login keeps flat file (main-compatible). + if target_session != "default" or session_specified: + on_disk = cbrain_config.load_credentials() or {} + if is_flat_credentials(on_disk): + on_disk = {"default": {k: v for k, v in on_disk.items() if k != ACTIVE_SESSION_KEY}} + on_disk[target_session] = credentials + on_disk[ACTIVE_SESSION_KEY] = target_session + cbrain_config.save_credentials(on_disk) + print( + f"Connection successful, API token saved in {cbrain_config.CREDENTIALS_FILE} " + f"for session '{target_session}'" + ) + else: + cbrain_config.save_credentials(credentials) + print(f"Connection successful, API token saved in {cbrain_config.CREDENTIALS_FILE}") return 0 # MARK: Logout -def logout_session(args): - """ - Logout from CBRAIN by deleting the session file. - Parameters - ---------- - args : argparse.Namespace - Parsed command-line arguments (unused). - Returns - ------- - int - Exit code (0 on success). +def logout_session(args): """ + Logout from CBRAIN. + Without ``--session``: logout all sessions (or the single flat session). + With ``--session ``: logout only that session. + """ if not cbrain_config.CREDENTIALS_FILE.exists(): print("Not logged in. Use 'cbrain login' to login first.") return 0 - credentials = cbrain_config.load_credentials() - if credentials is None: + all_creds = cbrain_config.load_credentials() + if all_creds is None: print("Invalid credentials file. Removing local session.") cbrain_config.CREDENTIALS_FILE.unlink(missing_ok=True) print(f"Local session removed from {cbrain_config.CREDENTIALS_FILE}") return 0 - cbrain_url = credentials.get("cbrain_url") - api_token = credentials.get("api_token") - if not cbrain_url or not api_token: - print("Invalid credentials file. Removing local session.") - cbrain_config.CREDENTIALS_FILE.unlink(missing_ok=True) - print(f"Local session removed from {cbrain_config.CREDENTIALS_FILE}") + sessions = get_named_sessions(all_creds) + flat = is_flat_credentials(all_creds) + + if session_specified: + sessions_to_logout = [session_name] + else: + sessions_to_logout = list(sessions) + + if not sessions_to_logout: + print("Not logged in. Use 'cbrain login' to login first.") return 0 - try: - _, status = CbrainClient.from_credentials().send("DELETE", "/session") - if status == 200: - print("Successfully logged out from CBRAIN server.") - else: - print("Logout failed") - except CliApiError as e: - if e.status == 401: - print("Session already expired on server.") - else: - print(f"Logout request failed: HTTP {e.status}") - except urllib.error.URLError as e: - print(f"Network error during logout: {e}") + for s_name in sessions_to_logout: + creds = sessions.get(s_name, {}) + s_url, s_token = creds.get("cbrain_url"), creds.get("api_token") - if cbrain_config.CREDENTIALS_FILE.exists(): - cbrain_config.CREDENTIALS_FILE.unlink() - print(f"Local session removed from {cbrain_config.CREDENTIALS_FILE}") + if not s_url or not s_token: + if s_name in sessions: + print(f"Invalid credentials for session '{s_name}'. Removing local session.") + if flat: + cbrain_config.CREDENTIALS_FILE.unlink(missing_ok=True) + print(f"Local session removed from {cbrain_config.CREDENTIALS_FILE}") + return 0 + all_creds.pop(s_name, None) + elif session_specified: + print(f"Not logged in to session '{s_name}'.") + elif len(sessions_to_logout) == 1: + print("Not logged in. Use 'cbrain login' to login first.") + if flat: + cbrain_config.CREDENTIALS_FILE.unlink(missing_ok=True) + print(f"Local session removed from {cbrain_config.CREDENTIALS_FILE}") + return 0 + continue + + display_name = creds.get("username", s_name) + try: + _, status = CbrainClient(s_url, s_token, creds.get("user_id")).send( + "DELETE", "/session" + ) + if status == 200: + if flat or not session_specified and len(sessions_to_logout) == 1: + print("Successfully logged out from CBRAIN server.") + else: + print(f"Successfully logged out from CBRAIN server as {display_name}.") + else: + print(f"Logout failed for session '{s_name}'." if not flat else "Logout failed") + except CliApiError as e: + if e.status == 401: + print( + "Session already expired on server." + if flat + else f"Session '{s_name}' already expired on server." + ) + else: + print( + f"Logout request failed: HTTP {e.status}" + if flat + else f"Logout request failed for '{s_name}': HTTP {e.status}" + ) + except urllib.error.URLError as e: + print( + f"Network error during logout: {e}" + if flat + else f"Network error during logout for '{s_name}': {e}" + ) + + if flat: + if cbrain_config.CREDENTIALS_FILE.exists(): + cbrain_config.CREDENTIALS_FILE.unlink() + print(f"Local session removed from {cbrain_config.CREDENTIALS_FILE}") + return 0 + + all_creds.pop(s_name, None) + print(f"Local session '{s_name}' removed from {cbrain_config.CREDENTIALS_FILE}.") + + if not flat: + remaining = get_named_sessions(all_creds) + if not remaining: + cbrain_config.CREDENTIALS_FILE.unlink(missing_ok=True) + else: + if all_creds.get(ACTIVE_SESSION_KEY) not in remaining: + all_creds[ACTIVE_SESSION_KEY] = next(iter(remaining)) + cbrain_config.save_credentials(all_creds) return 0 diff --git a/switch_session b/switch_session new file mode 100644 index 0000000..075934f --- /dev/null +++ b/switch_session @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Convenience wrapper: ./switch_session +# Equivalent to: cbrain switch_session +set -e +exec "$(dirname "$0")/cbrain" switch_session "$@"