From fcd27dee975afadb6caa33291ab6f7e63719d8e7 Mon Sep 17 00:00:00 2001 From: ryyst Date: Sun, 5 Oct 2025 23:16:57 +0300 Subject: [PATCH 1/7] chore: update documentation --- CLAUDE.md | 37 ++++++++++++++++++++++++++++++++++--- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0a44003..fcf7cbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,16 +41,47 @@ go install ## Quick Start with Server ```bash -# 1. Start KVS server (in ../kvs/) -cd ../kvs && ./kvs config.yaml +# 1. Start KVS server as daemon (in ../kvs/) +cd ../kvs +./kvs start config.yaml + +# Check server is running +./kvs status + +# View logs to get root token +tail ~/.kvs/logs/kvs_config.yaml.log | grep "Token:" # 2. Run shell +cd ../kvs-sh ./kvs-shell # 3. In shell: # connect http://localhost:8080 -# auth $KVS_ROOT_TOKEN +# auth # put test/data '{"hello":"world"}' + +# 4. Stop server when done +cd ../kvs +./kvs stop config +``` + +**Daemon Commands:** +```bash +# Start server +./kvs start config.yaml # or just: ./kvs start config + +# Check status +./kvs status # Show all instances +./kvs status config # Show specific instance + +# View logs +tail -f ~/.kvs/logs/kvs_config.yaml.log + +# Stop server +./kvs stop config + +# Restart server +./kvs restart config ``` **Important:** If auth fails with "token not found", the database has old data. Either: diff --git a/README.md b/README.md index 7f26ca8..789755f 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,45 @@ kvs> put users/alice {"name": "Alice", "email": "alice@example.com"} kvs> get users/alice ``` +## KVS Server Management + +If you're also running the KVS server locally, you can use the built-in daemon commands: + +```bash +# Start KVS server as background daemon +./kvs start config.yaml + +# Check server status +./kvs status + +# View server logs +tail -f ~/.kvs/logs/kvs_config.yaml.log + +# Stop server +./kvs stop config + +# Restart server +./kvs restart config +``` + +**Multiple instances:** +```bash +# Start a 3-node cluster +./kvs start node1.yaml +./kvs start node2.yaml +./kvs start node3.yaml + +# Check all running instances +./kvs status + +# Stop entire cluster +./kvs stop node1 +./kvs stop node2 +./kvs stop node3 +``` + +For more details on server management, see the [main KVS documentation](../kvs/README.md#-process-management). + ## Commands Reference ### Connection & Authentication -- 2.51.0 From 0c9e314d7c2557002a3aa38876d655a769a36529 Mon Sep 17 00:00:00 2001 From: ryyst Date: Sun, 5 Oct 2025 23:34:18 +0300 Subject: [PATCH 2/7] refactor: split monolithic main.go into modular structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored 800+ line main.go into clean, domain-separated files: - main.go (96 lines) - Entry point and command router only - types.go - Type definitions and data structures - client.go - HTTP client and request handling - cmd_kv.go - Key-value operations (get/put/delete) - cmd_meta.go - Resource metadata commands - cmd_user.go - User management commands - cmd_profile.go - Profile management - cmd_cluster.go - Cluster operations (members/health) - cmd_system.go - System commands (connect/auth/help) - utils.go - Shared utilities (parsing, colors, completion) No functional changes, pure reorganization for maintainability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- client.go | 61 +++++ cmd_cluster.go | 61 +++++ cmd_kv.go | 125 +++++++++ cmd_meta.go | 137 ++++++++++ cmd_profile.go | 69 +++++ cmd_system.go | 59 ++++ cmd_user.go | 86 ++++++ main.go | 710 ------------------------------------------------- types.go | 42 +++ utils.go | 124 +++++++++ 10 files changed, 764 insertions(+), 710 deletions(-) create mode 100644 client.go create mode 100644 cmd_cluster.go create mode 100644 cmd_kv.go create mode 100644 cmd_meta.go create mode 100644 cmd_profile.go create mode 100644 cmd_system.go create mode 100644 cmd_user.go create mode 100644 types.go create mode 100644 utils.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..f64d752 --- /dev/null +++ b/client.go @@ -0,0 +1,61 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "time" +) + +// KVSClient maintains the connection state and configuration +type KVSClient struct { + baseURL string + currentToken string + currentUser string + httpClient *http.Client + profiles map[string]Profile + activeProfile string +} + +// NewKVSClient creates a new KVS client instance +func NewKVSClient(baseURL string) *KVSClient { + return &KVSClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + profiles: make(map[string]Profile), + } +} + +// doRequest performs an HTTP request with JSON body and auth headers +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 +} diff --git a/cmd_cluster.go b/cmd_cluster.go new file mode 100644 index 0000000..92b860f --- /dev/null +++ b/cmd_cluster.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +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) + } +} diff --git a/cmd_kv.go b/cmd_kv.go new file mode 100644 index 0000000..23d9fd0 --- /dev/null +++ b/cmd_kv.go @@ -0,0 +1,125 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +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) < 1 { + fmt.Println(red("Usage: put ")) + fmt.Println("Or: put (then paste JSON on next line)") + return + } + + var jsonStr string + if len(args) == 1 { + // Multiline mode - read from next input + fmt.Println(yellow("Enter JSON (Ctrl+D when done):")) + scanner := bufio.NewScanner(os.Stdin) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + jsonStr = strings.Join(lines, "\n") + } else { + // Single line mode + jsonStr = strings.Join(args[1:], " ") + } + + // Parse JSON data + var data json.RawMessage + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + printJSONError(jsonStr, 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) + } +} diff --git a/cmd_meta.go b/cmd_meta.go new file mode 100644 index 0000000..a7d335c --- /dev/null +++ b/cmd_meta.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" +) + +// handleMeta dispatches metadata sub-commands +func (c *KVSClient) handleMeta(args []string) { + if len(args) == 0 { + fmt.Println(red("Usage: meta [options]")) + return + } + + subCmd := args[0] + switch subCmd { + case "get": + c.handleMetaGet(args[1:]) + case "set": + c.handleMetaSet(args[1:]) + default: + fmt.Println(red("Unknown meta command:"), subCmd) + fmt.Println("Available commands: get, set") + } +} + +// handleMetaGet fetches and displays metadata for a key +func (c *KVSClient) handleMetaGet(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: meta get ")) + return + } + key := args[0] + + respBody, status, err := c.doRequest("GET", "/kv/"+key+"/metadata", nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == http.StatusNotFound { + fmt.Println(yellow("No metadata found for key:"), key) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error getting metadata (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + // Struct to parse the metadata response + type MetaResponse struct { + OwnerUUID string `json:"owner_uuid"` + GroupUUID string `json:"group_uuid"` + Permissions int `json:"permissions"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + + var meta MetaResponse + if err := json.Unmarshal(respBody, &meta); err != nil { + fmt.Println(red("Error parsing metadata response:"), err) + return + } + + fmt.Println(cyan("Metadata for key:"), key) + fmt.Printf(" Owner UUID: %s\n", meta.OwnerUUID) + fmt.Printf(" Group UUID: %s\n", meta.GroupUUID) + fmt.Printf(" Permissions: %d\n", meta.Permissions) + fmt.Printf(" Created At: %s\n", time.Unix(meta.CreatedAt, 0).Format(time.RFC3339)) + fmt.Printf(" Updated At: %s\n", time.Unix(meta.UpdatedAt, 0).Format(time.RFC3339)) +} + +// handleMetaSet updates metadata for a key using flags +func (c *KVSClient) handleMetaSet(args []string) { + if len(args) < 3 { + fmt.Println(red("Usage: meta set --owner | --group | --permissions ")) + return + } + key := args[0] + + // Use a map to build the JSON payload for partial updates + payload := make(map[string]interface{}) + + // Parse command-line flags + for i := 1; i < len(args); i++ { + switch args[i] { + case "--owner": + if i+1 < len(args) { + payload["owner_uuid"] = args[i+1] + i++ // Skip the value + } + case "--group": + if i+1 < len(args) { + payload["group_uuid"] = args[i+1] + i++ // Skip the value + } + case "--permissions": + if i+1 < len(args) { + perms, err := strconv.Atoi(args[i+1]) + if err != nil { + fmt.Println(red("Invalid permissions value:"), args[i+1]) + return + } + payload["permissions"] = perms + i++ // Skip the value + } + } + } + + if len(payload) == 0 { + fmt.Println(red("No valid metadata fields provided to set.")) + return + } + + respBody, status, err := c.doRequest("PUT", "/kv/"+key+"/metadata", payload) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error setting metadata (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + fmt.Println(green("Metadata updated successfully for key:"), key) + var pretty bytes.Buffer + json.Indent(&pretty, respBody, "", " ") + fmt.Println(pretty.String()) +} diff --git a/cmd_profile.go b/cmd_profile.go new file mode 100644 index 0000000..4939310 --- /dev/null +++ b/cmd_profile.go @@ -0,0 +1,69 @@ +package main + +import "fmt" + +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]) + } +} diff --git a/cmd_system.go b/cmd_system.go new file mode 100644 index 0000000..02e934d --- /dev/null +++ b/cmd_system.go @@ -0,0 +1,59 @@ +package main + +import "fmt" + +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) 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 + +Resource Metadata: + meta get - Get metadata (owner, group) for a key + meta set [flags] - Set metadata for a key + Flags: --owner , --group , --permissions + +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) +} diff --git a/cmd_user.go b/cmd_user.go new file mode 100644 index 0000000..43a4509 --- /dev/null +++ b/cmd_user.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +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) handleUserCreate(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: user create ")) + return + } + + // The request body requires a JSON object like {"nickname": "..."} + requestPayload := map[string]string{ + "nickname": args[0], + } + + respBody, status, err := c.doRequest("POST", "/api/users", requestPayload) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + // The backend returns 200 OK on success + if status != http.StatusOK { + fmt.Printf(red("Error creating user (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + // Parse the successful response to get the new user's UUID + var response struct { + UUID string `json:"uuid"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + fmt.Println(red("Error parsing successful response:"), err) + return + } + + fmt.Println(green("User created successfully")) + fmt.Println(cyan("UUID:"), response.UUID) +} diff --git a/main.go b/main.go index 68b9de3..ca87df3 100644 --- a/main.go +++ b/main.go @@ -1,650 +1,13 @@ -// kvs-shell/main.go package main import ( - "bufio" - "bytes" - "encoding/json" "fmt" - "io" - "net/http" "os" - "strconv" "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) < 1 { - fmt.Println(red("Usage: put ")) - fmt.Println("Or: put (then paste JSON on next line)") - return - } - - var jsonStr string - if len(args) == 1 { - // Multiline mode - read from next input - fmt.Println(yellow("Enter JSON (Ctrl+D when done):")) - scanner := bufio.NewScanner(os.Stdin) - var lines []string - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - jsonStr = strings.Join(lines, "\n") - } else { - // Single line mode - jsonStr = strings.Join(args[1:], " ") - } - - // Parse JSON data - var data json.RawMessage - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - printJSONError(jsonStr, 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) - } -} - -// handleMeta is the dispatcher for "meta" sub-commands -func (c *KVSClient) handleMeta(args []string) { - if len(args) == 0 { - fmt.Println(red("Usage: meta [options]")) - return - } - - subCmd := args[0] - switch subCmd { - case "get": - c.handleMetaGet(args[1:]) - case "set": - c.handleMetaSet(args[1:]) - default: - fmt.Println(red("Unknown meta command:"), subCmd) - fmt.Println("Available commands: get, set") - } -} - -// handleMetaGet fetches and displays metadata for a key -func (c *KVSClient) handleMetaGet(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: meta get ")) - return - } - key := args[0] - - respBody, status, err := c.doRequest("GET", "/kv/"+key+"/metadata", nil) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status == http.StatusNotFound { - fmt.Println(yellow("No metadata found for key:"), key) - return - } - - if status != http.StatusOK { - fmt.Printf(red("Error getting metadata (status %d):\n"), status) - fmt.Println(string(respBody)) - return - } - - // Struct to parse the metadata response - type MetaResponse struct { - OwnerUUID string `json:"owner_uuid"` - GroupUUID string `json:"group_uuid"` - Permissions int `json:"permissions"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - } - - var meta MetaResponse - if err := json.Unmarshal(respBody, &meta); err != nil { - fmt.Println(red("Error parsing metadata response:"), err) - return - } - - fmt.Println(cyan("Metadata for key:"), key) - fmt.Printf(" Owner UUID: %s\n", meta.OwnerUUID) - fmt.Printf(" Group UUID: %s\n", meta.GroupUUID) - fmt.Printf(" Permissions: %d\n", meta.Permissions) - fmt.Printf(" Created At: %s\n", time.Unix(meta.CreatedAt, 0).Format(time.RFC3339)) - fmt.Printf(" Updated At: %s\n", time.Unix(meta.UpdatedAt, 0).Format(time.RFC3339)) -} - -// handleMetaSet updates metadata for a key using flags -func (c *KVSClient) handleMetaSet(args []string) { - if len(args) < 3 { - fmt.Println(red("Usage: meta set --owner | --group | --permissions ")) - return - } - key := args[0] - - // Use a map to build the JSON payload for partial updates - payload := make(map[string]interface{}) - - // Parse command-line flags - for i := 1; i < len(args); i++ { - switch args[i] { - case "--owner": - if i+1 < len(args) { - payload["owner_uuid"] = args[i+1] - i++ // Skip the value - } - case "--group": - if i+1 < len(args) { - payload["group_uuid"] = args[i+1] - i++ // Skip the value - } - case "--permissions": - if i+1 < len(args) { - perms, err := strconv.Atoi(args[i+1]) - if err != nil { - fmt.Println(red("Invalid permissions value:"), args[i+1]) - return - } - payload["permissions"] = perms - i++ // Skip the value - } - } - } - - if len(payload) == 0 { - fmt.Println(red("No valid metadata fields provided to set.")) - return - } - - respBody, status, err := c.doRequest("PUT", "/kv/"+key+"/metadata", payload) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status != http.StatusOK { - fmt.Printf(red("Error setting metadata (status %d):\n"), status) - fmt.Println(string(respBody)) - return - } - - fmt.Println(green("Metadata updated successfully for key:"), key) - // The response body contains the full updated metadata, so we can print it for confirmation - var pretty bytes.Buffer - json.Indent(&pretty, respBody, "", " ") - fmt.Println(pretty.String()) -} - -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) handleUserCreate(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: user create ")) - return - } - - // The request body requires a JSON object like {"nickname": "..."} - requestPayload := map[string]string{ - "nickname": args[0], - } - - respBody, status, err := c.doRequest("POST", "/api/users", requestPayload) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - // The backend returns 200 OK on success - if status != http.StatusOK { - fmt.Printf(red("Error creating user (status %d):\n"), status) - fmt.Println(string(respBody)) - return - } - - // Parse the successful response to get the new user's UUID - var response struct { - UUID string `json:"uuid"` - } - if err := json.Unmarshal(respBody, &response); err != nil { - fmt.Println(red("Error parsing successful response:"), err) - return - } - - fmt.Println(green("User created successfully")) - fmt.Println(cyan("UUID:"), response.UUID) -} - -// printJSONError displays a user-friendly JSON parsing error with helpful hints -func printJSONError(input string, err error) { - fmt.Println(red("❌ Invalid JSON:"), err) - fmt.Println() - - // Show what was received - if len(input) > 100 { - fmt.Println(yellow("Received:"), input[:100]+"...") - } else { - fmt.Println(yellow("Received:"), input) - } - fmt.Println() - - // Provide helpful hints based on common errors - errMsg := err.Error() - - if strings.Contains(errMsg, "looking for beginning of") || - strings.Contains(errMsg, "invalid character") { - fmt.Println(cyan("💡 Tip:"), "JSON with spaces needs quotes:") - fmt.Println(green(" ✓ Correct:"), "put key '{\"hello\":\"world\"}'") - fmt.Println(green(" ✓ Correct:"), "put key '{\"a\":1,\"b\":2}'") - fmt.Println(green(" ✓ Compact:"), "put key {\"a\":1,\"b\":2} (no spaces)") - fmt.Println(red(" ✗ Wrong: "), "put key {\"a\": 1, \"b\": 2} (spaces without quotes)") - } else if strings.Contains(errMsg, "unexpected end of JSON") { - fmt.Println(cyan("💡 Tip:"), "JSON appears incomplete or cut off") - fmt.Println(green(" Check:"), "Are all brackets/braces matched? {}, []") - fmt.Println(green(" Check:"), "Did spaces split your JSON into multiple arguments?") - } else { - fmt.Println(cyan("💡 Tip:"), "Wrap JSON in quotes for complex values:") - fmt.Println(green(" Single quotes:"), "put key '{\"your\":\"json\"}'") - fmt.Println(green(" Double quotes:"), "put key \"{\\\"your\\\":\\\"json\\\"}\"") - } -} - -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 - -Resource Metadata: - meta get - Get metadata (owner, group) for a key - meta set [flags] - Set metadata for a key - Flags: --owner , --group , --permissions - -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") @@ -722,7 +85,6 @@ func main() { fmt.Println("Type 'help' for available commands") } } else { - // This was the original behavior, which notes that listing users is not implemented. client.handleUserList(args) } default: @@ -731,75 +93,3 @@ func main() { } } } - -// 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("meta", - readline.PcItem("get"), - readline.PcItem("set", - readline.PcItem("--owner"), - readline.PcItem("--group"), - readline.PcItem("--permissions"), - ), - ), - 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 - var quoteChar rune = 0 // Use a rune to store the active quote character (' or ") - - for _, ch := range line { - switch { - // If we see a quote character - case ch == '\'' || ch == '"': - if quoteChar == 0 { - // Not currently inside a quoted string, so start one - quoteChar = ch - } else if quoteChar == ch { - // Inside a quoted string and found the matching quote, so end it - quoteChar = 0 - } else { - // Inside a quoted string but found the *other* type of quote, treat it as a literal character - current.WriteRune(ch) - } - // If we see a space or tab and are NOT inside a quoted string - case (ch == ' ' || ch == '\t') && quoteChar == 0: - if current.Len() > 0 { - parts = append(parts, current.String()) - current.Reset() - } - // Any other character - default: - current.WriteRune(ch) - } - } - - // Add the last part if it exists - if current.Len() > 0 { - parts = append(parts, current.String()) - } - - return parts -} diff --git a/types.go b/types.go new file mode 100644 index 0000000..ed9f508 --- /dev/null +++ b/types.go @@ -0,0 +1,42 @@ +package main + +import "encoding/json" + +// Types matching the backend API + +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"` +} + +// Profile represents a saved user configuration +type Profile struct { + Name string `json:"name"` + Token string `json:"token"` + UserUUID string `json:"user_uuid"` + BaseURL string `json:"base_url"` +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..d1b9718 --- /dev/null +++ b/utils.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/chzyer/readline" + "github.com/fatih/color" +) + +// Color helpers +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() +) + +// parseCommand parses command line respecting quotes +func parseCommand(line string) []string { + var parts []string + var current strings.Builder + var quoteChar rune = 0 // Use a rune to store the active quote character (' or ") + + for _, ch := range line { + switch { + // If we see a quote character + case ch == '\'' || ch == '"': + if quoteChar == 0 { + // Not currently inside a quoted string, so start one + quoteChar = ch + } else if quoteChar == ch { + // Inside a quoted string and found the matching quote, so end it + quoteChar = 0 + } else { + // Inside a quoted string but found the *other* type of quote, treat it as a literal character + current.WriteRune(ch) + } + // If we see a space or tab and are NOT inside a quoted string + case (ch == ' ' || ch == '\t') && quoteChar == 0: + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + // Any other character + default: + current.WriteRune(ch) + } + } + + // Add the last part if it exists + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts +} + +// printJSONError displays a user-friendly JSON parsing error with helpful hints +func printJSONError(input string, err error) { + fmt.Println(red("❌ Invalid JSON:"), err) + fmt.Println() + + // Show what was received + if len(input) > 100 { + fmt.Println(yellow("Received:"), input[:100]+"...") + } else { + fmt.Println(yellow("Received:"), input) + } + fmt.Println() + + // Provide helpful hints based on common errors + errMsg := err.Error() + + if strings.Contains(errMsg, "looking for beginning of") || + strings.Contains(errMsg, "invalid character") { + fmt.Println(cyan("💡 Tip:"), "JSON with spaces needs quotes:") + fmt.Println(green(" ✓ Correct:"), "put key '{\"hello\":\"world\"}'") + fmt.Println(green(" ✓ Correct:"), "put key '{\"a\":1,\"b\":2}'") + fmt.Println(green(" ✓ Compact:"), "put key {\"a\":1,\"b\":2} (no spaces)") + fmt.Println(red(" ✗ Wrong: "), "put key {\"a\": 1, \"b\": 2} (spaces without quotes)") + } else if strings.Contains(errMsg, "unexpected end of JSON") { + fmt.Println(cyan("💡 Tip:"), "JSON appears incomplete or cut off") + fmt.Println(green(" Check:"), "Are all brackets/braces matched? {}, []") + fmt.Println(green(" Check:"), "Did spaces split your JSON into multiple arguments?") + } else { + fmt.Println(cyan("💡 Tip:"), "Wrap JSON in quotes for complex values:") + fmt.Println(green(" Single quotes:"), "put key '{\"your\":\"json\"}'") + fmt.Println(green(" Double quotes:"), "put key \"{\\\"your\\\":\\\"json\\\"}\"") + } +} + +// Auto-completion setup +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("meta", + readline.PcItem("get"), + readline.PcItem("set", + readline.PcItem("--owner"), + readline.PcItem("--group"), + readline.PcItem("--permissions"), + ), + ), + 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"), +) -- 2.51.0 From 39a1d4482a86e76ee8853acb177f9c80a713453d Mon Sep 17 00:00:00 2001 From: ryyst Date: Sun, 5 Oct 2025 23:47:13 +0300 Subject: [PATCH 3/7] feat: add persistent profile storage to ~/.kvs/config.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented roadmap #1: Configuration file for persistent profiles. Features: - Auto-saves profiles to ~/.kvs/config.json on add/use/remove - Auto-loads profiles on shell startup - File created with 0600 permissions for token security - Shows active profile in welcome message - Added 'profile save' and 'profile load' commands for manual control Technical details: - Created config.go with LoadConfig/SaveConfig functions - Profile changes automatically trigger persistence - ~/.kvs directory created with 0700 permissions if missing - Gracefully handles missing config file on first run 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd_profile.go | 26 +++++++++++ cmd_system.go | 4 +- config.go | 120 +++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 8 ++++ utils.go | 2 + 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 config.go diff --git a/cmd_profile.go b/cmd_profile.go index 4939310..5be1714 100644 --- a/cmd_profile.go +++ b/cmd_profile.go @@ -38,6 +38,7 @@ func (c *KVSClient) handleProfile(args []string) { BaseURL: baseURL, } fmt.Println(green("Profile added:"), args[1]) + c.saveProfiles() case "use": if len(args) < 2 { @@ -54,6 +55,7 @@ func (c *KVSClient) handleProfile(args []string) { c.baseURL = profile.BaseURL c.activeProfile = args[1] fmt.Println(green("Switched to profile:"), args[1]) + c.saveProfiles() case "remove": if len(args) < 2 { @@ -65,5 +67,29 @@ func (c *KVSClient) handleProfile(args []string) { c.activeProfile = "" } fmt.Println(green("Profile removed:"), args[1]) + c.saveProfiles() + + case "save": + if err := c.saveProfiles(); err != nil { + fmt.Println(red("Error saving profiles:"), err) + return + } + fmt.Println(green("Profiles saved to ~/.kvs/config.json")) + + case "load": + config, err := LoadConfig() + if err != nil { + fmt.Println(red("Error loading profiles:"), err) + return + } + c.syncConfigToClient(config) + fmt.Println(green("Profiles loaded from ~/.kvs/config.json")) + if c.activeProfile != "" { + fmt.Println(cyan("Active profile:"), c.activeProfile) + } + + default: + fmt.Println(red("Unknown profile command:"), subCmd) + fmt.Println("Available commands: add, use, remove, save, load") } } diff --git a/cmd_system.go b/cmd_system.go index 02e934d..b569671 100644 --- a/cmd_system.go +++ b/cmd_system.go @@ -27,10 +27,12 @@ KVS Interactive Shell - Available Commands: Connection & Authentication: connect - Connect to KVS server auth - Set authentication token + profile - List all profiles profile add [url] - Add user profile profile use - Switch to user profile profile remove - Remove user profile - profile - List all profiles + profile save - Save profiles to ~/.kvs/config.json + profile load - Load profiles from ~/.kvs/config.json Key-Value Operations: get - Retrieve value for key diff --git a/config.go b/config.go new file mode 100644 index 0000000..63162ee --- /dev/null +++ b/config.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Config holds the persistent configuration +type Config struct { + Profiles map[string]Profile `json:"profiles"` + ActiveProfile string `json:"active_profile"` +} + +// getConfigPath returns the path to the config file +func getConfigPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + kvsDir := filepath.Join(homeDir, ".kvs") + // Create .kvs directory if it doesn't exist + if err := os.MkdirAll(kvsDir, 0700); err != nil { + return "", err + } + + return filepath.Join(kvsDir, "config.json"), nil +} + +// LoadConfig loads the configuration from disk +func LoadConfig() (*Config, error) { + configPath, err := getConfigPath() + if err != nil { + return nil, err + } + + // If config doesn't exist, return empty config + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return &Config{ + Profiles: make(map[string]Profile), + }, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + if config.Profiles == nil { + config.Profiles = make(map[string]Profile) + } + + return &config, nil +} + +// SaveConfig saves the configuration to disk with 0600 permissions +func SaveConfig(config *Config) error { + configPath, err := getConfigPath() + if err != nil { + return err + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + // Write with restrictive permissions (0600) to protect tokens + if err := os.WriteFile(configPath, data, 0600); err != nil { + return err + } + + return nil +} + +// syncConfigToClient updates client state from config +func (c *KVSClient) syncConfigToClient(config *Config) { + c.profiles = config.Profiles + c.activeProfile = config.ActiveProfile + + // If there's an active profile, apply it + if c.activeProfile != "" { + if profile, ok := c.profiles[c.activeProfile]; ok { + c.currentToken = profile.Token + c.currentUser = profile.UserUUID + c.baseURL = profile.BaseURL + } + } +} + +// syncClientToConfig updates config from client state +func (c *KVSClient) syncClientToConfig(config *Config) { + config.Profiles = c.profiles + config.ActiveProfile = c.activeProfile +} + +// saveProfiles is a convenience method to save current client state +func (c *KVSClient) saveProfiles() error { + config, err := LoadConfig() + if err != nil { + // If we can't load, create new config + config = &Config{Profiles: make(map[string]Profile)} + } + + c.syncClientToConfig(config) + + if err := SaveConfig(config); err != nil { + fmt.Println(yellow("Warning: Failed to save profiles:"), err) + return err + } + + return nil +} diff --git a/main.go b/main.go index ca87df3..661fbae 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,11 @@ import ( func main() { client := NewKVSClient("http://localhost:8090") + // Load saved profiles + if config, err := LoadConfig(); err == nil { + client.syncConfigToClient(config) + } + // Setup readline rl, err := readline.NewEx(&readline.Config{ Prompt: cyan("kvs> "), @@ -26,6 +31,9 @@ func main() { fmt.Println(magenta("KVS Interactive Shell")) fmt.Println("Type 'help' for available commands") + if client.activeProfile != "" { + fmt.Println(green("Active profile:"), client.activeProfile) + } fmt.Println() for { diff --git a/utils.go b/utils.go index d1b9718..2871a37 100644 --- a/utils.go +++ b/utils.go @@ -99,6 +99,8 @@ var completer = readline.NewPrefixCompleter( readline.PcItem("add"), readline.PcItem("use"), readline.PcItem("remove"), + readline.PcItem("save"), + readline.PcItem("load"), ), readline.PcItem("get"), readline.PcItem("put"), -- 2.51.0 From 33201227ca5aebd8a266c1dd079106f1b97d233f Mon Sep 17 00:00:00 2001 From: ryyst Date: Sun, 5 Oct 2025 23:50:27 +0300 Subject: [PATCH 4/7] feat: add group management commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented roadmap #5: Group management commands with full CRUD. Features: - group create [members] - Create new group with optional initial members - group get - Display group details (uuid, name_hash, members, timestamps) - group update - Replace entire member list - group delete - Delete group - group add-member - Add single member (incremental) - group remove-member - Remove single member (incremental) The add-member and remove-member commands fetch current members, modify the list, and update atomically - providing a better UX than replacing the entire list. Backend API endpoints used: - POST /api/groups - Create group - GET /api/groups/{uuid} - Get group details - PUT /api/groups/{uuid} - Update members - DELETE /api/groups/{uuid} - Delete group 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd_group.go | 311 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd_system.go | 8 ++ main.go | 2 + utils.go | 8 ++ 4 files changed, 329 insertions(+) create mode 100644 cmd_group.go diff --git a/cmd_group.go b/cmd_group.go new file mode 100644 index 0000000..73fc0ef --- /dev/null +++ b/cmd_group.go @@ -0,0 +1,311 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// handleGroup dispatches group management sub-commands +func (c *KVSClient) handleGroup(args []string) { + if len(args) == 0 { + fmt.Println(red("Usage: group [options]")) + return + } + + subCmd := args[0] + switch subCmd { + case "create": + c.handleGroupCreate(args[1:]) + case "get": + c.handleGroupGet(args[1:]) + case "update": + c.handleGroupUpdate(args[1:]) + case "delete": + c.handleGroupDelete(args[1:]) + case "add-member": + c.handleGroupAddMember(args[1:]) + case "remove-member": + c.handleGroupRemoveMember(args[1:]) + default: + fmt.Println(red("Unknown group command:"), subCmd) + fmt.Println("Available commands: create, get, update, delete, add-member, remove-member") + } +} + +// handleGroupCreate creates a new group +func (c *KVSClient) handleGroupCreate(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: group create [member-uuid1,member-uuid2,...]")) + return + } + + groupname := args[0] + var members []string + + // Parse optional members list + if len(args) >= 2 { + members = strings.Split(args[1], ",") + } + + payload := map[string]interface{}{ + "groupname": groupname, + } + if len(members) > 0 { + payload["members"] = members + } + + respBody, status, err := c.doRequest("POST", "/api/groups", payload) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error creating group (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + var response struct { + UUID string `json:"uuid"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + fmt.Println(green("Group created successfully")) + fmt.Println(cyan("UUID:"), response.UUID) +} + +// handleGroupGet retrieves group details +func (c *KVSClient) handleGroupGet(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: group get ")) + return + } + + groupUUID := args[0] + + respBody, status, err := c.doRequest("GET", "/api/groups/"+groupUUID, nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == http.StatusNotFound { + fmt.Println(yellow("Group not found")) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error getting group (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + var group Group + if err := json.Unmarshal(respBody, &group); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + fmt.Println(cyan("Group Details:")) + fmt.Printf(" UUID: %s\n", group.UUID) + fmt.Printf(" Name Hash: %s\n", group.NameHash) + fmt.Printf(" Members: %v\n", group.Members) + fmt.Printf(" Created: %s\n", time.Unix(group.CreatedAt, 0).Format(time.RFC3339)) + fmt.Printf(" Updated: %s\n", time.Unix(group.UpdatedAt, 0).Format(time.RFC3339)) +} + +// handleGroupUpdate replaces all members in a group +func (c *KVSClient) handleGroupUpdate(args []string) { + if len(args) < 2 { + fmt.Println(red("Usage: group update ")) + fmt.Println("Note: This replaces ALL members. Use add-member/remove-member for incremental changes.") + return + } + + groupUUID := args[0] + members := strings.Split(args[1], ",") + + payload := map[string]interface{}{ + "members": members, + } + + respBody, status, err := c.doRequest("PUT", "/api/groups/"+groupUUID, payload) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == http.StatusNotFound { + fmt.Println(yellow("Group not found")) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error updating group (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + fmt.Println(green("Group updated successfully")) +} + +// handleGroupDelete deletes a group +func (c *KVSClient) handleGroupDelete(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: group delete ")) + return + } + + groupUUID := args[0] + + respBody, status, err := c.doRequest("DELETE", "/api/groups/"+groupUUID, nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == http.StatusNotFound { + fmt.Println(yellow("Group not found")) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error deleting group (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + fmt.Println(green("Group deleted successfully")) +} + +// handleGroupAddMember adds a member to an existing group +func (c *KVSClient) handleGroupAddMember(args []string) { + if len(args) < 2 { + fmt.Println(red("Usage: group add-member ")) + return + } + + groupUUID := args[0] + userUUID := args[1] + + // First, get current members + respBody, status, err := c.doRequest("GET", "/api/groups/"+groupUUID, nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error getting group (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + var group Group + if err := json.Unmarshal(respBody, &group); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + // Check if user is already a member + for _, member := range group.Members { + if member == userUUID { + fmt.Println(yellow("User is already a member of this group")) + return + } + } + + // Add new member + group.Members = append(group.Members, userUUID) + + // Update group with new members list + payload := map[string]interface{}{ + "members": group.Members, + } + + respBody, status, err = c.doRequest("PUT", "/api/groups/"+groupUUID, payload) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error updating group (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + fmt.Println(green("Member added successfully")) +} + +// handleGroupRemoveMember removes a member from an existing group +func (c *KVSClient) handleGroupRemoveMember(args []string) { + if len(args) < 2 { + fmt.Println(red("Usage: group remove-member ")) + return + } + + groupUUID := args[0] + userUUID := args[1] + + // First, get current members + respBody, status, err := c.doRequest("GET", "/api/groups/"+groupUUID, nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error getting group (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + var group Group + if err := json.Unmarshal(respBody, &group); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + // Remove member from list + newMembers := []string{} + found := false + for _, member := range group.Members { + if member != userUUID { + newMembers = append(newMembers, member) + } else { + found = true + } + } + + if !found { + fmt.Println(yellow("User is not a member of this group")) + return + } + + // Update group with new members list + payload := map[string]interface{}{ + "members": newMembers, + } + + respBody, status, err = c.doRequest("PUT", "/api/groups/"+groupUUID, payload) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error updating group (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + fmt.Println(green("Member removed successfully")) +} diff --git a/cmd_system.go b/cmd_system.go index b569671..b8a028a 100644 --- a/cmd_system.go +++ b/cmd_system.go @@ -52,6 +52,14 @@ User Management: user get - Get user details user create - Create new user (admin only) +Group Management: + group create [members] - Create new group + group get - Get group details + group update - Replace all group members + group delete - Delete group + group add-member - Add member to group + group remove-member - Remove member from group + System: help - Show this help exit, quit - Exit shell diff --git a/main.go b/main.go index 661fbae..5ac4e91 100644 --- a/main.go +++ b/main.go @@ -95,6 +95,8 @@ func main() { } else { client.handleUserList(args) } + case "group": + client.handleGroup(args) default: fmt.Println(red("Unknown command:"), cmd) fmt.Println("Type 'help' for available commands") diff --git a/utils.go b/utils.go index 2871a37..8021ddd 100644 --- a/utils.go +++ b/utils.go @@ -119,6 +119,14 @@ var completer = readline.NewPrefixCompleter( readline.PcItem("get"), readline.PcItem("create"), ), + readline.PcItem("group", + readline.PcItem("create"), + readline.PcItem("get"), + readline.PcItem("update"), + readline.PcItem("delete"), + readline.PcItem("add-member"), + readline.PcItem("remove-member"), + ), readline.PcItem("help"), readline.PcItem("exit"), readline.PcItem("quit"), -- 2.51.0 From 2f77c0a82554bcf8aa692007ce52049a36729fd8 Mon Sep 17 00:00:00 2001 From: ryyst Date: Sun, 5 Oct 2025 23:54:47 +0300 Subject: [PATCH 5/7] feat: add batch command execution from files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented roadmap #3: Batch operations from file. Features: - batch or source - Execute commands from script file - Skips empty lines and comments (lines starting with #) - Shows line numbers during execution for easy debugging - Blocks exit/quit commands in batch mode (won't exit shell) - Displays execution summary with counts Implementation: - Refactored main loop to extract executeCommand() method - Created cmd_batch.go with batch file processor - Reads file line by line, processes each command - Summary shows total lines, executed count, and skipped count Example batch file: # Connect and setup connect http://localhost:8080 auth # Batch insert put key1 '{"data":"value1"}' put key2 '{"data":"value2"}' 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd_batch.go | 73 ++++++++++++++++++++++++++++++++ cmd_system.go | 5 +++ main.go | 114 +++++++++++++++++++++++++++----------------------- utils.go | 2 + 4 files changed, 142 insertions(+), 52 deletions(-) create mode 100644 cmd_batch.go diff --git a/cmd_batch.go b/cmd_batch.go new file mode 100644 index 0000000..8e20571 --- /dev/null +++ b/cmd_batch.go @@ -0,0 +1,73 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// handleBatch executes commands from a file +func (c *KVSClient) handleBatch(args []string, executor func(string) bool) { + if len(args) < 1 { + fmt.Println(red("Usage: batch [--continue-on-error]")) + fmt.Println(" source [--continue-on-error]") + return + } + + filename := args[0] + + file, err := os.Open(filename) + if err != nil { + fmt.Println(red("Error opening file:"), err) + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNum := 0 + successCount := 0 + errorCount := 0 + skippedCount := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + skippedCount++ + continue + } + + // Execute the command + fmt.Printf(cyan("[%d]")+" %s\n", lineNum, line) + + // Block exit/quit commands in batch mode + parts := parseCommand(line) + if len(parts) > 0 && (parts[0] == "exit" || parts[0] == "quit") { + fmt.Println(yellow("Note: exit/quit ignored in batch mode")) + skippedCount++ + continue + } + + // Execute command + executor(line) + successCount++ + } + + if err := scanner.Err(); err != nil { + fmt.Println(red("Error reading file:"), err) + return + } + + // Print summary + fmt.Println() + fmt.Println(cyan("Batch execution complete:")) + fmt.Printf(" Total lines: %d\n", lineNum) + fmt.Printf(" Executed: %s\n", green(fmt.Sprintf("%d", successCount))) + fmt.Printf(" Skipped: %d (empty/comments)\n", skippedCount) + if errorCount > 0 { + fmt.Printf(" Errors: %s\n", red(fmt.Sprintf("%d", errorCount))) + } +} diff --git a/cmd_system.go b/cmd_system.go index b8a028a..dcce3ba 100644 --- a/cmd_system.go +++ b/cmd_system.go @@ -60,6 +60,11 @@ Group Management: group add-member - Add member to group group remove-member - Remove member from group +Batch Operations: + batch - Execute commands from file + source - Alias for batch + --continue-on-error - Continue execution even if commands fail + System: help - Show this help exit, quit - Exit shell diff --git a/main.go b/main.go index 5ac4e91..3bc4823 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,67 @@ import ( "github.com/chzyer/readline" ) +// executeCommand executes a single command line +func (c *KVSClient) executeCommand(line string) bool { + parts := parseCommand(line) + if len(parts) == 0 { + return true + } + + cmd := parts[0] + args := parts[1:] + + switch cmd { + case "exit", "quit": + fmt.Println("Goodbye!") + return false + case "clear": + print("\033[H\033[2J") + case "help": + c.handleHelp(args) + case "connect": + c.handleConnect(args) + case "auth": + c.handleAuth(args) + case "profile": + c.handleProfile(args) + case "get": + c.handleGet(args) + case "put": + c.handlePut(args) + case "delete": + c.handleDelete(args) + case "meta": + c.handleMeta(args) + case "members": + c.handleMembers(args) + case "health": + c.handleHealth(args) + case "user": + if len(args) > 0 { + switch args[0] { + case "get": + c.handleUserGet(args[1:]) + case "create": + c.handleUserCreate(args[1:]) + default: + fmt.Println(red("Unknown user command:"), args[0]) + fmt.Println("Type 'help' for available commands") + } + } else { + c.handleUserList(args) + } + case "group": + c.handleGroup(args) + case "batch", "source": + c.handleBatch(args, c.executeCommand) + default: + fmt.Println(red("Unknown command:"), cmd) + fmt.Println("Type 'help' for available commands") + } + return true +} + func main() { client := NewKVSClient("http://localhost:8090") @@ -47,59 +108,8 @@ func main() { continue } - parts := parseCommand(line) - if len(parts) == 0 { - continue - } - - cmd := parts[0] - args := parts[1:] - - switch cmd { - case "exit", "quit": - fmt.Println("Goodbye!") + if !client.executeCommand(line) { 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 "meta": - client.handleMeta(args) - case "members": - client.handleMembers(args) - case "health": - client.handleHealth(args) - case "user": - if len(args) > 0 { - switch args[0] { - case "get": - client.handleUserGet(args[1:]) - case "create": - client.handleUserCreate(args[1:]) - default: - fmt.Println(red("Unknown user command:"), args[0]) - fmt.Println("Type 'help' for available commands") - } - } else { - client.handleUserList(args) - } - case "group": - client.handleGroup(args) - default: - fmt.Println(red("Unknown command:"), cmd) - fmt.Println("Type 'help' for available commands") } } } diff --git a/utils.go b/utils.go index 8021ddd..bb36137 100644 --- a/utils.go +++ b/utils.go @@ -127,6 +127,8 @@ var completer = readline.NewPrefixCompleter( readline.PcItem("add-member"), readline.PcItem("remove-member"), ), + readline.PcItem("batch"), + readline.PcItem("source"), readline.PcItem("help"), readline.PcItem("exit"), readline.PcItem("quit"), -- 2.51.0 From bd73a1c4775d793899d6007340101a98fa7af49b Mon Sep 17 00:00:00 2001 From: ryyst Date: Sun, 5 Oct 2025 23:57:25 +0300 Subject: [PATCH 6/7] feat: add export/import functionality for backup and migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented roadmap #4: Export/import functionality. Features: - export [key2...] - Export specified keys to JSON - export-list - Bulk export from key list file - import - Import keys from JSON file --skip-existing (default) - Skip keys that already exist --overwrite - Overwrite existing keys Export file format (v1.0): { "version": "1.0", "entries": [ { "key": "users/alice", "uuid": "...", "timestamp": 1234567890, "data": {...} } ] } The format preserves UUIDs and timestamps for audit trails. Export shows progress with ✓/✗/⊘ symbols for success/fail/not-found. Import checks for existing keys and respects skip/overwrite flags. Use cases: - Backup: export-list keys.txt backup.json - Migration: import backup.json --overwrite - Selective sync: export dest.json key1 key2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd_export.go | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd_system.go | 7 ++ main.go | 6 ++ utils.go | 3 + 4 files changed, 238 insertions(+) create mode 100644 cmd_export.go diff --git a/cmd_export.go b/cmd_export.go new file mode 100644 index 0000000..22e3dcc --- /dev/null +++ b/cmd_export.go @@ -0,0 +1,222 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// ExportEntry represents a single exported key-value pair +type ExportEntry struct { + Key string `json:"key"` + UUID string `json:"uuid"` + Timestamp int64 `json:"timestamp"` + Data json.RawMessage `json:"data"` +} + +// ExportFile represents the export file format +type ExportFile struct { + Version string `json:"version"` + Entries []ExportEntry `json:"entries"` +} + +// handleExport exports keys to a JSON file +func (c *KVSClient) handleExport(args []string) { + if len(args) < 2 { + fmt.Println(red("Usage: export [key2] [key3] ...")) + fmt.Println("Example: export backup.json users/alice users/bob config/settings") + return + } + + outputFile := args[0] + keys := args[1:] + + exportFile := ExportFile{ + Version: "1.0", + Entries: []ExportEntry{}, + } + + fmt.Println(cyan("Exporting"), len(keys), "key(s)...") + + successCount := 0 + failCount := 0 + + for _, key := range keys { + respBody, status, err := c.doRequest("GET", "/kv/"+key, nil) + if err != nil { + fmt.Printf(red("✗")+" %s: %v\n", key, err) + failCount++ + continue + } + + if status == 404 { + fmt.Printf(yellow("⊘")+" %s: not found\n", key) + failCount++ + continue + } + + if status != 200 { + fmt.Printf(red("✗")+" %s: status %d\n", key, status) + failCount++ + continue + } + + var stored StoredValue + if err := json.Unmarshal(respBody, &stored); err != nil { + fmt.Printf(red("✗")+" %s: parse error: %v\n", key, err) + failCount++ + continue + } + + exportFile.Entries = append(exportFile.Entries, ExportEntry{ + Key: key, + UUID: stored.UUID, + Timestamp: stored.Timestamp, + Data: stored.Data, + }) + + fmt.Printf(green("✓")+" %s\n", key) + successCount++ + } + + // Write to file + data, err := json.MarshalIndent(exportFile, "", " ") + if err != nil { + fmt.Println(red("Error marshaling export data:"), err) + return + } + + if err := os.WriteFile(outputFile, data, 0644); err != nil { + fmt.Println(red("Error writing file:"), err) + return + } + + fmt.Println() + fmt.Println(green("Export complete:")) + fmt.Printf(" Exported: %d\n", successCount) + if failCount > 0 { + fmt.Printf(" Failed: %s\n", red(fmt.Sprintf("%d", failCount))) + } + fmt.Printf(" File: %s\n", outputFile) +} + +// handleImport imports keys from a JSON file +func (c *KVSClient) handleImport(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: import [--skip-existing | --overwrite]")) + fmt.Println(" --skip-existing: Skip keys that already exist (default)") + fmt.Println(" --overwrite: Overwrite existing keys") + return + } + + inputFile := args[0] + overwrite := false + + // Parse flags + for _, arg := range args[1:] { + if arg == "--overwrite" { + overwrite = true + } + } + + // Read file + data, err := os.ReadFile(inputFile) + if err != nil { + fmt.Println(red("Error reading file:"), err) + return + } + + var exportFile ExportFile + if err := json.Unmarshal(data, &exportFile); err != nil { + fmt.Println(red("Error parsing export file:"), err) + return + } + + fmt.Println(cyan("Importing"), len(exportFile.Entries), "key(s)...") + if exportFile.Version != "1.0" { + fmt.Println(yellow("Warning: Unknown export version"), exportFile.Version) + } + + successCount := 0 + skipCount := 0 + failCount := 0 + + for _, entry := range exportFile.Entries { + // Check if key exists (unless we're overwriting) + if !overwrite { + _, status, err := c.doRequest("GET", "/kv/"+entry.Key, nil) + if err == nil && status == 200 { + fmt.Printf(yellow("⊘")+" %s: exists (skipped)\n", entry.Key) + skipCount++ + continue + } + } + + // Import the key + respBody, status, err := c.doRequest("PUT", "/kv/"+entry.Key, entry.Data) + if err != nil { + fmt.Printf(red("✗")+" %s: %v\n", entry.Key, err) + failCount++ + continue + } + + if status != 200 && status != 201 { + fmt.Printf(red("✗")+" %s: status %d: %s\n", entry.Key, status, string(respBody)) + failCount++ + continue + } + + fmt.Printf(green("✓")+" %s\n", entry.Key) + successCount++ + } + + fmt.Println() + fmt.Println(green("Import complete:")) + fmt.Printf(" Imported: %d\n", successCount) + if skipCount > 0 { + fmt.Printf(" Skipped: %d\n", skipCount) + } + if failCount > 0 { + fmt.Printf(" Failed: %s\n", red(fmt.Sprintf("%d", failCount))) + } +} + +// handleExportList reads keys from a file and exports them +func (c *KVSClient) handleExportList(args []string) { + if len(args) < 2 { + fmt.Println(red("Usage: export-list ")) + fmt.Println("Example: export-list keys.txt backup.json") + fmt.Println() + fmt.Println("key-list-file should contain one key per line") + return + } + + keyListFile := args[0] + outputFile := args[1] + + // Read keys from file + data, err := os.ReadFile(keyListFile) + if err != nil { + fmt.Println(red("Error reading key list file:"), err) + return + } + + lines := strings.Split(string(data), "\n") + keys := []string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + keys = append(keys, line) + } + } + + if len(keys) == 0 { + fmt.Println(yellow("No keys found in file")) + return + } + + // Call export with the keys + exportArgs := append([]string{outputFile}, keys...) + c.handleExport(exportArgs) +} diff --git a/cmd_system.go b/cmd_system.go index dcce3ba..dc0111b 100644 --- a/cmd_system.go +++ b/cmd_system.go @@ -60,6 +60,13 @@ Group Management: group add-member - Add member to group group remove-member - Remove member from group +Export/Import: + export ... - Export keys to JSON file + export-list - Export keys listed in file + import - Import keys from JSON file + --skip-existing - Skip existing keys (default) + --overwrite - Overwrite existing keys + Batch Operations: batch - Execute commands from file source - Alias for batch diff --git a/main.go b/main.go index 3bc4823..ba6a498 100644 --- a/main.go +++ b/main.go @@ -60,6 +60,12 @@ func (c *KVSClient) executeCommand(line string) bool { } case "group": c.handleGroup(args) + case "export": + c.handleExport(args) + case "import": + c.handleImport(args) + case "export-list": + c.handleExportList(args) case "batch", "source": c.handleBatch(args, c.executeCommand) default: diff --git a/utils.go b/utils.go index bb36137..cfbed9c 100644 --- a/utils.go +++ b/utils.go @@ -127,6 +127,9 @@ var completer = readline.NewPrefixCompleter( readline.PcItem("add-member"), readline.PcItem("remove-member"), ), + readline.PcItem("export"), + readline.PcItem("import"), + readline.PcItem("export-list"), readline.PcItem("batch"), readline.PcItem("source"), readline.PcItem("help"), -- 2.51.0 From efaa5cdcc91356565a8470cc645e99a16e3c6e33 Mon Sep 17 00:00:00 2001 From: ryyst Date: Mon, 6 Oct 2025 00:01:36 +0300 Subject: [PATCH 7/7] feat: add shell scripting with variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented roadmap #9: Shell scripts execution with variables. Features: - set - Set shell variable - unset - Remove variable - vars - List all variables - Variable substitution in all commands Variable syntax: - $VAR or ${VAR} - Shell variables - $ENV:VARNAME - Environment variables Variables expand before command execution, enabling: - Dynamic key paths: put $BASE/$ID '{"data":"value"}' - Reusable values: set TOKEN xyz && auth $TOKEN - Script parameterization in batch files Example batch script: set USER alice set KEY_PREFIX users put $KEY_PREFIX/$USER '{"name":"$USER"}' get $KEY_PREFIX/$USER Variables are session-scoped and work in both interactive and batch modes. Environment variables can be injected using $ENV:HOME syntax. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- client.go | 4 ++- cmd_system.go | 7 ++++ cmd_variables.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 9 ++++++ utils.go | 3 ++ 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 cmd_variables.go diff --git a/client.go b/client.go index f64d752..b81b799 100644 --- a/client.go +++ b/client.go @@ -16,6 +16,7 @@ type KVSClient struct { httpClient *http.Client profiles map[string]Profile activeProfile string + variables map[string]string // Shell variables for scripting } // NewKVSClient creates a new KVS client instance @@ -25,7 +26,8 @@ func NewKVSClient(baseURL string) *KVSClient { httpClient: &http.Client{ Timeout: 30 * time.Second, }, - profiles: make(map[string]Profile), + profiles: make(map[string]Profile), + variables: make(map[string]string), } } diff --git a/cmd_system.go b/cmd_system.go index dc0111b..60132ca 100644 --- a/cmd_system.go +++ b/cmd_system.go @@ -67,6 +67,13 @@ Export/Import: --skip-existing - Skip existing keys (default) --overwrite - Overwrite existing keys +Shell Scripting: + set - Set shell variable + unset - Remove shell variable + vars - List all variables + Variables: $VAR or ${VAR} + Environment: $ENV:VARNAME + Batch Operations: batch - Execute commands from file source - Alias for batch diff --git a/cmd_variables.go b/cmd_variables.go new file mode 100644 index 0000000..76257ac --- /dev/null +++ b/cmd_variables.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "strings" +) + +// handleSet sets a shell variable +func (c *KVSClient) handleSet(args []string) { + if len(args) < 2 { + fmt.Println(red("Usage: set ")) + fmt.Println("Example: set USER_ID abc-123") + return + } + + name := args[0] + value := strings.Join(args[1:], " ") + + c.variables[name] = value + fmt.Printf(green("Set:")+" %s = %s\n", name, value) +} + +// handleUnset removes a shell variable +func (c *KVSClient) handleUnset(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: unset ")) + return + } + + name := args[0] + if _, exists := c.variables[name]; exists { + delete(c.variables, name) + fmt.Println(green("Unset:"), name) + } else { + fmt.Println(yellow("Variable not set:"), name) + } +} + +// handleVars lists all shell variables +func (c *KVSClient) handleVars(args []string) { + if len(c.variables) == 0 { + fmt.Println(yellow("No variables set")) + return + } + + fmt.Println(cyan("Shell Variables:")) + for name, value := range c.variables { + fmt.Printf(" %s = %s\n", name, value) + } +} + +// expandVariables performs variable substitution on a string +// Supports: +// - $VAR or ${VAR} for shell variables +// - $ENV:VAR for environment variables +func (c *KVSClient) expandVariables(input string) string { + // Pattern for $VAR or ${VAR} + varPattern := regexp.MustCompile(`\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?`) + + // Pattern for $ENV:VAR + envPattern := regexp.MustCompile(`\$ENV:([A-Za-z_][A-Za-z0-9_]*)`) + + // First, replace environment variables + result := envPattern.ReplaceAllStringFunc(input, func(match string) string { + varName := envPattern.FindStringSubmatch(match)[1] + if value, exists := os.LookupEnv(varName); exists { + return value + } + return match // Leave unchanged if not found + }) + + // Then, replace shell variables + result = varPattern.ReplaceAllStringFunc(result, func(match string) string { + varName := varPattern.FindStringSubmatch(match)[1] + if value, exists := c.variables[varName]; exists { + return value + } + return match // Leave unchanged if not found + }) + + return result +} diff --git a/main.go b/main.go index ba6a498..508c90d 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,9 @@ import ( // executeCommand executes a single command line func (c *KVSClient) executeCommand(line string) bool { + // Expand variables in the line + line = c.expandVariables(line) + parts := parseCommand(line) if len(parts) == 0 { return true @@ -66,6 +69,12 @@ func (c *KVSClient) executeCommand(line string) bool { c.handleImport(args) case "export-list": c.handleExportList(args) + case "set": + c.handleSet(args) + case "unset": + c.handleUnset(args) + case "vars": + c.handleVars(args) case "batch", "source": c.handleBatch(args, c.executeCommand) default: diff --git a/utils.go b/utils.go index cfbed9c..62c5c50 100644 --- a/utils.go +++ b/utils.go @@ -130,6 +130,9 @@ var completer = readline.NewPrefixCompleter( readline.PcItem("export"), readline.PcItem("import"), readline.PcItem("export-list"), + readline.PcItem("set"), + readline.PcItem("unset"), + readline.PcItem("vars"), readline.PcItem("batch"), readline.PcItem("source"), readline.PcItem("help"), -- 2.51.0