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 diff --git a/client.go b/client.go new file mode 100644 index 0000000..b81b799 --- /dev/null +++ b/client.go @@ -0,0 +1,63 @@ +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 + variables map[string]string // Shell variables for scripting +} + +// 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), + variables: make(map[string]string), + } +} + +// 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_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_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_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_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_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..5be1714 --- /dev/null +++ b/cmd_profile.go @@ -0,0 +1,95 @@ +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]) + c.saveProfiles() + + 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]) + c.saveProfiles() + + 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]) + 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 new file mode 100644 index 0000000..60132ca --- /dev/null +++ b/cmd_system.go @@ -0,0 +1,88 @@ +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 - List all profiles + profile add [url] - Add user profile + profile use - Switch to user profile + profile remove - Remove user profile + profile save - Save profiles to ~/.kvs/config.json + profile load - Load profiles from ~/.kvs/config.json + +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) + +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 + +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 + +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 + --continue-on-error - Continue execution even if commands fail + +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/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/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 68b9de3..508c90d 100644 --- a/main.go +++ b/main.go @@ -1,653 +1,97 @@ -// 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"` -} +// executeCommand executes a single command line +func (c *KVSClient) executeCommand(line string) bool { + // Expand variables in the line + line = c.expandVariables(line) -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) + parts := parseCommand(line) + if len(parts) == 0 { + return true } - req, err := http.NewRequest(method, c.baseURL+path, reqBody) - if err != nil { - return nil, 0, err - } + cmd := parts[0] + args := parts[1:] - 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 { + 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.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 + 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 "export": + c.handleExport(args) + case "import": + 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: + fmt.Println(red("Unknown command:"), cmd) + fmt.Println("Type 'help' for available commands") } - - 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) + return true } 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> "), @@ -663,6 +107,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 { @@ -676,130 +123,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 { - // This was the original behavior, which notes that listing users is not implemented. - 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("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..62c5c50 --- /dev/null +++ b/utils.go @@ -0,0 +1,142 @@ +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("save"), + readline.PcItem("load"), + ), + 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("group", + readline.PcItem("create"), + readline.PcItem("get"), + readline.PcItem("update"), + readline.PcItem("delete"), + readline.PcItem("add-member"), + readline.PcItem("remove-member"), + ), + 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"), + readline.PcItem("exit"), + readline.PcItem("quit"), + readline.PcItem("clear"), +)