Add client engine and desktop CLI that mirror KDBX files

Why: a KeePass application must see an ordinary .kdbx file. A mirror
directory of real files gives that on every desktop platform without FUSE,
WinFsp or macFUSE, and without cgo.

What: internal/client holds the API wrapper, an SSE reader, and a mirror
agent whose entire policy is one pure decide() table: adopt, pull, push,
force-push, server-wins, restore or manual. Saves are detected by rescan,
remote changes arrive over the event stream, and every overwrite of local
bytes leaves a conflict copy. cmd/keeporg drives it: login (password, TOTP or
browser/OIDC device flow), run, sync, status, plus the group, grant, database
and invite administration.

Proved: go test ./internal/client covers the decision table and runs a real
server in-process for adopt, follow, server-wins, client-wins, manual,
read-only restore, server-side rename and event-driven update. Also smoke
tested live: built both binaries, bootstrapped a server, logged in, created a
database and adopted a local file.
This commit is contained in:
2026-10-29 16:01:35 +01:00
parent 8bbb3179a1
commit 4d0d53ff04
10 changed files with 2288 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
package client
import (
"bufio"
"encoding/json"
"io"
"strings"
)
// sseDecoder reads the subset of the Server-Sent Events format the server
// emits: "event:" and "data:" lines, one JSON payload per event.
type sseDecoder struct {
sc *bufio.Scanner
}
func newSSEDecoder(r io.Reader) *sseDecoder {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 8<<10), 1<<20)
return &sseDecoder{sc: sc}
}
// next returns the next event, or nil for a comment or keepalive line.
func (d *sseDecoder) next() (*Event, error) {
var (
name string
data string
)
for d.sc.Scan() {
line := d.sc.Text()
switch {
case line == "":
if data == "" {
name = ""
continue
}
var ev Event
if err := json.Unmarshal([]byte(data), &ev); err != nil {
return nil, nil // skip a payload we cannot read
}
if ev.Type == "" {
ev.Type = name
}
return &ev, nil
case strings.HasPrefix(line, ":"):
continue
case strings.HasPrefix(line, "event:"):
name = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
if err := d.sc.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}