From f9eabe093328fb311e8f73deb692133bb6dd96ce Mon Sep 17 00:00:00 2001 From: Ari Krakauer Date: Thu, 30 Apr 2026 17:18:44 -0400 Subject: [PATCH 1/2] Added get version and all Co-authored-by: Copilot --- README.md | 25 ++++++++++++++ cli.py | 86 ++++++++++++++++++++++++++++++++++++++++++----- client.py | 17 +++++++++- tests/test_cli.py | 60 ++++++++++++++++++++++++++++++++- 4 files changed, 177 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e4c6e6b..1eed915 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ Notes: dsvc ping dsvc create dsvc get +dsvc get --all +dsvc get --version dsvc update dsvc delete ``` @@ -68,11 +70,34 @@ dsvc login alice dsvc ping dsvc create db-password hunter2 dsvc get db-password +dsvc get db-password --all +dsvc get db-password --version 1 dsvc update db-password new-value dsvc delete db-password dsvc logout ``` +#### Get command options + +The `get` command supports retrieving secret versions: + +- `dsvc get ` - Retrieve the current version of a secret +- `dsvc get --all` - Retrieve all versions of a secret +- `dsvc get --version ` - Retrieve a specific version of a secret + +Examples: + +```bash +# Get the current version +dsvc get db-password + +# Get all versions (returns a list) +dsvc get db-password --all + +# Get a specific version +dsvc get db-password --version 2 +``` + With no arguments, `dsvc` prints help and exits. ## Batch mode diff --git a/cli.py b/cli.py index 81cde6a..5c65545 100755 --- a/cli.py +++ b/cli.py @@ -6,6 +6,9 @@ dsvc ping dsvc login my-user dsvc create my-secret value + dsvc get my-secret + dsvc get my-secret --all + dsvc get my-secret --version 2 dsvc --script commands.txt """ @@ -23,7 +26,7 @@ "login": "login ", "logout": "logout", "create": "create ", - "get": "get ", + "get": "get [--version | --all]", "update": "update ", "delete": "delete ", } @@ -34,7 +37,7 @@ "login": 2, "logout": 1, "create": 3, - "get": 2, + "get": -1, # Variable arguments: 2, 3 (--all), or 4 (--version ) "update": 3, "delete": 2, } @@ -44,7 +47,7 @@ "login": ("Store the username and start a session.", "dsvc login my-user"), "logout": ("Clear the stored username and end the session.", "dsvc logout"), "create": ("Create a secret.", "dsvc create db-password hunter2"), - "get": ("Retrieve a secret value.", "dsvc get db-password"), + "get": ("Retrieve a secret value (current version by default).", "dsvc get db-password"), "update": ("Update an existing secret value.", "dsvc update db-password new-value"), "delete": ("Delete a secret.", "dsvc delete db-password"), } @@ -56,7 +59,7 @@ # Command runners # --------------------------------------------------------------------------- -def _run_ping(client: Client, args: list[str]) -> None: +def _run_ping(client: Client) -> None: response = client.ping() _print_http_response(response) @@ -66,9 +69,50 @@ def _run_create(client: Client, args: list[str], username: str) -> None: _print_http_response(response) +def _parse_get_options(args: list[str]) -> tuple[str, Optional[str]]: + """Parse get command options. + + Returns: (secret_name, option_value) + - option_value is None for current version + - option_value is "all" for all versions + - option_value is the version number for a specific version + """ + secret_name = args[1] + + if len(args) == 2: + # get + return secret_name, None + elif len(args) == 3 and args[2] == "--all": + # get --all + return secret_name, "all" + elif len(args) == 4 and args[2] == "--version": + # get --version + return secret_name, args[3] + else: + return None, None # Invalid + + def _run_get(client: Client, args: list[str], username: str) -> None: - response = client.get_secret(args[1], username) - _print_http_response(response) + secret_name, option_value = _parse_get_options(args) + + if secret_name is None: + _print_invalid_parameters("get", COMMAND_USAGE["get"]) + return + + try: + if option_value is None: + # Get current version + response = client.get_secret(secret_name, username) + elif option_value == "all": + # Get all versions + response = client.get_all_secret_versions(secret_name, username) + else: + # Get specific version + response = client.get_secret_version(secret_name, option_value, username) + + _print_http_response(response) + except ClientException as exc: + _print_request_failure(exc) def _run_update(client: Client, args: list[str], username: str) -> None: @@ -102,7 +146,7 @@ def _run_login(config: dict, args: list[str]) -> dict: return config -def _run_logout(config: dict, args: list[str]) -> dict: +def _run_logout(config: dict) -> dict: if not str(config.get("username", "")).strip(): print("You are already logged out.") return config @@ -124,6 +168,20 @@ def _validate_command_arguments(args: list[str]) -> bool: expected_count = COMMAND_ARGC.get(command) if expected_count is None: return True + # Special handling for "get" which has variable arguments + if expected_count == -1: # Variable arguments + if command == "get": + # get can have 2, 3 (--all), or 4 (--version ) arguments + if len(args) == 2: + return True # get + elif len(args) == 3 and args[2] == "--all": + return True # get --all + elif len(args) == 4 and args[2] == "--version": + return True # get --version + else: + _print_invalid_parameters(command, COMMAND_USAGE[command]) + return False + return True if len(args) == expected_count: return True _print_invalid_parameters(command, COMMAND_USAGE[command]) @@ -153,7 +211,7 @@ def _run_command(client: Optional[Client], config: dict, args: list[str]) -> dic if operation == "logout": if not _validate_command_arguments(args): return config - return _run_logout(config, args) + return _run_logout(config) if not _validate_command_arguments(args): return config @@ -171,7 +229,7 @@ def _run_command(client: Optional[Client], config: dict, args: list[str]) -> dic try: match operation: case "ping": - _run_ping(client, args) + _run_ping(client) case "create": _run_create(client, args, username) case "get": @@ -204,7 +262,17 @@ def _print_usage() -> None: print(f" {COMMAND_USAGE[command]}") print(f" {description}") print(f" Example: {example}") + # Add additional examples for get command options + if command == "get": + print(f" Get all versions: dsvc get db-password --all") + print(f" Get specific version: dsvc get db-password --version 2") print() + print("Get command details:") + print(" The 'get' command supports retrieving different versions of a secret:") + print(" - dsvc get Retrieve the current version") + print(" - dsvc get --all Retrieve all versions (returns a list)") + print(" - dsvc get --version Retrieve a specific version") + print() print("Batch mode:") print(" dsvc --script ") print(" Run commands from a file, one command per line.") diff --git a/client.py b/client.py index b8ef9ca..0c9852b 100644 --- a/client.py +++ b/client.py @@ -69,6 +69,22 @@ def get_secret(self, secret_name: str, auth_key: str = "") -> str: path += f"?user={urllib.parse.quote(auth_key, safe='')}" return self._send("GET", path, None, 200) + def get_secret_version(self, secret_name: str, version: str, auth_key: str = "") -> str: + encoded_name = urllib.parse.quote(secret_name, safe="") + path = f"{self.SECRETS_PATH}/{encoded_name}" + params = [f"version={urllib.parse.quote(version, safe='')}"] + if auth_key: + params.append(f"user={urllib.parse.quote(auth_key, safe='')}") + path += "?" + "&".join(params) + return self._send("GET", path, None, 200) + + def get_all_secret_versions(self, secret_name: str, auth_key: str = "") -> str: + encoded_name = urllib.parse.quote(secret_name, safe="") + path = f"{self.SECRETS_PATH}/{encoded_name}/all" + if auth_key: + path += f"?user={urllib.parse.quote(auth_key, safe='')}" + return self._send("GET", path, None, 200) + def update_secret( self, secret_name: str, secret_updated_value: str, auth_key: str ) -> str: @@ -156,4 +172,3 @@ def _normalize_reason(status_code: int, reason: str) -> str: return HTTPStatus(status_code).phrase except ValueError: return "" - diff --git a/tests/test_cli.py b/tests/test_cli.py index cf335cd..1fbac4e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,7 +7,7 @@ import unittest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import urlparse +from urllib.parse import urlparse, parse_qs class MockDsvHandler(BaseHTTPRequestHandler): @@ -17,15 +17,26 @@ class MockDsvHandler(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) path = parsed.path + query_params = parse_qs(parsed.query) if path == "/health": self._respond(200, "OK") return if path == "/api/v1/secrets/my-secret": + # Check if version parameter is present + if "version" in query_params: + version = query_params["version"][0] + self._respond(200, f"version-{version}") + return self._respond(200, "retrieved") return + # Handle get all versions: /api/v1/secrets/my-secret/all + if path == "/api/v1/secrets/my-secret/all": + self._respond(200, '["version1", "version2", "version3"]') + return + if path == "/api/v1/secrets/missing": self._respond(404, "Secret not found") return @@ -312,6 +323,53 @@ def test_script_mode_rejects_command_without_login(self): self.assertEqual(0, result.returncode) self.assertIn("Please log in first: dsvc login ", result.stdout) + def test_get_current_version_still_works(self): + result = self._run_cli(["get", "my-secret"]) + self.assertEqual(0, result.returncode) + self.assertIn("retrieved", result.stdout) + + def test_get_all_versions(self): + result = self._run_cli(["get", "my-secret", "--all"]) + self.assertEqual(0, result.returncode) + self.assertIn("version1", result.stdout) + self.assertIn("version2", result.stdout) + self.assertIn("version3", result.stdout) + + def test_get_specific_version(self): + result = self._run_cli(["get", "my-secret", "--version", "2"]) + self.assertEqual(0, result.returncode) + self.assertIn("version-2", result.stdout) + + def test_get_with_invalid_version_option_syntax(self): + result = self._run_cli(["get", "my-secret", "--version"]) + self.assertEqual(0, result.returncode) + self.assertIn("Invalid parameters for 'get'.", result.stdout) + self.assertIn("Expected:", result.stdout) + + def test_get_with_unknown_option(self): + result = self._run_cli(["get", "my-secret", "--unknown"]) + self.assertEqual(0, result.returncode) + self.assertIn("Invalid parameters for 'get'.", result.stdout) + + def test_get_all_versions_in_script_mode(self): + result = self._run_cli( + ["--script", self._build_script(["login alice", "get my-secret --all"])], + cleanup_script=True, + config_data={"base_url": self.base_url, "username": ""}, + ) + self.assertEqual(0, result.returncode) + self.assertIn("version1", result.stdout) + self.assertIn("version2", result.stdout) + + def test_get_specific_version_in_script_mode(self): + result = self._run_cli( + ["--script", self._build_script(["login alice", "get my-secret --version 3"])], + cleanup_script=True, + config_data={"base_url": self.base_url, "username": ""}, + ) + self.assertEqual(0, result.returncode) + self.assertIn("version-3", result.stdout) + if __name__ == "__main__": unittest.main() From 856be59f0285310466313496bbbb2d75b8dbd11e Mon Sep 17 00:00:00 2001 From: Max F Date: Thu, 30 Apr 2026 17:42:38 -0400 Subject: [PATCH 2/2] update formatting and help Co-authored-by: Copilot --- cli.py | 46 +++++++++++++++++++++++++++++++++------------- tests/test_cli.py | 2 +- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/cli.py b/cli.py index 5c65545..388a4e0 100755 --- a/cli.py +++ b/cli.py @@ -21,7 +21,7 @@ from config import is_configured, is_logged_in, load_config, save_config COMMAND_USAGE: dict[str, str] = { - "help": "help", + "help": "help, -h, --help", "ping": "ping", "login": "login ", "logout": "logout", @@ -43,11 +43,15 @@ } COMMAND_DESCRIPTIONS: dict[str, tuple[str, str]] = { + "help": ("Show this help message and exit.", "dsvc help"), "ping": ("Check server connectivity.", "dsvc ping"), "login": ("Store the username and start a session.", "dsvc login my-user"), "logout": ("Clear the stored username and end the session.", "dsvc logout"), "create": ("Create a secret.", "dsvc create db-password hunter2"), - "get": ("Retrieve a secret value (current version by default).", "dsvc get db-password"), + "get": ( + "Retrieve a secret value (current version by default).", + "dsvc get db-password", + ), "update": ("Update an existing secret value.", "dsvc update db-password new-value"), "delete": ("Delete a secret.", "dsvc delete db-password"), } @@ -59,6 +63,7 @@ # Command runners # --------------------------------------------------------------------------- + def _run_ping(client: Client) -> None: response = client.ping() _print_http_response(response) @@ -71,7 +76,7 @@ def _run_create(client: Client, args: list[str], username: str) -> None: def _parse_get_options(args: list[str]) -> tuple[str, Optional[str]]: """Parse get command options. - + Returns: (secret_name, option_value) - option_value is None for current version - option_value is "all" for all versions @@ -89,7 +94,7 @@ def _parse_get_options(args: list[str]) -> tuple[str, Optional[str]]: # get --version return secret_name, args[3] else: - return None, None # Invalid + return "", None # Invalid def _run_get(client: Client, args: list[str], username: str) -> None: @@ -190,7 +195,9 @@ def _validate_command_arguments(args: list[str]) -> bool: def _print_missing_server_configuration() -> None: print("Server URL is not configured.") - print("Set 'base_url' in ~/.dsv_client/config.json or run the installer setup again.") + print( + "Set 'base_url' in ~/.dsv_client/config.json or run the installer setup again." + ) def _run_command(client: Optional[Client], config: dict, args: list[str]) -> dict: @@ -250,6 +257,7 @@ def _run_command(client: Optional[Client], config: dict, args: list[str]) -> dic # Formatting helpers # --------------------------------------------------------------------------- + def _print_usage() -> None: print("DSV Client usage") print() @@ -257,7 +265,16 @@ def _print_usage() -> None: print(" dsvc [arguments]") print() print("Commands:") - for command in ("ping", "login", "logout", "create", "get", "update", "delete"): + for command in ( + "help", + "ping", + "login", + "logout", + "create", + "get", + "update", + "delete", + ): description, example = COMMAND_DESCRIPTIONS[command] print(f" {COMMAND_USAGE[command]}") print(f" {description}") @@ -267,12 +284,6 @@ def _print_usage() -> None: print(f" Get all versions: dsvc get db-password --all") print(f" Get specific version: dsvc get db-password --version 2") print() - print("Get command details:") - print(" The 'get' command supports retrieving different versions of a secret:") - print(" - dsvc get Retrieve the current version") - print(" - dsvc get --all Retrieve all versions (returns a list)") - print(" - dsvc get --version Retrieve a specific version") - print() print("Batch mode:") print(" dsvc --script ") print(" Run commands from a file, one command per line.") @@ -342,6 +353,7 @@ def _extract_response_message(body: str) -> str: # Line parser (handles quoted tokens) # --------------------------------------------------------------------------- + def _parse_line(line: str) -> list[str]: """Split *line* into tokens, respecting single- and double-quoted strings.""" tokens: list[str] = [] @@ -412,9 +424,17 @@ def _run_script(script_file: str) -> None: # Entry point # --------------------------------------------------------------------------- + def main() -> None: + # Check for help command or empty arguments early + if len(sys.argv) == 1 or ( + len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help", "help") + ): + _print_usage() + return + parser = argparse.ArgumentParser( - prog="dsvc", description="Distributed Secrets Vault CLI Client" + prog="dsvc", description="Distributed Secrets Vault CLI Client", add_help=False ) parser.add_argument( "--script", diff --git a/tests/test_cli.py b/tests/test_cli.py index 1fbac4e..ef54264 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -232,7 +232,7 @@ def test_no_args_prints_help_and_exits(self): result = self._run_cli([]) self.assertEqual(0, result.returncode) - self.assertIn("usage:", result.stdout) + self.assertIn("DSV Client usage", result.stdout) self.assertIn("dsvc --script ", result.stdout) def test_repl_command_is_rejected(self):