diff --git a/CHANGELOG.md b/CHANGELOG.md index 79691a3..b63b9e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ This CLI follows [Semantic Versioning](https://semver.org/) and targets the stable `chunkdb` 1.x protocol; see the engine's [compatibility policy](https://github.com/chunkdb/chunkdb/blob/main/docs/COMPATIBILITY.md). +## Unreleased + +### Added +- `chunksetbin` and `chunksetbinstate`: binary chunk writes over the new + `CHUNKSETBIN` command (chunkdb server 1.3+). The payload is given as hex or + read from a file with `--in`, in the byte layouts `chunkbin` / + `chunkbinstate` print, so `chunkbin --out` output can be written back as is. + Also available in `shell` + ## 1.1.0 - 2026-07-18 ### Added diff --git a/README.md b/README.md index 7553a02..9b17cb5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Targets the stable `chunkdb` 1.x protocol; see the engine's - `chunkset` - `chunkstate` - `chunksetstate` + - `chunksetbin` + - `chunksetbinstate` - `chunk` - `chunkbin` - `chunkbinstate` @@ -82,6 +84,8 @@ chunk-cli --uri chunk://mytoken@127.0.0.1:4242/ chunksetstate 0 0 chunk-cli --uri chunk://mytoken@127.0.0.1:4242/ chunk 0 0 chunk-cli --uri chunk://mytoken@127.0.0.1:4242/ chunkbin 0 0 chunk-cli --uri chunk://mytoken@127.0.0.1:4242/ chunkbinstate 0 0 +chunk-cli --uri chunk://mytoken@127.0.0.1:4242/ chunksetbin 0 0 +chunk-cli --uri chunk://mytoken@127.0.0.1:4242/ chunksetbinstate --in state.bin 0 0 ``` Block-state note: @@ -99,6 +103,7 @@ Chunk-state note: - `chunksetstate |` writes mixed present/absent block state - `chunkbinstate ` prints exact chunk-state bytes as `[payload_bytes][presence_bytes]` - `chunkbinc ` / `chunkbincstate ` fetch the same bytes as `chunkbin`/`chunkbinstate` over the compressed `CHUNKBINC` transfer and decompress client-side; pass `--raw` to keep the compressed payload +- `chunksetbin ` / `chunksetbinstate ` write raw chunk bytes in the layouts `chunkbin`/`chunkbinstate` print; `--in ` reads the bytes from a file (for example one written by `chunkbin --out`). Requires chunkdb server 1.3+ - `chunkradius ` reads populated chunks within a chunk-space radius (disc), like `chunkrange` but circular ## Interactive Shell @@ -124,6 +129,8 @@ The shell prompt is `chunk>`. Supported shell commands: - `chunkset ` - `chunkstate ` - `chunksetstate |` +- `chunksetbin | --in ` +- `chunksetbinstate | --in ` - `chunk ` - `chunkbin [--out ] ` - `chunkbinstate [--out ] ` @@ -233,6 +240,8 @@ Auth behavior: - `chunkstate ` - sends `CHUNK ... STATE`; prints `|` - `chunksetstate |` +- `chunksetbin | --in ` +- `chunksetbinstate | --in ` - sends `CHUNKSET ... STATE`; validates both halves as binary before request - `chunk ` - sends `CHUNK`, prints text chunk payload diff --git a/cmd/chunk-cli/main.go b/cmd/chunk-cli/main.go index 33c4b31..c7d6e78 100644 --- a/cmd/chunk-cli/main.go +++ b/cmd/chunk-cli/main.go @@ -51,7 +51,7 @@ func main() { case "help", "-h", "--help": printUsage() return - case "ping", "info", "auth", "get", "exists", "set", "unset", "mset", "mget", "chunkexists", "chunkset", "chunkstate", "chunksetstate", "chunk", "chunkbin", "chunkbinstate", "chunkbinc", "chunkbincstate", "chunkscan", "chunkrange", "chunkradius", "chunkver", "chunkcas", "chunkbatch", "walflush", "metrics", "shell": + case "ping", "info", "auth", "get", "exists", "set", "unset", "mset", "mget", "chunkexists", "chunkset", "chunkstate", "chunksetstate", "chunksetbin", "chunksetbinstate", "chunk", "chunkbin", "chunkbinstate", "chunkbinc", "chunkbincstate", "chunkscan", "chunkrange", "chunkradius", "chunkver", "chunkcas", "chunkbatch", "walflush", "metrics", "shell": // network command default: fatal(fmt.Errorf("unknown command %q", cmd)) @@ -189,6 +189,14 @@ func main() { fatal(err) } printTextPayload(os.Stdout, payload) + case "chunksetbin": + if err := runChunkSetBin(client, false, "chunksetbin", cmdArgs, os.Stdout, os.Stderr); err != nil { + fatal(err) + } + case "chunksetbinstate": + if err := runChunkSetBin(client, true, "chunksetbinstate", cmdArgs, os.Stdout, os.Stderr); err != nil { + fatal(err) + } case "chunkbin": if err := runChunkBinLike(client, "CHUNKBIN", false, "chunkbin", cmdArgs, os.Stdout, os.Stderr); err != nil { fatal(err) @@ -411,6 +419,12 @@ func validateCommandArgs(cmd string, cmdArgs []string) error { if err := validateIntArg(cmdArgs[1], "cy"); err != nil { return err } + case "chunksetbin": + _, _, _, err := parseChunkSetBinArgs("chunksetbin", cmdArgs, stderrDiscard{}) + return err + case "chunksetbinstate": + _, _, _, err := parseChunkSetBinArgs("chunksetbinstate", cmdArgs, stderrDiscard{}) + return err case "chunkbin": return validateChunkBinLikeArgs("chunkbin", cmdArgs, stderrDiscard{}) case "chunkbinstate": @@ -681,6 +695,73 @@ func runChunkBinLike(client *chunkclient.Client, command string, state bool, nam return nil } +// parseChunkSetBinArgs validates " " or "--in " +// and returns the coordinates plus the raw payload bytes (nil when the payload +// is only known at run time from --in). +func parseChunkSetBinArgs(name string, cmdArgs []string, stderr io.Writer) (cx string, cy string, payload []byte, err error) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(stderr) + inPath := fs.String("in", "", "read the raw payload bytes from file") + if err := fs.Parse(cmdArgs); err != nil { + return "", "", nil, err + } + remaining := fs.Args() + usage := fmt.Errorf("usage: %s | %s --in ", name, name) + switch { + case *inPath != "" && len(remaining) == 2: + case *inPath == "" && len(remaining) == 3: + default: + return "", "", nil, usage + } + cx, cy = remaining[0], remaining[1] + if err := validateIntArg(cx, "cx"); err != nil { + return "", "", nil, err + } + if err := validateIntArg(cy, "cy"); err != nil { + return "", "", nil, err + } + if *inPath != "" { + data, err := os.ReadFile(*inPath) + if err != nil { + return "", "", nil, fmt.Errorf("read %s: %w", *inPath, err) + } + return cx, cy, data, nil + } + data, err := hex.DecodeString(remaining[2]) + if err != nil { + return "", "", nil, fmt.Errorf("payload must be hex: %w", err) + } + if len(data) == 0 { + return "", "", nil, fmt.Errorf("payload must not be empty") + } + return cx, cy, data, nil +} + +// runChunkSetBin sends CHUNKSETBIN [STATE] with the raw payload bytes taken +// from a hex argument or a file; the byte layout is what chunkbin / +// chunkbinstate print. +func runChunkSetBin(client *chunkclient.Client, state bool, name string, cmdArgs []string, stdout io.Writer, stderr io.Writer) error { + cx, cy, payload, err := parseChunkSetBinArgs(name, cmdArgs, stderr) + if err != nil { + return err + } + request := fmt.Sprintf("CHUNKSETBIN %s %s", cx, cy) + if state { + request += " STATE" + } + request += fmt.Sprintf(" %d", len(payload)) + + resp, err := client.CommandWithPayload(request, payload) + if err != nil { + return fmt.Errorf("%s failed: %w", name, err) + } + if resp.Kind != chunkclient.ResponseSimple { + return fmt.Errorf("%s failed: expected simple response", name) + } + fmt.Fprintln(stdout, resp.Simple) + return nil +} + // runChunkBinCompressed sends CHUNKBINC, decompresses the zrle payload // client-side, and presents the same decompressed bytes as chunkbin so // compressed reads are usable without a separate tool. @@ -940,6 +1021,14 @@ func runShell( if err := runChunkBinLike(client, "CHUNKBIN", false, "chunkbin", cmdArgs, stdout, stderr); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) } + case "chunksetbin": + if err := runChunkSetBin(client, false, "chunksetbin", cmdArgs, stdout, stderr); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + } + case "chunksetbinstate": + if err := runChunkSetBin(client, true, "chunksetbinstate", cmdArgs, stdout, stderr); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + } case "chunkstate": if err := validateCommandArgs(cmd, cmdArgs); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) @@ -1126,6 +1215,8 @@ Commands: chunk chunkbin [--out ] chunkbinstate [--out ] + chunksetbin | --in + chunksetbinstate | --in chunkbinc [--out ] [--raw] chunkbincstate [--out ] [--raw] chunkscan [ ] diff --git a/cmd/chunk-cli/main_test.go b/cmd/chunk-cli/main_test.go index 059fb97..7edbd5d 100644 --- a/cmd/chunk-cli/main_test.go +++ b/cmd/chunk-cli/main_test.go @@ -55,6 +55,12 @@ func TestValidateCommandArgs(t *testing.T) { {name: "chunk ok", cmd: "chunk", args: []string{"0", "0"}, wantErr: false}, {name: "auth too many", cmd: "auth", args: []string{"a", "b"}, wantErr: true}, {name: "chunkbin passthrough", cmd: "chunkbin", args: []string{"--out", "x", "1", "2"}, wantErr: false}, + {name: "chunksetbin hex ok", cmd: "chunksetbin", args: []string{"1", "2", "a0a1"}, wantErr: false}, + {name: "chunksetbin bad hex", cmd: "chunksetbin", args: []string{"1", "2", "zz"}, wantErr: true}, + {name: "chunksetbin bad int", cmd: "chunksetbin", args: []string{"x", "2", "a0"}, wantErr: true}, + {name: "chunksetbin missing payload", cmd: "chunksetbin", args: []string{"1", "2"}, wantErr: true}, + {name: "chunksetbin in and hex", cmd: "chunksetbin", args: []string{"--in", "x", "1", "2", "a0"}, wantErr: true}, + {name: "chunksetbinstate hex ok", cmd: "chunksetbinstate", args: []string{"1", "2", "a0a1ff"}, wantErr: false}, {name: "shell ok", cmd: "shell", args: nil, wantErr: false}, {name: "shell extra", cmd: "shell", args: []string{"ping"}, wantErr: true}, {name: "chunkscan ok", cmd: "chunkscan", args: []string{"10"}, wantErr: false}, diff --git a/internal/chunkclient/client.go b/internal/chunkclient/client.go index 87aa455..c2fde98 100644 --- a/internal/chunkclient/client.go +++ b/internal/chunkclient/client.go @@ -99,6 +99,12 @@ func (c *Client) Close() error { } func (c *Client) Command(command string) (Response, error) { + return c.CommandWithPayload(command, nil) +} + +// CommandWithPayload sends a request line followed by raw payload bytes and an +// empty line (the CHUNKSETBIN framing). A nil payload sends the line alone. +func (c *Client) CommandWithPayload(command string, payload []byte) (Response, error) { if c.conn == nil { return Response{}, fmt.Errorf("connection is closed") } @@ -114,6 +120,14 @@ func (c *Client) Command(command string) (Response, error) { if _, err := c.writer.WriteString(command + "\r\n"); err != nil { return Response{}, fmt.Errorf("write command: %w", err) } + if payload != nil { + if _, err := c.writer.Write(payload); err != nil { + return Response{}, fmt.Errorf("write payload: %w", err) + } + if _, err := c.writer.WriteString("\r\n"); err != nil { + return Response{}, fmt.Errorf("write payload terminator: %w", err) + } + } if err := c.writer.Flush(); err != nil { return Response{}, fmt.Errorf("flush command: %w", err) }