Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
efaa5cdcc9 | |||
bd73a1c477 | |||
2f77c0a825 | |||
33201227ca | |||
39a1d4482a | |||
0c9e314d7c | |||
fcd27dee97 |
37
CLAUDE.md
37
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 <root-token-from-logs>
|
||||
# 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:
|
||||
|
39
README.md
39
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
|
||||
|
63
client.go
Normal file
63
client.go
Normal file
@@ -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
|
||||
}
|
73
cmd_batch.go
Normal file
73
cmd_batch.go
Normal file
@@ -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 <file> [--continue-on-error]"))
|
||||
fmt.Println(" source <file> [--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)))
|
||||
}
|
||||
}
|
61
cmd_cluster.go
Normal file
61
cmd_cluster.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
222
cmd_export.go
Normal file
222
cmd_export.go
Normal file
@@ -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 <output-file> <key1> [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 <input-file> [--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 <key-list-file> <output-file>"))
|
||||
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)
|
||||
}
|
311
cmd_group.go
Normal file
311
cmd_group.go
Normal file
@@ -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 <create|get|update|delete|add-member|remove-member> [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 <groupname> [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 <group-uuid>"))
|
||||
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 <group-uuid> <member-uuid1,member-uuid2,...>"))
|
||||
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 <group-uuid>"))
|
||||
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 <group-uuid> <user-uuid>"))
|
||||
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 <group-uuid> <user-uuid>"))
|
||||
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"))
|
||||
}
|
125
cmd_kv.go
Normal file
125
cmd_kv.go
Normal file
@@ -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 <key>"))
|
||||
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 <key> <json-data>"))
|
||||
fmt.Println("Or: put <key> (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 <key>"))
|
||||
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)
|
||||
}
|
||||
}
|
137
cmd_meta.go
Normal file
137
cmd_meta.go
Normal file
@@ -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 <get|set> [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 <key>"))
|
||||
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 <key> --owner <uuid> | --group <uuid> | --permissions <number>"))
|
||||
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())
|
||||
}
|
95
cmd_profile.go
Normal file
95
cmd_profile.go
Normal file
@@ -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 <name> <token> <user-uuid> [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 <name>"))
|
||||
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 <name>"))
|
||||
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")
|
||||
}
|
||||
}
|
88
cmd_system.go
Normal file
88
cmd_system.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (c *KVSClient) handleConnect(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Println(red("Usage: connect <base-url>"))
|
||||
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 <token>"))
|
||||
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 <url> - Connect to KVS server
|
||||
auth <token> - Set authentication token
|
||||
profile - List all profiles
|
||||
profile add <name> <token> <user-uuid> [url] - Add user profile
|
||||
profile use <name> - Switch to user profile
|
||||
profile remove <name> - Remove user profile
|
||||
profile save - Save profiles to ~/.kvs/config.json
|
||||
profile load - Load profiles from ~/.kvs/config.json
|
||||
|
||||
Key-Value Operations:
|
||||
get <key> - Retrieve value for key
|
||||
put <key> <json> - Store JSON value at key
|
||||
delete <key> - Delete key
|
||||
|
||||
Resource Metadata:
|
||||
meta get <key> - Get metadata (owner, group) for a key
|
||||
meta set <key> [flags] - Set metadata for a key
|
||||
Flags: --owner <uuid>, --group <uuid>, --permissions <number>
|
||||
|
||||
Cluster Management:
|
||||
members - List cluster members
|
||||
health - Check service health
|
||||
|
||||
User Management:
|
||||
user get <uuid> - Get user details
|
||||
user create <nickname> - Create new user (admin only)
|
||||
|
||||
Group Management:
|
||||
group create <name> [members] - Create new group
|
||||
group get <uuid> - Get group details
|
||||
group update <uuid> <members> - Replace all group members
|
||||
group delete <uuid> - Delete group
|
||||
group add-member <uuid> <user-uuid> - Add member to group
|
||||
group remove-member <uuid> <user-uuid> - Remove member from group
|
||||
|
||||
Export/Import:
|
||||
export <file> <key1> ... - Export keys to JSON file
|
||||
export-list <keyfile> <output> - Export keys listed in file
|
||||
import <file> - Import keys from JSON file
|
||||
--skip-existing - Skip existing keys (default)
|
||||
--overwrite - Overwrite existing keys
|
||||
|
||||
Shell Scripting:
|
||||
set <name> <value> - Set shell variable
|
||||
unset <name> - Remove shell variable
|
||||
vars - List all variables
|
||||
Variables: $VAR or ${VAR}
|
||||
Environment: $ENV:VARNAME
|
||||
|
||||
Batch Operations:
|
||||
batch <file> - Execute commands from file
|
||||
source <file> - 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)
|
||||
}
|
86
cmd_user.go
Normal file
86
cmd_user.go
Normal file
@@ -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 <uuid>"))
|
||||
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 <nickname>"))
|
||||
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)
|
||||
}
|
84
cmd_variables.go
Normal file
84
cmd_variables.go
Normal file
@@ -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 <name> <value>"))
|
||||
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 <name>"))
|
||||
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
|
||||
}
|
120
config.go
Normal file
120
config.go
Normal file
@@ -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
|
||||
}
|
825
main.go
825
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 <base-url>"))
|
||||
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 <token>"))
|
||||
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 <name> <token> <user-uuid> [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 <name>"))
|
||||
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 <name>"))
|
||||
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 <key>"))
|
||||
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 <key> <json-data>"))
|
||||
fmt.Println("Or: put <key> (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 <key>"))
|
||||
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 <get|set> [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 <key>"))
|
||||
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 <key> --owner <uuid> | --group <uuid> | --permissions <number>"))
|
||||
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 <uuid>"))
|
||||
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 <nickname>"))
|
||||
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 <url> - Connect to KVS server
|
||||
auth <token> - Set authentication token
|
||||
profile add <name> <token> <user-uuid> [url] - Add user profile
|
||||
profile use <name> - Switch to user profile
|
||||
profile remove <name> - Remove user profile
|
||||
profile - List all profiles
|
||||
|
||||
Key-Value Operations:
|
||||
get <key> - Retrieve value for key
|
||||
put <key> <json> - Store JSON value at key
|
||||
delete <key> - Delete key
|
||||
|
||||
Resource Metadata:
|
||||
meta get <key> - Get metadata (owner, group) for a key
|
||||
meta set <key> [flags] - Set metadata for a key
|
||||
Flags: --owner <uuid>, --group <uuid>, --permissions <number>
|
||||
|
||||
Cluster Management:
|
||||
members - List cluster members
|
||||
health - Check service health
|
||||
|
||||
User Management:
|
||||
user get <uuid> - Get user details
|
||||
user create <nickname> - 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
|
||||
}
|
||||
|
42
types.go
Normal file
42
types.go
Normal file
@@ -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"`
|
||||
}
|
142
utils.go
Normal file
142
utils.go
Normal file
@@ -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"),
|
||||
)
|
Reference in New Issue
Block a user