Compare commits
No commits in common. "99e47fcb17be66da221166d210f0b227668c1a86" and "edc708a106ce9a8831c404d82f60ede9df2fc4f1" have entirely different histories.
99e47fcb17
...
edc708a106
|
|
@ -24,7 +24,6 @@ frontend/node_modules/
|
|||
frontend/bindings/
|
||||
/verstak-gui
|
||||
/verstak-cli
|
||||
/verstak-server
|
||||
|
||||
# Vault data
|
||||
.verstak/
|
||||
|
|
|
|||
|
|
@ -14,14 +14,12 @@ import (
|
|||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
|
|
@ -39,7 +37,6 @@ type App struct {
|
|||
worklog *worklog.Service
|
||||
search *search.Service
|
||||
plugins *plugins.Manager
|
||||
sync *syncsvc.Service
|
||||
vault string
|
||||
}
|
||||
|
||||
|
|
@ -389,7 +386,6 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e
|
|||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, map[string]string{"title": title})
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
}
|
||||
|
|
@ -484,7 +480,6 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
|||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, map[string]string{"title": title})
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
|
@ -506,7 +501,6 @@ func (a *App) SaveNote(noteID, content string) error {
|
|||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]string{"title": n.Title})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -579,7 +573,6 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
|||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
|
@ -591,7 +584,6 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
|||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
|
@ -610,11 +602,6 @@ func (a *App) DeleteFileOrFolder(nodeID string) error {
|
|||
targetType = activity.TargetFolder
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
|
||||
syncEntity := syncsvc.EntityFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
syncEntity = syncsvc.EntityFolder
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpDelete, nil)
|
||||
}
|
||||
return a.files.DeleteNodeAndChildren(nodeID)
|
||||
}
|
||||
|
|
@ -625,7 +612,6 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
|||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": filename})
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
|
@ -642,7 +628,6 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
|||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": node.Title})
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
|
@ -667,11 +652,6 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
targetType = activity.TargetFolder
|
||||
}
|
||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
|
||||
syncEntity := syncsvc.EntityFile
|
||||
if n.Type == nodes.TypeFolder {
|
||||
syncEntity = syncsvc.EntityFolder
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]string{"title": newTitle})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -707,7 +687,6 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
|||
pid = *node.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]string{"title": node.Title})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -746,7 +725,6 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, map[string]string{"title": rec.Title, "kind": rec.Kind})
|
||||
return &ActionDTO{
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
|
|
@ -757,7 +735,6 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
|
|||
}
|
||||
|
||||
func (a *App) DeleteAction(id string) error {
|
||||
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
|
||||
return a.actions.Delete(id)
|
||||
}
|
||||
|
||||
|
|
@ -797,7 +774,6 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, map[string]string{"summary": summary})
|
||||
mins := 0
|
||||
if entry.Minutes != nil {
|
||||
mins = *entry.Minutes
|
||||
|
|
@ -836,122 +812,6 @@ func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sync
|
||||
// ============================================================
|
||||
|
||||
type SyncStatusDTO struct {
|
||||
Configured bool `json:"configured"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
UnpushedOps int `json:"unpushedOps"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
}
|
||||
|
||||
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
|
||||
if err != nil {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
unpushed, _ := a.sync.GetUnpushedOps()
|
||||
cfg, _ := config.Load(a.vault)
|
||||
dto := &SyncStatusDTO{
|
||||
Configured: serverURL != "" && apiKey != "",
|
||||
ServerURL: serverURL,
|
||||
UnpushedOps: len(unpushed),
|
||||
LastSyncAt: lastSyncAt,
|
||||
}
|
||||
if cfg != nil {
|
||||
dto.DeviceID = cfg.Sync.DeviceID
|
||||
dto.SyncInterval = cfg.Sync.SyncInterval
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||
// Register device on server with user credentials.
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register: %w", err)
|
||||
}
|
||||
|
||||
if err := a.sync.SetState(serverURL, apiKey); err != nil {
|
||||
return err
|
||||
}
|
||||
// Persist to vault config.
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Sync.ServerURL = serverURL
|
||||
cfg.Sync.APIKey = apiKey
|
||||
cfg.Sync.DeviceID = deviceID
|
||||
return config.Save(a.vault, cfg)
|
||||
}
|
||||
|
||||
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
_, _, err := client.RegisterDeviceWithAuth("test-connection", username, password)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) SyncSetInterval(minutes int) error {
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Sync.SyncInterval = minutes
|
||||
return config.Save(a.vault, cfg)
|
||||
}
|
||||
|
||||
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
serverURL, apiKey, lastRev, _, err := a.sync.GetState()
|
||||
if err != nil || serverURL == "" || apiKey == "" {
|
||||
return nil, fmt.Errorf("sync not configured")
|
||||
}
|
||||
|
||||
deviceID := ""
|
||||
if cfg, err := config.Load(a.vault); err == nil {
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
|
||||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
|
||||
|
||||
// Push unpushed ops.
|
||||
unpushed, err := a.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ops: %w", err)
|
||||
}
|
||||
pushResult := &syncsvc.PushResponse{}
|
||||
if len(unpushed) > 0 {
|
||||
pushResult, err = client.Push(unpushed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: %w", err)
|
||||
}
|
||||
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
|
||||
return nil, fmt.Errorf("mark pushed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull remote ops.
|
||||
pullResult, err := client.Pull(lastRev)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pull: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"pushed": len(pushResult.Accepted),
|
||||
"pulled": len(pullResult.Ops),
|
||||
"serverRevision": pullResult.ServerRevision,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// File Dialogs (Wails v2 Runtime)
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ import (
|
|||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/worklog"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
|
|
@ -55,16 +53,6 @@ func main() {
|
|||
pm := plugins.NewManager(abs)
|
||||
pm.Discover()
|
||||
|
||||
// Sync service — use configured device ID or vault ID as fallback.
|
||||
deviceID := ""
|
||||
if cfg, err := config.Load(abs); err == nil {
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
if deviceID == "" {
|
||||
deviceID = "gui-" + abs[:8]
|
||||
}
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
|
||||
app := &App{
|
||||
db: db,
|
||||
nodes: nodeRepo,
|
||||
|
|
@ -75,7 +63,6 @@ func main() {
|
|||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
plugins: pm,
|
||||
sync: syncSvc,
|
||||
vault: abs,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
#!/bin/sh
|
||||
#
|
||||
# install.sh — установка Verstak Sync Server
|
||||
#
|
||||
# Использование:
|
||||
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
|
||||
#
|
||||
# Флаги:
|
||||
# --port Порт сервера (по умолчанию: 47732)
|
||||
# --user Системный пользователь (по умолчанию: verstak)
|
||||
# --admin-user Логин администратора (обязательный)
|
||||
# --admin-pass Пароль администратора (обязательный)
|
||||
# --bin Путь к бинарнику (по умолчанию: ./verstak-server)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Defaults
|
||||
PORT="${VERSTAK_PORT:-47732}"
|
||||
USER="verstak"
|
||||
ADMIN_USER=""
|
||||
ADMIN_PASS=""
|
||||
BIN="./verstak-server"
|
||||
|
||||
# Parse flags
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--user) USER="$2"; shift 2 ;;
|
||||
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
||||
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
|
||||
--bin) BIN="$2"; shift 2 ;;
|
||||
*) echo "Unknown: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
|
||||
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "This script must be run as root (sudo)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Verstak Sync Server Installation ==="
|
||||
echo "Port: $PORT"
|
||||
echo "User: $USER"
|
||||
echo "Admin: $ADMIN_USER"
|
||||
echo "Binary: $BIN"
|
||||
echo ""
|
||||
|
||||
# 1. Create system user if not exists.
|
||||
if ! id -u "$USER" >/dev/null 2>&1; then
|
||||
echo "Creating user: $USER"
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
|
||||
fi
|
||||
|
||||
# 2. Install binary.
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/verstak-server/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing binary to /usr/local/bin/verstak-server"
|
||||
cp "$BIN" /usr/local/bin/verstak-server
|
||||
chmod 755 /usr/local/bin/verstak-server
|
||||
|
||||
# 3. Create data directory.
|
||||
echo "Creating /var/lib/verstak-server"
|
||||
mkdir -p /var/lib/verstak-server
|
||||
chown "$USER:$USER" /var/lib/verstak-server
|
||||
chmod 750 /var/lib/verstak-server
|
||||
|
||||
# 4. Set up admin user (first run).
|
||||
echo "Setting up admin user"
|
||||
/usr/local/bin/verstak-server \
|
||||
--port "$PORT" \
|
||||
--data /var/lib/verstak-server \
|
||||
--admin-user "$ADMIN_USER" \
|
||||
--admin-pass "$ADMIN_PASS" &
|
||||
SERVER_PID=$!
|
||||
sleep 2
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
|
||||
# 5. Install systemd unit.
|
||||
echo "Installing systemd unit"
|
||||
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
|
||||
cp "$(dirname "$0")/verstak-server.service" "$SERVICE_FILE"
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
|
||||
# Set port in environment file.
|
||||
mkdir -p /etc/verstak-server
|
||||
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
|
||||
|
||||
# 6. Enable and start.
|
||||
echo "Enabling and starting service"
|
||||
systemctl daemon-reload
|
||||
systemctl enable verstak-server
|
||||
systemctl start verstak-server
|
||||
|
||||
echo ""
|
||||
echo "=== Installation complete ==="
|
||||
echo "Service: verstak-server"
|
||||
echo "Port: $PORT"
|
||||
echo "Admin: http://localhost:$PORT/admin/login"
|
||||
echo ""
|
||||
echo "Check status: systemctl status verstak-server"
|
||||
echo "View logs: journalctl -u verstak-server -f"
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 47732, "HTTP port")
|
||||
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
|
||||
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
|
||||
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
|
||||
flag.Parse()
|
||||
|
||||
absData, err := filepath.Abs(*dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("data dir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absData, 0750); err != nil {
|
||||
log.Fatalf("create data dir: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(absData)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
// First-run admin setup.
|
||||
if *adminUser != "" && *adminPass != "" {
|
||||
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
|
||||
log.Fatalf("set admin: %v", err)
|
||||
}
|
||||
fmt.Printf("Admin user %q created.\n", *adminUser)
|
||||
}
|
||||
|
||||
// Open server DB.
|
||||
dbPath := filepath.Join(absData, "server.db")
|
||||
srv, err := NewServer(dbPath, absData, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
addr := fmt.Sprintf(":%d", *port)
|
||||
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
|
||||
if err := srv.ListenAndServe(addr); err != nil {
|
||||
log.Fatalf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,22 +0,0 @@
|
|||
[Unit]
|
||||
Description=Verstak Sync Server
|
||||
Documentation=https://github.com/anomalyco/verstak
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/verstak-server --port ${VERSTAK_PORT} --data /var/lib/verstak-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=verstak
|
||||
Group=verstak
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
StateDirectory=verstak-server
|
||||
RuntimeDirectory=verstak-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -8,11 +8,9 @@ import (
|
|||
"strings"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/vault"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
|
@ -40,8 +38,6 @@ func main() {
|
|||
runLog(os.Args[2:])
|
||||
case "index":
|
||||
runIndex(os.Args[2:])
|
||||
case "sync":
|
||||
runSync(os.Args[2:])
|
||||
case "plugin":
|
||||
runPlugin(os.Args[2:])
|
||||
default:
|
||||
|
|
@ -60,7 +56,6 @@ func usage() {
|
|||
fmt.Println(" node Manage nodes")
|
||||
fmt.Println(" action Manage actions")
|
||||
fmt.Println(" --version Show version")
|
||||
fmt.Println(" sync Sync with server (push/pull/status)")
|
||||
fmt.Println(" --help Show this help")
|
||||
}
|
||||
|
||||
|
|
@ -602,162 +597,6 @@ func runIndexRebuild(args []string) {
|
|||
fmt.Printf("indexed %d nodes\n", count)
|
||||
}
|
||||
|
||||
// --- sync ---
|
||||
|
||||
func runSync(args []string) {
|
||||
if len(args) == 0 {
|
||||
fmt.Println("verstak sync — synchronize with server")
|
||||
fmt.Println()
|
||||
fmt.Println("Usage: verstak sync <command> [options]")
|
||||
fmt.Println()
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" push Push local changes to server")
|
||||
fmt.Println(" pull Pull remote changes from server")
|
||||
fmt.Println(" status Show sync status")
|
||||
os.Exit(0)
|
||||
}
|
||||
switch args[0] {
|
||||
case "push":
|
||||
runSyncPush(args[1:])
|
||||
case "pull":
|
||||
runSyncPull(args[1:])
|
||||
case "status":
|
||||
runSyncStatus(args[1:])
|
||||
case "--help", "-h":
|
||||
runSync(nil)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown sync command: %s\n", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func openSyncDB(args []string) (*storage.DB, string) {
|
||||
vaultPath, _ := stringFlag(args, "--vault")
|
||||
abs, _ := filepath.Abs(vaultPath)
|
||||
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return db, abs
|
||||
}
|
||||
|
||||
func runSyncPush(args []string) {
|
||||
db, abs := openSyncDB(args)
|
||||
defer db.Close()
|
||||
|
||||
cfg, err := config.Load(abs)
|
||||
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
|
||||
fmt.Fprintln(os.Stderr, "Sync not configured. Use 'verstak sync configure' or GUI settings.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
deviceID := cfg.Sync.DeviceID
|
||||
if deviceID == "" {
|
||||
deviceID = "cli-" + abs[:8]
|
||||
}
|
||||
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
|
||||
|
||||
unpushed, err := syncSvc.GetUnpushedOps()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Get ops: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(unpushed) == 0 {
|
||||
fmt.Println("Nothing to push.")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.Push(unpushed)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := syncSvc.MarkPushed(result.Accepted); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Mark pushed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
|
||||
}
|
||||
|
||||
func runSyncPull(args []string) {
|
||||
db, abs := openSyncDB(args)
|
||||
defer db.Close()
|
||||
|
||||
cfg, err := config.Load(abs)
|
||||
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
|
||||
fmt.Fprintln(os.Stderr, "Sync not configured.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
deviceID := cfg.Sync.DeviceID
|
||||
if deviceID == "" {
|
||||
deviceID = "cli-" + abs[:8]
|
||||
}
|
||||
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
|
||||
|
||||
_, _, lastRev, _, err := syncSvc.GetState()
|
||||
if err != nil {
|
||||
lastRev = 0
|
||||
}
|
||||
|
||||
result, err := client.Pull(lastRev)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var opIDs []string
|
||||
for _, op := range result.Ops {
|
||||
fmt.Printf(" %s\t%s\t%s\t%s\n", op.OpType, op.EntityType, op.EntityID, op.PayloadJSON)
|
||||
opIDs = append(opIDs, op.OpID)
|
||||
}
|
||||
|
||||
if len(opIDs) > 0 {
|
||||
syncSvc.MarkApplied(opIDs)
|
||||
}
|
||||
|
||||
fmt.Printf("Pulled %d ops (server rev: %d)\n", len(result.Ops), result.ServerRevision)
|
||||
}
|
||||
|
||||
func runSyncStatus(args []string) {
|
||||
db, abs := openSyncDB(args)
|
||||
defer db.Close()
|
||||
|
||||
cfg, err := config.Load(abs)
|
||||
configured := err == nil && cfg.Sync.ServerURL != "" && cfg.Sync.APIKey != ""
|
||||
serverURL := ""
|
||||
deviceID := ""
|
||||
if cfg != nil {
|
||||
serverURL = cfg.Sync.ServerURL
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
|
||||
unpushed := 0
|
||||
if configured {
|
||||
if deviceID == "" {
|
||||
deviceID = "cli-" + abs[:8]
|
||||
}
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
ops, _ := syncSvc.GetUnpushedOps()
|
||||
unpushed = len(ops)
|
||||
}
|
||||
|
||||
fmt.Println("Sync Status")
|
||||
fmt.Println(" Configured:", configured)
|
||||
fmt.Println(" Server:", serverURL)
|
||||
fmt.Println(" Device:", deviceID)
|
||||
fmt.Println(" Unpushed ops:", unpushed)
|
||||
}
|
||||
|
||||
// --- plugin ---
|
||||
|
||||
func runPlugin(args []string) {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@
|
|||
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
||||
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
||||
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
|
||||
10. [[10_Sync_Server_Guide]] — установка и настройка сервера синхронизации.
|
||||
|
||||
## Главные принципы
|
||||
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
# Сервер синхронизации Верстак — руководство
|
||||
|
||||
## 1. Зачем нужен сервер
|
||||
|
||||
Сервер позволяет синхронизировать данные между несколькими устройствами (например, рабочий ПК и ноутбук), а также хранить резервную копию vault'ов.
|
||||
|
||||
Он не обязателен — Верстак работает полностью локально и без сервера.
|
||||
|
||||
## 2. Установка
|
||||
|
||||
### Быстрая установка (systemd)
|
||||
|
||||
```bash
|
||||
# Сборка бинарника
|
||||
go build -o verstak-server ./cmd/verstak-server/
|
||||
|
||||
# Установка (от root)
|
||||
sudo ./verstak-server/install.sh \
|
||||
--port 47732 \
|
||||
--admin-user admin \
|
||||
--admin-pass 'мой-надёжный-пароль'
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
- создаёт системного пользователя `verstak`;
|
||||
- копирует бинарник в `/usr/local/bin/verstak-server`;
|
||||
- создаёт `/var/lib/verstak-server` для данных;
|
||||
- записывает админа в конфиг;
|
||||
- устанавливает systemd-сервис `verstak-server.service`;
|
||||
- запускает и включает автозапуск.
|
||||
|
||||
### Ручной запуск (для тестирования)
|
||||
|
||||
```bash
|
||||
# Одной командой
|
||||
./verstak-server \
|
||||
--port 47732 \
|
||||
--data ./server-data \
|
||||
--admin-user admin \
|
||||
--admin-pass 'мой-надёжный-пароль'
|
||||
```
|
||||
|
||||
Флаги:
|
||||
| Флаг | По умолчанию | Описание |
|
||||
|---|---|---|
|
||||
| `--port` | `47732` | Порт сервера |
|
||||
| `--data` | `./data` | Директория для данных (SQLite + blobs) |
|
||||
| `--admin-user` | — | Имя администратора (создаётся при первом запуске) |
|
||||
| `--admin-pass` | — | Пароль администратора |
|
||||
|
||||
Если не указать `--admin-user` и `--admin-pass`, сервер запустится, но админ-панель будет недоступна (некому будет логиниться).
|
||||
|
||||
## 3. Админ-панель
|
||||
|
||||
### Как открыть
|
||||
|
||||
Откройте в браузере: `http://<сервер>:47732/admin/login`
|
||||
|
||||
Войдите с логином и паролем, указанными при установке.
|
||||
|
||||
### Что там есть
|
||||
|
||||
- **Дашборд** — статистика: количество устройств, количество операций, список API-ключей.
|
||||
- **Управление API-ключами** — просмотр, создание и удаление ключей устройств.
|
||||
|
||||
Сессия живёт 24 часа. После перезапуска сервера все сессии сбрасываются (хранятся в памяти).
|
||||
|
||||
### Несколько админов
|
||||
|
||||
Можно запустить сервер с другими `--admin-user`/`--admin-pass` — добавится второй администратор. Повторный запуск с тем же именем меняет пароль.
|
||||
|
||||
## 4. API-ключи (устройства)
|
||||
|
||||
### Что такое API-ключ
|
||||
|
||||
API-ключ — это токен, который клиент (Верстак на вашем ноутбуке или ПК) использует для доступа к серверу. Без ключа push/pull/blobs не работают.
|
||||
|
||||
### Как создать
|
||||
|
||||
Через админ-панель:
|
||||
1. Зайти в `/admin/dashboard`.
|
||||
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
|
||||
3. Скопировать сгенерированный ключ.
|
||||
|
||||
Через API (требует логин и пароль администратора):
|
||||
```bash
|
||||
curl -X POST http://localhost:47732/api/v1/device/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"мой-ноутбук","username":"admin","password":"пароль-админа"}'
|
||||
```
|
||||
Ответ: `{"device_id":"...","api_key":"..."}`
|
||||
|
||||
**Важно:** не выставляйте сервер в интернет без HTTPS (через reverse proxy). До создания полноценной системы пользователей регистрация устройств требует учётных данных администратора.
|
||||
|
||||
### Как использовать
|
||||
|
||||
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`:
|
||||
```bash
|
||||
curl -X POST http://localhost:47732/api/v1/sync/push \
|
||||
-H "Authorization: Bearer b10c5d8e3f2a..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":"b10c5d8e3f2a","ops":[...]}'
|
||||
```
|
||||
|
||||
В клиенте Верстак достаточно вбить URL сервера и API-ключ в настройках (GUI Settings или `config.yml`).
|
||||
|
||||
### Один ключ на все устройства или отдельный на каждое?
|
||||
|
||||
**На каждое устройство — отдельный ключ.** Так вы сможете отозвать доступ конкретному устройству (удалить ключ в админ-панели), не затронув остальные.
|
||||
|
||||
Технически один и тот же ключ можно использовать на нескольких устройствах, но:
|
||||
- его нельзя будет удалить, не отключив все устройства разом;
|
||||
- в логах сервера все операции будут выглядеть как от одного устройства;
|
||||
- это не поддерживаемый сценарий.
|
||||
|
||||
**Рекомендация:** создавайте отдельный ключ для каждого клиента (ПК, ноутбук, телефон).
|
||||
|
||||
## 5. Настройка клиента
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
verstak sync push --vault /путь/к/vault
|
||||
verstak sync pull --vault /путь/к/vault
|
||||
verstak sync status --vault /путь/к/vault
|
||||
```
|
||||
|
||||
Перед использованием нужно указать URL сервера и API-ключ в `.verstak/config.yml` внутри vault:
|
||||
```yaml
|
||||
sync:
|
||||
server_url: http://мой-сервер:47732
|
||||
api_key: мой-ключ
|
||||
device_id: id-устройства # опционально
|
||||
auto_sync: false
|
||||
```
|
||||
|
||||
### GUI
|
||||
В графическом интерфейсе нажмите на иконку шестерёнки в левом нижнем углу → откроется окно настроек синхронизации. Укажите URL сервера и API-ключ, нажмите "Сохранить". Кнопка "Синхронизировать" запускает push + pull.
|
||||
|
||||
## 6. Безопасность
|
||||
|
||||
Сервер **не поддерживает HTTPS**. В production используйте reverse proxy (nginx, Caddy) для терминирования TLS.
|
||||
|
||||
Что стоит учесть:
|
||||
- Регистрация устройств открыта — любой, кто достучался до сервера, может создать ключ.
|
||||
- Нет logout'а — сессия живёт 24 часа или до перезапуска сервера.
|
||||
- Нет rate limiting'а — возможен перебор пароля.
|
||||
- Пароль хранится в bcrypt — база данных не должна быть общедоступной.
|
||||
|
||||
Рекомендации для production:
|
||||
- Закрыть порт сервера фаерволом (только доверенные IP).
|
||||
- Использовать VPN (WireGuard/OpenVPN) для доступа между устройствами.
|
||||
- Или поставить nginx/Caddy перед сервером с HTTPS и базовой аутентификацией на `/api/v1/device/register`.
|
||||
|
||||
## 7. Полный API
|
||||
|
||||
### Открытые endpoint'ы
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/health` | Проверка здоровья сервера |
|
||||
|
||||
### Требуют API-ключ (Authorization: Bearer)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/sync/push` | Отправить операции на сервер |
|
||||
| POST | `/api/v1/sync/pull` | Получить операции с сервера |
|
||||
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
|
||||
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
|
||||
|
||||
### Требуют логин+пароль администратора
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/device/register` | Регистрация устройства (body: name + username + password) |
|
||||
|
||||
### Требуют сессию админа (cookie)
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET/POST | `/admin/login` | Страница входа / отправка формы |
|
||||
| GET | `/admin/dashboard` | Дашборд |
|
||||
| GET | `/admin/api/keys` | Список API-ключей (JSON) |
|
||||
| POST | `/admin/api/keys` | Создать ключ |
|
||||
| DELETE | `/admin/api/keys/{id}` | Удалить ключ |
|
||||
|
|
@ -98,16 +98,6 @@
|
|||
let renameValue = ''
|
||||
let renameError = ''
|
||||
|
||||
// ===== Sync state =====
|
||||
let showSettings = false
|
||||
let syncStatus = null
|
||||
let syncLoading = false
|
||||
let syncServerUrl = ''
|
||||
let syncUsername = ''
|
||||
let syncPassword = ''
|
||||
let syncInterval = 0
|
||||
let syncResult = ''
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'notes', label: 'Заметки' },
|
||||
|
|
@ -148,7 +138,6 @@
|
|||
window.addEventListener('keydown', handleKeydown)
|
||||
|
||||
loading = false
|
||||
loadSyncStatus()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -893,70 +882,6 @@
|
|||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Sync =====
|
||||
async function loadSyncStatus() {
|
||||
try {
|
||||
syncStatus = await wailsCall('SyncStatus')
|
||||
} catch (e) {
|
||||
syncStatus = { configured: false, serverUrl: '', deviceId: '', unpushedOps: 0, lastSyncAt: '', syncInterval: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
showSettings = true
|
||||
syncServerUrl = syncStatus?.serverUrl || ''
|
||||
syncUsername = ''
|
||||
syncPassword = ''
|
||||
syncInterval = syncStatus?.syncInterval || 0
|
||||
syncResult = ''
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
showSettings = false
|
||||
syncResult = ''
|
||||
}
|
||||
|
||||
async function saveSyncConfig() {
|
||||
syncLoading = true
|
||||
syncResult = ''
|
||||
try {
|
||||
await wailsCall('SyncConfigure', syncServerUrl, syncUsername, syncPassword)
|
||||
if (syncInterval > 0) {
|
||||
await wailsCall('SyncSetInterval', syncInterval)
|
||||
}
|
||||
syncResult = 'ok'
|
||||
await loadSyncStatus()
|
||||
} catch (e) {
|
||||
syncResult = 'err: ' + String(e)
|
||||
}
|
||||
syncLoading = false
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
syncLoading = true
|
||||
syncResult = ''
|
||||
try {
|
||||
await wailsCall('SyncTestConnection', syncServerUrl, syncUsername, syncPassword)
|
||||
syncResult = 'connection ok'
|
||||
} catch (e) {
|
||||
syncResult = 'connection failed: ' + String(e)
|
||||
}
|
||||
syncLoading = false
|
||||
}
|
||||
|
||||
async function runSyncNow() {
|
||||
syncLoading = true
|
||||
syncResult = ''
|
||||
try {
|
||||
const r = await wailsCall('SyncNow')
|
||||
syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (rev ' + r.serverRevision + ')'
|
||||
await loadSyncStatus()
|
||||
} catch (e) {
|
||||
syncResult = 'err: ' + String(e)
|
||||
}
|
||||
syncLoading = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
|
|
@ -989,14 +914,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<button class="sidebar-sync-btn" on:click={openSettings} title="Настройки синхронизации">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
<span class="sync-dot" class:active={syncStatus?.configured}></span>
|
||||
<span class="sidebar-sync-label">Синхронизация</span>
|
||||
</button>
|
||||
<span class="version">{version}</span>
|
||||
</div>
|
||||
<div class="sidebar-footer"><span class="version">{version}</span></div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
|
|
@ -1012,16 +930,6 @@
|
|||
<span class="crumb placeholder">Выберите раздел или дело</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{#if syncStatus?.configured}
|
||||
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title="Синхронизировать">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
{#if syncStatus.unpushedOps > 0}
|
||||
<span class="sync-badge">{syncStatus.unpushedOps}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
|
|
@ -1517,48 +1425,6 @@
|
|||
on:cancel={handleCancel}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showSettings}
|
||||
<div class="modal-overlay" on:click|self={closeSettings}>
|
||||
<div class="modal modal-sync">
|
||||
<h3>Настройки синхронизации</h3>
|
||||
{#if syncStatus}
|
||||
<div class="sync-status">
|
||||
<div class="sync-row"><span class="sync-label">Статус</span><span class="sync-value">{syncStatus.configured ? 'Включена' : 'Отключена'}</span></div>
|
||||
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl || '—'}</span></div>
|
||||
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value mono">{syncStatus.deviceId || '—'}</span></div>
|
||||
<div class="sync-row"><span class="sync-label">Неотправлено</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
|
||||
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt || '—'}</span></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="form-group">
|
||||
<label>URL сервера</label>
|
||||
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Логин</label>
|
||||
<input type="text" placeholder="username" bind:value={syncUsername} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Пароль</label>
|
||||
<input type="password" placeholder="password" bind:value={syncPassword} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Автосинхронизация (мин)</label>
|
||||
<input type="number" placeholder="0 = отключено" bind:value={syncInterval} min="0" />
|
||||
</div>
|
||||
{#if syncResult}
|
||||
<div class="sync-result">{syncResult}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button>
|
||||
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
|
||||
<button class="btn" on:click={runSyncNow} disabled={syncLoading || !syncStatus?.configured}>Синхронизировать</button>
|
||||
<button class="btn" on:click={closeSettings}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
@ -1578,18 +1444,12 @@
|
|||
.nav-item:hover { background: #222233; }
|
||||
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
|
||||
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
|
||||
.sidebar-footer { padding: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.version { font-size: 11px; color: #555; text-align: center; }
|
||||
.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
|
||||
.version { font-size: 11px; color: #555; }
|
||||
|
||||
/* Main */
|
||||
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
|
||||
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
|
||||
.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||
.header-right { display: flex; align-items: center; gap: 8px; }
|
||||
.header-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: #b0b0c0; font-family: inherit; font-size: 13px; position: relative; }
|
||||
.header-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
|
||||
.header-sync-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.sync-badge { background: #6366f1; color: #fff; font-size: 10px; border-radius: 50%; width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; position: absolute; top: -6px; right: -6px; }
|
||||
.crumb { font-size: 14px; font-weight: 500; }
|
||||
.crumb.placeholder { color: #666; }
|
||||
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
|
||||
|
|
@ -1776,18 +1636,4 @@
|
|||
.activity-feed-type { font-size: 11px; color: #666; }
|
||||
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
||||
.activity-feed-time { font-size: 11px; color: #555; }
|
||||
|
||||
/* Sync */
|
||||
.sidebar-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 8px 12px; cursor: pointer; width: 100%; display: flex; align-items: center; gap: 8px; color: #888; font-family: inherit; font-size: 13px; }
|
||||
.sidebar-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
|
||||
.sidebar-sync-label { flex: 1; text-align: left; }
|
||||
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a4a; flex-shrink: 0; }
|
||||
.sync-dot.active { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); }
|
||||
.modal-sync { width: 460px; }
|
||||
.sync-status { background: #13131f; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
|
||||
.sync-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
|
||||
.sync-label { color: #666; }
|
||||
.sync-value { color: #e4e4ef; }
|
||||
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||||
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ type SyncConfig struct {
|
|||
APIKey string `yaml:"api_key"`
|
||||
DeviceID string `yaml:"device_id"`
|
||||
AutoSync bool `yaml:"auto_sync"`
|
||||
SyncInterval int `yaml:"sync_interval"`
|
||||
}
|
||||
|
||||
type BrowserConfig struct {
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
package storage
|
||||
|
||||
// migration010 — sync_ops table for recording operations to push to sync server.
|
||||
const migration010 = `
|
||||
CREATE TABLE IF NOT EXISTS sync_ops (
|
||||
id TEXT PRIMARY KEY,
|
||||
op_id TEXT NOT NULL UNIQUE,
|
||||
device_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
pushed_at TEXT,
|
||||
applied_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_ops_pushed ON sync_ops(pushed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_ops_entity ON sync_ops(entity_type, entity_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
server_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
last_push_rev INTEGER NOT NULL DEFAULT 0,
|
||||
last_pull_rev INTEGER NOT NULL DEFAULT 0,
|
||||
last_sync_at TEXT
|
||||
);
|
||||
`
|
||||
|
|
@ -66,7 +66,6 @@ var migrationFiles = map[int]string{
|
|||
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
||||
8: migration008,
|
||||
9: migration009,
|
||||
10: migration010,
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// BlobDir returns the path to .verstak/blobs/ inside the vault.
|
||||
func BlobDir(vaultRoot string) string {
|
||||
return filepath.Join(vaultRoot, ".verstak", "blobs")
|
||||
}
|
||||
|
||||
// BlobPath returns the on-disk path for a SHA-256 hash.
|
||||
func BlobPath(blobsDir, shaHex string) string {
|
||||
return filepath.Join(blobsDir, shaHex[:2], shaHex[2:4], shaHex)
|
||||
}
|
||||
|
||||
// HashFile computes SHA-256 of a file.
|
||||
func HashFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// HashBytes computes SHA-256 of byte data.
|
||||
func HashBytes(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// StoreBlob copies a file into the blob store, returns its SHA-256.
|
||||
func StoreBlob(blobsDir, srcPath string) (string, error) {
|
||||
shaHex, err := HashFile(srcPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := BlobPath(blobsDir, shaHex)
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return shaHex, nil // already exists
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
src, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = dst.Sync()
|
||||
return shaHex, nil
|
||||
}
|
||||
|
||||
// ReadBlob reads a blob by SHA-256 hash.
|
||||
func ReadBlob(blobsDir, shaHex string) ([]byte, error) {
|
||||
return os.ReadFile(BlobPath(blobsDir, shaHex))
|
||||
}
|
||||
|
||||
// Ensure the package compiles without unused errors.
|
||||
var _ = fmt.Sprintf
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client communicates with the Verstak Sync Server.
|
||||
type Client struct {
|
||||
ServerURL string
|
||||
APIKey string
|
||||
DeviceID string
|
||||
VaultRoot string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a sync client.
|
||||
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
|
||||
return &Client{
|
||||
ServerURL: serverURL,
|
||||
APIKey: apiKey,
|
||||
DeviceID: deviceID,
|
||||
VaultRoot: vaultRoot,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterDevice calls POST /api/v1/device/register and returns the API key.
|
||||
func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
|
||||
body := map[string]string{"name": name}
|
||||
var resp struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
if err := c.post("/api/v1/device/register", body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.APIKey, nil
|
||||
}
|
||||
|
||||
// RegisterDeviceWithAuth registers a device with user credentials.
|
||||
func (c *Client) RegisterDeviceWithAuth(name, username, password string) (deviceID, apiKey string, err error) {
|
||||
body := map[string]string{"name": name, "username": username, "password": password}
|
||||
var resp struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
// Temporarily clear API key for this request (server expects login/password, not API key).
|
||||
savedKey := c.APIKey
|
||||
c.APIKey = ""
|
||||
err = c.post("/api/v1/device/register", body, &resp)
|
||||
c.APIKey = savedKey
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return resp.DeviceID, resp.APIKey, nil
|
||||
}
|
||||
|
||||
// Login authenticates with user credentials and returns a session token.
|
||||
func (c *Client) Login(username, password string) (token string, err error) {
|
||||
body := map[string]string{"username": username, "password": password}
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
savedKey := c.APIKey
|
||||
c.APIKey = ""
|
||||
err = c.post("/api/v1/auth/login", body, &resp)
|
||||
c.APIKey = savedKey
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Token, nil
|
||||
}
|
||||
|
||||
// PushRequest is the payload for POST /sync/push.
|
||||
type PushRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Ops []PushOp `json:"ops"`
|
||||
}
|
||||
|
||||
// PushOp is a single operation in a push request.
|
||||
type PushOp struct {
|
||||
OpID string `json:"op_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// PushResponse is the response from POST /sync/push.
|
||||
type PushResponse struct {
|
||||
Accepted []string `json:"accepted"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Push sends local operations to the server.
|
||||
func (c *Client) Push(ops []Op) (*PushResponse, error) {
|
||||
pushOps := make([]PushOp, len(ops))
|
||||
for i, op := range ops {
|
||||
pushOps[i] = PushOp{
|
||||
OpID: op.OpID,
|
||||
EntityType: op.EntityType,
|
||||
EntityID: op.EntityID,
|
||||
OpType: op.OpType,
|
||||
PayloadJSON: op.PayloadJSON,
|
||||
CreatedAt: op.CreatedAt,
|
||||
}
|
||||
}
|
||||
req := PushRequest{DeviceID: c.DeviceID, Ops: pushOps}
|
||||
var resp PushResponse
|
||||
if err := c.post("/api/v1/sync/push", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// PullRequest is the payload for POST /sync/pull.
|
||||
type PullRequest struct {
|
||||
SinceRevision int `json:"since_revision"`
|
||||
}
|
||||
|
||||
// PullResponse is the response from POST /sync/pull.
|
||||
type PullResponse struct {
|
||||
ServerRevision int `json:"server_revision"`
|
||||
Ops []Op `json:"ops"`
|
||||
}
|
||||
|
||||
// Pull fetches remote operations since a given revision.
|
||||
func (c *Client) Pull(sinceRevision int) (*PullResponse, error) {
|
||||
req := PullRequest{SinceRevision: sinceRevision}
|
||||
var resp PullResponse
|
||||
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UploadBlob uploads a file to the server and returns its SHA-256.
|
||||
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(fw, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
w.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.SHA256, nil
|
||||
}
|
||||
|
||||
// DownloadBlob downloads a blob by SHA-256 hash.
|
||||
func (c *Client) DownloadBlob(sha256, destPath string) error {
|
||||
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- internal ---
|
||||
|
||||
func (c *Client) post(path string, body, result interface{}) error {
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(body); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// Entity types (matches activity targets).
|
||||
const (
|
||||
EntityNode = "node"
|
||||
EntityNote = "note"
|
||||
EntityFile = "file"
|
||||
EntityFolder = "folder"
|
||||
EntityAction = "action"
|
||||
EntityWorklog = "worklog"
|
||||
)
|
||||
|
||||
// Op types.
|
||||
const (
|
||||
OpCreate = "create"
|
||||
OpUpdate = "update"
|
||||
OpDelete = "delete"
|
||||
OpMove = "move"
|
||||
)
|
||||
|
||||
// Op represents a sync operation.
|
||||
type Op struct {
|
||||
ID string `json:"id"`
|
||||
OpID string `json:"op_id"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PushedAt *string `json:"pushed_at,omitempty"`
|
||||
}
|
||||
|
||||
// Service records and manages sync operations.
|
||||
type Service struct {
|
||||
db *storage.DB
|
||||
deviceID string
|
||||
}
|
||||
|
||||
// NewService creates a sync service.
|
||||
func NewService(db *storage.DB, deviceID string) *Service {
|
||||
return &Service{db: db, deviceID: deviceID}
|
||||
}
|
||||
|
||||
// RecordOp writes a sync operation to the local sync_ops table.
|
||||
func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error {
|
||||
id := util.UUID7()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
var payloadStr string
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payloadStr = string(b)
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, id, s.deviceID, entityType, entityID, opType, payloadStr, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUnpushedOps returns ops that have not been pushed yet.
|
||||
func (s *Service) GetUnpushedOps() ([]Op, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at
|
||||
FROM sync_ops WHERE pushed_at IS NULL ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanOps(rows)
|
||||
}
|
||||
|
||||
// MarkPushed marks ops as pushed to server.
|
||||
func (s *Service) MarkPushed(opIDs []string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
for _, id := range opIDs {
|
||||
_, err := s.db.Exec("UPDATE sync_ops SET pushed_at=? WHERE op_id=?", now, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkApplied marks remote ops as applied locally.
|
||||
func (s *Service) MarkApplied(opIDs []string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
for _, id := range opIDs {
|
||||
_, err := s.db.Exec("UPDATE sync_ops SET applied_at=? WHERE op_id=?", now, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetState returns the current sync state.
|
||||
func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyncAt string, err error) {
|
||||
err = s.db.QueryRow(
|
||||
`SELECT server_url, api_key, last_push_rev, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`,
|
||||
s.deviceID).Scan(&serverURL, &apiKey, &lastPushRev, &lastSyncAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", "", 0, "", nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SetState saves sync connection state.
|
||||
func (s *Service) SetState(serverURL, apiKey string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO sync_state (device_id, server_url, api_key, last_push_rev, last_sync_at)
|
||||
VALUES (?, ?, ?, 0, '')
|
||||
ON CONFLICT(device_id) DO UPDATE SET server_url=excluded.server_url, api_key=excluded.api_key`,
|
||||
s.deviceID, serverURL, apiKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func scanOps(rows *sql.Rows) ([]Op, error) {
|
||||
var out []Op
|
||||
for rows.Next() {
|
||||
var o Op
|
||||
var pushedAt sql.NullString
|
||||
if err := rows.Scan(&o.ID, &o.OpID, &o.DeviceID, &o.EntityType, &o.EntityID,
|
||||
&o.OpType, &o.PayloadJSON, &o.CreatedAt, &pushedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pushedAt.Valid {
|
||||
o.PushedAt = &pushedAt.String
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MustVar ensures the package is not considered unused.
|
||||
var _ = fmt.Sprintf
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Verstak Sync E2E Smoke Test ==="
|
||||
|
||||
SERVER_PORT=18999
|
||||
SDIR=$(mktemp -d)
|
||||
VD=$(mktemp -d)
|
||||
cleanup() { kill "$SPID" 2>/dev/null || true; rm -rf "$SDIR" "$VD"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo ":: Build"
|
||||
go build -o /tmp/vs-server ./cmd/verstak-server/
|
||||
go build -o /tmp/vs-cli ./cmd/verstak/
|
||||
|
||||
echo ":: Start server"
|
||||
/tmp/vs-server --port "$SERVER_PORT" --data "$SDIR" --admin-user admin --admin-pass pass > /dev/null 2>&1 &
|
||||
SPID=$!
|
||||
sleep 2
|
||||
|
||||
echo ":: Health"
|
||||
curl -sf "http://localhost:$SERVER_PORT/api/v1/health" | grep -q '"ok"' && echo " OK"
|
||||
|
||||
echo ":: Register device"
|
||||
# Register user first
|
||||
curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"smokeuser","email":"smoke@test.local","password":"mypass123"}' > /dev/null
|
||||
# Confirm email
|
||||
TOKEN=$(sqlite3 "$SDIR/server.db" "SELECT token FROM server_email_tokens WHERE purpose='confirm' LIMIT 1")
|
||||
curl -sf "http://localhost:$SERVER_PORT/api/v1/auth/confirm?token=$TOKEN" > /dev/null
|
||||
# Register device with user creds
|
||||
REG=$(curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/device/register" \
|
||||
-H "Content-Type: application/json" -d '{"name":"smoke","username":"smokeuser","password":"mypass123"}')
|
||||
DID=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['device_id'])")
|
||||
AKEY=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])")
|
||||
echo " device=$DID"
|
||||
|
||||
echo ":: Init vault"
|
||||
/tmp/vs-cli init --vault "$VD"
|
||||
|
||||
echo ":: Configure sync"
|
||||
cat > "$VD/.verstak/config.yml" <<YML
|
||||
sync:
|
||||
server_url: http://localhost:$SERVER_PORT
|
||||
api_key: $AKEY
|
||||
device_id: $DID
|
||||
auto_sync: false
|
||||
YML
|
||||
|
||||
echo ":: Insert sync op (simulate recorded op)"
|
||||
sqlite3 "$VD/.verstak/index.db" \
|
||||
"INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at) VALUES ('e1','e1','$DID','node','n1','create','{\"title\":\"t\"}',datetime('now'));"
|
||||
|
||||
echo ":: Push"
|
||||
/tmp/vs-cli sync push --vault "$VD" | grep -q "Pushed 1" && echo " OK"
|
||||
|
||||
echo ":: Pull"
|
||||
/tmp/vs-cli sync pull --vault "$VD" | grep -q "Pulled" && echo " OK"
|
||||
|
||||
echo ":: Status"
|
||||
/tmp/vs-cli sync status --vault "$VD"
|
||||
|
||||
echo ""
|
||||
echo "=== ALL TESTS PASSED ==="
|
||||
Loading…
Reference in New Issue