From 3ae9395f9da65f64e430b0f7254a8f58fc0bcf56 Mon Sep 17 00:00:00 2001 From: Kalzu Rekku Date: Sun, 5 Oct 2025 20:03:35 +0300 Subject: [PATCH] Initial commit. The shell now combiles and runs. --- README.md | 257 +++++++++++++++++++++++++ go.mod | 14 ++ go.sum | 18 ++ main.go | 554 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 843 insertions(+) create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f26ca8 --- /dev/null +++ b/README.md @@ -0,0 +1,257 @@ +# KVS Shell + +An interactive command-line client for the KVS (Key-Value Store) distributed database system. + +## Overview + +KVS Shell is a terminal-based client that provides an intuitive interface for interacting with KVS servers. It offers features similar to `mongosh`, `psql`, or `redis-cli`, allowing you to manage key-value data, users, groups, and cluster members through a simple command interface. + +## Features + +- **Interactive Shell**: Full-featured REPL with command history and auto-completion +- **Profile Management**: Switch between multiple user accounts and servers +- **Authentication**: JWT token-based authentication with secure credential storage +- **Key-Value Operations**: Store, retrieve, and delete JSON data +- **Cluster Management**: Monitor cluster members and health status +- **User Management**: View and manage user accounts (with appropriate permissions) +- **Colored Output**: Syntax-highlighted responses for better readability + +## Installation + +### Prerequisites + +- Go 1.21 or higher +- Access to a running KVS server + +### Build from Source + +```bash +git clone +cd kvs-shell +go mod tidy +go build -o kvs-shell +``` + +### Install + +```bash +# Install to your Go bin directory +go install + +# Or copy the binary to your PATH +sudo cp kvs-shell /usr/local/bin/ +``` + +## Quick Start + +1. Start the shell: +```bash +./kvs-shell +``` + +2. Connect to your KVS server: +``` +kvs> connect http://localhost:8090 +``` + +3. Authenticate (if required): +``` +kvs> auth eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +4. Store and retrieve data: +``` +kvs> put users/alice {"name": "Alice", "email": "alice@example.com"} +kvs> get users/alice +``` + +## Commands Reference + +### Connection & Authentication + +```bash +connect # Connect to KVS server +auth # Set authentication token +``` + +### Profile Management + +```bash +profile # List all saved profiles +profile add [url] # Add a new profile +profile use # Switch to a profile +profile remove # Delete a profile +``` + +**Example:** +``` +kvs> profile add admin eyJhbG... user-uuid-123 http://localhost:8090 +kvs> profile add dev eyJhbG... user-uuid-456 http://localhost:8091 +kvs> profile use dev +``` + +### Key-Value Operations + +```bash +get # Retrieve value for key +put # Store JSON value at key +delete # Delete key +``` + +**Examples:** +``` +kvs> put config/app {"version": "1.0", "debug": true} +kvs> get config/app +kvs> delete config/old +``` + +### Cluster Management + +```bash +members # List cluster members +health # Check service health +``` + +### User Management + +```bash +user get # Get user details +user create # Create new user (admin only) +``` + +### System Commands + +```bash +help # Show help +clear # Clear screen +exit, quit # Exit shell +``` + +## Response Format + +### Stored Values + +When retrieving data with `get`, you'll see: +``` +UUID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +Timestamp: 2025-10-05T12:34:56Z +Data: +{ + "name": "Alice", + "email": "alice@example.com" +} +``` + +### Cluster Members + +The `members` command shows: +``` +Cluster Members: + • node-1 (192.168.1.10:8090) - Last seen: 2025-10-05T12:34:56Z + • node-2 (192.168.1.11:8090) - Last seen: 2025-10-05T12:35:01Z +``` + +## Configuration + +### History + +Command history is automatically saved to `~/.kvs_history` and persists across sessions. + +### Profiles + +Profiles are stored in memory during the session. To persist profiles across restarts, you can save them to a configuration file (feature to be added in future versions). + +## Authentication + +KVS Shell uses JWT tokens for authentication. Obtain a token from your KVS server administrator or through the server's authentication API: + +```bash +# Example: Get token from KVS server +curl -X POST http://localhost:8090/api/tokens \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"user_uuid": "your-uuid", "scopes": ["read", "write"]}' +``` + +Then use the returned token in the shell: +``` +kvs> auth eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +## Advanced Usage + +### Working with Complex JSON + +For multi-line or complex JSON, use quotes: +``` +kvs> put data/user '{"name": "Bob", "preferences": {"theme": "dark", "notifications": true}}' +``` + +### Key Naming Conventions + +Keys can use path-like structures: +``` +users/alice +config/app/production +data/metrics/2025-10 +``` + +This helps organize your data hierarchically. + +## Troubleshooting + +### Connection Issues + +If you can't connect to the server: +1. Verify the server is running: `curl http://localhost:8090/health` +2. Check firewall settings +3. Ensure the URL includes the protocol (http:// or https://) + +### Authentication Errors + +If you get "Unauthorized" errors: +1. Verify your token is still valid (tokens may expire) +2. Check that your user has the required permissions +3. Generate a new token if needed + +### Command Not Found + +If a command isn't recognized: +- Type `help` to see available commands +- Check for typos in the command name +- Ensure you've connected to a server first + +## Development + +### Building + +```bash +go build -o kvs-shell +``` + +### Testing + +```bash +go test ./... +``` + +### Dependencies + +- `github.com/chzyer/readline` - Interactive line editing and history +- `github.com/fatih/color` - Colored terminal output + +## Roadmap + +Planned features: +- [ ] Configuration file for persistent profiles +- [ ] Management of local KVS instance configuration file +- [ ] Batch operations from file +- [ ] Export/import functionality +- [ ] Group management commands +- [ ] Metadata management +- [ ] Query filtering and pagination +- [ ] TLS/SSL support +- [ ] Shell scripts execution +- [ ] Tab completion for keys +- [ ] Transaction support + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..24769b5 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module kvs-shell + +go 1.21 + +require ( + github.com/chzyer/readline v1.5.1 + github.com/fatih/color v1.16.0 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c2b0d7f --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e5fe0d0 --- /dev/null +++ b/main.go @@ -0,0 +1,554 @@ +// kvs-shell/main.go +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/chzyer/readline" + "github.com/fatih/color" +) + +// Types matching your backend +type StoredValue struct { + UUID string `json:"uuid"` + Timestamp int64 `json:"timestamp"` + Data json.RawMessage `json:"data"` +} + +type Member struct { + ID string `json:"id"` + Address string `json:"address"` + LastSeen int64 `json:"last_seen"` + JoinedTimestamp int64 `json:"joined_timestamp"` +} + +type User struct { + UUID string `json:"uuid"` + NicknameHash string `json:"nickname_hash"` + Groups []string `json:"groups"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type Group struct { + UUID string `json:"uuid"` + NameHash string `json:"name_hash"` + Members []string `json:"members"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// Client state +type KVSClient struct { + baseURL string + currentToken string + currentUser string + httpClient *http.Client + profiles map[string]Profile // Stored user profiles + activeProfile string +} + +type Profile struct { + Name string `json:"name"` + Token string `json:"token"` + UserUUID string `json:"user_uuid"` + BaseURL string `json:"base_url"` +} + +// Colors +var ( + cyan = color.New(color.FgCyan).SprintFunc() + green = color.New(color.FgGreen).SprintFunc() + yellow = color.New(color.FgYellow).SprintFunc() + red = color.New(color.FgRed).SprintFunc() + magenta = color.New(color.FgMagenta).SprintFunc() +) + +func NewKVSClient(baseURL string) *KVSClient { + return &KVSClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + profiles: make(map[string]Profile), + } +} + +// HTTP request helper +func (c *KVSClient) doRequest(method, path string, body interface{}) ([]byte, int, error) { + var reqBody io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + reqBody = bytes.NewBuffer(jsonData) + } + + req, err := http.NewRequest(method, c.baseURL+path, reqBody) + if err != nil { + return nil, 0, err + } + + req.Header.Set("Content-Type", "application/json") + if c.currentToken != "" { + req.Header.Set("Authorization", "Bearer "+c.currentToken) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + return respBody, resp.StatusCode, err +} + +// Command handlers +func (c *KVSClient) handleConnect(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: connect ")) + return + } + c.baseURL = args[0] + fmt.Println(green("Connected to:"), c.baseURL) +} + +func (c *KVSClient) handleAuth(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: auth ")) + return + } + c.currentToken = args[0] + fmt.Println(green("Authentication token set")) +} + +func (c *KVSClient) handleProfile(args []string) { + if len(args) == 0 { + // List profiles + if len(c.profiles) == 0 { + fmt.Println(yellow("No profiles configured")) + return + } + fmt.Println(cyan("Available profiles:")) + for name, profile := range c.profiles { + marker := " " + if name == c.activeProfile { + marker = "*" + } + fmt.Printf("%s %s (user: %s, url: %s)\n", marker, name, profile.UserUUID, profile.BaseURL) + } + return + } + + subCmd := args[0] + switch subCmd { + case "add": + if len(args) < 4 { + fmt.Println(red("Usage: profile add [base-url]")) + return + } + baseURL := c.baseURL + if len(args) >= 5 { + baseURL = args[4] + } + c.profiles[args[1]] = Profile{ + Name: args[1], + Token: args[2], + UserUUID: args[3], + BaseURL: baseURL, + } + fmt.Println(green("Profile added:"), args[1]) + + case "use": + if len(args) < 2 { + fmt.Println(red("Usage: profile use ")) + return + } + profile, ok := c.profiles[args[1]] + if !ok { + fmt.Println(red("Profile not found:"), args[1]) + return + } + c.currentToken = profile.Token + c.currentUser = profile.UserUUID + c.baseURL = profile.BaseURL + c.activeProfile = args[1] + fmt.Println(green("Switched to profile:"), args[1]) + + case "remove": + if len(args) < 2 { + fmt.Println(red("Usage: profile remove ")) + return + } + delete(c.profiles, args[1]) + if c.activeProfile == args[1] { + c.activeProfile = "" + } + fmt.Println(green("Profile removed:"), args[1]) + } +} + +func (c *KVSClient) handleGet(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: get ")) + return + } + + respBody, status, err := c.doRequest("GET", "/kv/"+args[0], nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == 404 { + fmt.Println(yellow("Key not found")) + return + } + + if status != 200 { + fmt.Println(red("Error:"), string(respBody)) + return + } + + var stored StoredValue + if err := json.Unmarshal(respBody, &stored); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + fmt.Println(cyan("UUID: "), stored.UUID) + fmt.Println(cyan("Timestamp:"), time.UnixMilli(stored.Timestamp).Format(time.RFC3339)) + fmt.Println(cyan("Data:")) + + var pretty bytes.Buffer + json.Indent(&pretty, stored.Data, "", " ") + fmt.Println(pretty.String()) +} + +func (c *KVSClient) handlePut(args []string) { + if len(args) < 2 { + fmt.Println(red("Usage: put ")) + return + } + + // Parse JSON data + var data json.RawMessage + if err := json.Unmarshal([]byte(args[1]), &data); err != nil { + fmt.Println(red("Invalid JSON:"), err) + return + } + + respBody, status, err := c.doRequest("PUT", "/kv/"+args[0], data) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != 200 && status != 201 { + fmt.Println(red("Error:"), string(respBody)) + return + } + + type PutResponse struct { + UUID string `json:"uuid"` + Timestamp int64 `json:"timestamp"` + } + + var resp PutResponse + if err := json.Unmarshal(respBody, &resp); err == nil { + fmt.Println(green("Stored successfully")) + fmt.Println(cyan("UUID: "), resp.UUID) + fmt.Println(cyan("Timestamp:"), time.UnixMilli(resp.Timestamp).Format(time.RFC3339)) + } +} + +func (c *KVSClient) handleDelete(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: delete ")) + return + } + + _, status, err := c.doRequest("DELETE", "/kv/"+args[0], nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == 404 { + fmt.Println(yellow("Key not found")) + return + } + + if status == 204 { + fmt.Println(green("Deleted successfully")) + } else { + fmt.Println(red("Unexpected status:"), status) + } +} + +func (c *KVSClient) handleMembers(args []string) { + respBody, status, err := c.doRequest("GET", "/members/", nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != 200 { + fmt.Println(red("Error:"), string(respBody)) + return + } + + var members []Member + if err := json.Unmarshal(respBody, &members); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + if len(members) == 0 { + fmt.Println(yellow("No cluster members")) + return + } + + fmt.Println(cyan("Cluster Members:")) + for _, m := range members { + lastSeen := time.UnixMilli(m.LastSeen).Format(time.RFC3339) + fmt.Printf(" • %s (%s) - Last seen: %s\n", m.ID, m.Address, lastSeen) + } +} + +func (c *KVSClient) handleHealth(args []string) { + respBody, status, err := c.doRequest("GET", "/health", nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != 200 { + fmt.Println(red("Service unhealthy")) + return + } + + var health map[string]interface{} + if err := json.Unmarshal(respBody, &health); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + fmt.Println(green("Service Status:")) + for k, v := range health { + fmt.Printf(" %s: %v\n", cyan(k), v) + } +} + +func (c *KVSClient) handleUserList(args []string) { + // Note: Backend doesn't have a list-all endpoint, this would need to be added + fmt.Println(yellow("User listing not implemented in backend API")) +} + +func (c *KVSClient) handleUserGet(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: user get ")) + return + } + + respBody, status, err := c.doRequest("GET", "/api/users/"+args[0], nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == 404 { + fmt.Println(yellow("User not found")) + return + } + + if status != 200 { + fmt.Println(red("Error:"), string(respBody)) + return + } + + var user User + if err := json.Unmarshal(respBody, &user); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + fmt.Println(cyan("User Details:")) + fmt.Printf(" UUID: %s\n", user.UUID) + fmt.Printf(" Nickname Hash: %s\n", user.NicknameHash) + fmt.Printf(" Groups: %v\n", user.Groups) + fmt.Printf(" Created: %s\n", time.Unix(user.CreatedAt, 0).Format(time.RFC3339)) + fmt.Printf(" Updated: %s\n", time.Unix(user.UpdatedAt, 0).Format(time.RFC3339)) +} + +func (c *KVSClient) handleHelp(args []string) { + help := ` +KVS Interactive Shell - Available Commands: + +Connection & Authentication: + connect - Connect to KVS server + auth - Set authentication token + profile add [url] - Add user profile + profile use - Switch to user profile + profile remove - Remove user profile + profile - List all profiles + +Key-Value Operations: + get - Retrieve value for key + put - Store JSON value at key + delete - Delete key + +Cluster Management: + members - List cluster members + health - Check service health + +User Management: + user get - Get user details + user create - Create new user (admin only) + +System: + help - Show this help + exit, quit - Exit shell + clear - Clear screen +` + fmt.Println(help) +} + +func main() { + client := NewKVSClient("http://localhost:8090") + + // Setup readline + rl, err := readline.NewEx(&readline.Config{ + Prompt: cyan("kvs> "), + HistoryFile: os.Getenv("HOME") + "/.kvs_history", + AutoComplete: completer, + InterruptPrompt: "^C", + EOFPrompt: "exit", + }) + if err != nil { + panic(err) + } + defer rl.Close() + + fmt.Println(magenta("KVS Interactive Shell")) + fmt.Println("Type 'help' for available commands") + fmt.Println() + + for { + line, err := rl.Readline() + if err != nil { + break + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := parseCommand(line) + if len(parts) == 0 { + continue + } + + cmd := parts[0] + args := parts[1:] + + switch cmd { + case "exit", "quit": + fmt.Println("Goodbye!") + return + case "clear": + print("\033[H\033[2J") + case "help": + client.handleHelp(args) + case "connect": + client.handleConnect(args) + case "auth": + client.handleAuth(args) + case "profile": + client.handleProfile(args) + case "get": + client.handleGet(args) + case "put": + client.handlePut(args) + case "delete": + client.handleDelete(args) + case "members": + client.handleMembers(args) + case "health": + client.handleHealth(args) + case "user": + if len(args) > 0 && args[0] == "get" { + client.handleUserGet(args[1:]) + } else { + client.handleUserList(args) + } + default: + fmt.Println(red("Unknown command:"), cmd) + fmt.Println("Type 'help' for available commands") + } + } +} + +// Auto-completion +var completer = readline.NewPrefixCompleter( + readline.PcItem("connect"), + readline.PcItem("auth"), + readline.PcItem("profile", + readline.PcItem("add"), + readline.PcItem("use"), + readline.PcItem("remove"), + ), + readline.PcItem("get"), + readline.PcItem("put"), + readline.PcItem("delete"), + readline.PcItem("members"), + readline.PcItem("health"), + readline.PcItem("user", + readline.PcItem("get"), + readline.PcItem("create"), + ), + readline.PcItem("help"), + readline.PcItem("exit"), + readline.PcItem("quit"), + readline.PcItem("clear"), +) + +// Parse command line respecting quotes +func parseCommand(line string) []string { + var parts []string + var current strings.Builder + inQuotes := false + + for i := 0; i < len(line); i++ { + ch := line[i] + switch ch { + case '"': + inQuotes = !inQuotes + case ' ', '\t': + if inQuotes { + current.WriteByte(ch) + } else if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + default: + current.WriteByte(ch) + } + } + + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts +}