feat: lodge server, client and admin

This commit is contained in:
hh committed 2026-09-19 23:33:24 +02:00
commit 95fc50e2c2
84 files changed
+7625

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
bin
storage
.env
.env.*
+2
View File
@@ -0,0 +1,2 @@
ADMIN_TOKEN=
PUBLIC_URL=
+2
View File
@@ -0,0 +1,2 @@
bin
storage
+60
View File
@@ -0,0 +1,60 @@
version: "2"
linters:
default: standard
enable:
- bodyclose
- contextcheck
- copyloopvar
- errorlint
- gocritic
- gosec
- misspell
- perfsprint
- revive
- unconvert
- unparam
- usestdlibvars
settings:
gosec:
excludes:
- G104
- G204
- G301
- G302
- G304
- G306
- G122
- G124
- G703
- G706
revive:
rules:
- name: exported
disabled: true
- name: package-comments
disabled: true
errcheck:
exclude-functions:
- (*os.File).Close
- (io.Closer).Close
- (*database/sql.Rows).Close
- (*database/sql.Tx).Rollback
- (*lodge/internal/server.Store).Close
- (*github.com/fsnotify/fsnotify.Watcher).Close
- os.Unsetenv
- os.Remove
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
formatters:
enable:
- gofumpt
- goimports
settings:
gofumpt:
module-path: lodge
goimports:
local-prefixes:
- lodge
+25
View File
@@ -0,0 +1,25 @@
FROM golang:1.27-alpine@sha256:4cb7ac979db5fcc41cae44b2227ba5ab8a51e8807f40d9ba4dee20a0ad960b5b AS build
WORKDIR /src
ENV CGO_ENABLED=0
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN go build -trimpath -ldflags "-s -w" -o /out/lodge-server ./cmd/lodge-server
RUN for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64; do \
GOOS=${target%/*} GOARCH=${target#*/} go build -trimpath -ldflags "-s -w" -o /out/dl/lodge-${target%/*}-${target#*/} ./cmd/lodge || exit 1; \
done
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
WORKDIR /app
COPY --from=build /out/lodge-server /usr/local/bin/lodge-server
COPY --from=build /out/dl ./dl
CMD ["lodge-server"]
+35
View File
@@ -0,0 +1,35 @@
COMPOSE := cd .. && docker compose
IMAGE := lodge/backend
LINT := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2
changed = ! git diff --quiet $(1) -- $(2) 2>/dev/null
stamp = { git update-ref $(1) HEAD 2>/dev/null || true; }
.PHONY: fmt check test build pre-deploy post-deploy client dev
fmt:
go mod tidy
$(LINT) fmt
check:
$(LINT) run
$(MAKE) test
test:
go test -race ./...
build:
$(COMPOSE) build api
pre-deploy:
@if $(call changed,refs/deploy/backend,.) || ! docker image inspect $(IMAGE) >/dev/null 2>&1; then \
$(MAKE) build && $(call stamp,refs/deploy/backend); \
fi
post-deploy:
@:
client:
go build -trimpath -ldflags "-s -w" -o bin/lodge ./cmd/lodge
dev:
LODGE_DB=storage/lodge.db STATIC_DIR=../frontend/build CLIENTS_DIR=bin ADMIN_TOKEN=dev go run ./cmd/lodge-server
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"cmp"
"log"
"net/http"
"os"
"path/filepath"
"time"
"lodge/internal/server"
)
func main() {
database := cmp.Or(os.Getenv("LODGE_DB"), "/app/storage/lodge.db")
if err := os.MkdirAll(filepath.Dir(database), 0o700); err != nil {
log.Fatal(err)
}
store, err := server.OpenStore(database)
if err != nil {
log.Fatal(err)
}
config := server.Config{
AdminToken: os.Getenv("ADMIN_TOKEN"),
PublicURL: os.Getenv("PUBLIC_URL"),
StaticDir: cmp.Or(os.Getenv("STATIC_DIR"), "/app/static"),
ClientsDir: cmp.Or(os.Getenv("CLIENTS_DIR"), "/app/dl"),
}
if config.AdminToken == "" {
log.Print("ADMIN_TOKEN is empty: no machine can be added until it is set")
}
httpServer := &http.Server{
Addr: cmp.Or(os.Getenv("LISTEN"), ":8080"),
Handler: server.New(store, config),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("listening on %s", httpServer.Addr)
log.Fatal(httpServer.ListenAndServe())
}
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"lodge/internal/client"
"lodge/internal/gitrepo"
"lodge/internal/protocol"
)
const usage = `lodge keeps agent files out of git and in sync across machines
lodge login <url> <code> connect this machine using a code from the admin page
lodge install start the daemon, install the git hook and the agent skill
lodge uninstall undo install
lodge update replace this binary with the one the server ships
lodge add [dir] start syncing a repository that was cloned before install
lodge forget [dir] stop syncing a repository
lodge sync [dir] sync once, for machines without a daemon
lodge status [dir] rules, files and oversized files of a repository
lodge log [path] revisions of the repository or of one file
lodge show <rev|sha> <path> a file as of a revision or a commit of the main repository
lodge daemon run the sync daemon in the foreground
status, log and sync accept --json
`
var hookEnvironment = []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"}
type status struct {
Root string `json:"root"`
Key string `json:"key"`
Branch string `json:"branch"`
Rules []client.Rule `json:"rules"`
Revision int64 `json:"revision"`
Spaces map[string]protocol.Manifest `json:"spaces"`
}
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
if os.Args[1] == "hook" {
hook(ctx)
return
}
err := run(ctx, os.Args[1], os.Args[2:])
stop()
if err != nil {
fmt.Fprintln(os.Stderr, "lodge:", err)
os.Exit(1)
}
}
func hook(ctx context.Context) {
for _, name := range hookEnvironment {
os.Unsetenv(name)
}
if c, err := client.Load(); err == nil {
_ = c.Hook(ctx, ".")
}
}
func run(ctx context.Context, name string, args []string) error {
flags := flag.NewFlagSet(name, flag.ExitOnError)
asJSON := flags.Bool("json", false, "machine-readable output")
_ = flags.Parse(args)
args = flags.Args()
target := func(fallback string) string {
if len(args) > 0 {
return args[0]
}
return fallback
}
switch name {
case "login":
if len(args) != 2 {
return errors.New("usage: lodge login <url> <code>")
}
return client.Login(ctx, args[0], args[1])
case "help", "-h", "--help":
fmt.Print(usage)
return nil
}
c, err := client.Load()
if err != nil {
return err
}
switch name {
case "install":
return c.Install()
case "uninstall":
return c.Uninstall()
case "daemon":
return c.Daemon(ctx)
case "update":
updated, err := c.Update(ctx)
if err == nil && !updated {
fmt.Println("already up to date")
}
return err
case "add", "sync":
res, err := c.Sync(ctx, target("."), true)
if err != nil {
return err
}
return output(*asJSON, res, func() {
fmt.Printf("pushed %d, pulled %d\n", res.Pushed, res.Pulled)
for _, path := range res.Skipped {
fmt.Printf("skipped, over 5 MB: %s\n", path)
}
})
case "forget":
root, err := filepath.Abs(target("."))
if err != nil {
return err
}
if repo, err := gitrepo.Open(root); err == nil {
root = repo.Root
}
return c.Forget(root)
case "status":
return showStatus(ctx, c, target("."), *asJSON)
case "log":
return showLog(ctx, c, target(""), *asJSON)
case "show":
if len(args) != 2 {
return errors.New("usage: lodge show <revision|commit-sha> <path>")
}
return showFile(ctx, c, args[0], args[1])
}
return fmt.Errorf("unknown command %q, see lodge help", name)
}
func output(asJSON bool, value any, human func()) error {
if !asJSON {
human()
return nil
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(value)
}
func showStatus(ctx context.Context, c *client.Client, dir string, asJSON bool) error {
repo, err := gitrepo.Open(dir)
if err != nil {
return err
}
state, err := c.State(ctx, repo.Key, "")
if err != nil {
return err
}
report := status{Root: repo.Root, Key: repo.Key, Branch: repo.Branch(), Rules: client.LoadRules(repo.Root), Revision: state.Seq, Spaces: state.Spaces}
return output(asJSON, report, func() {
fmt.Printf("%s\nkey %s, branch %s, revision %d\n\nrules:\n", report.Root, report.Key, report.Branch, report.Revision)
for _, rule := range report.Rules {
kind := "branch"
if rule.Shared {
kind = "shared"
}
fmt.Printf(" %s %s\n", kind, rule.Path)
}
for space, manifest := range report.Spaces {
fmt.Printf("\n%s:\n", space)
for path := range manifest {
fmt.Printf(" %s\n", path)
}
}
})
}
func showLog(ctx context.Context, c *client.Client, path string, asJSON bool) error {
repo, err := gitrepo.Open(".")
if err != nil {
return err
}
revisions, err := c.Revisions(ctx, repo.Key, path)
if err != nil {
return err
}
return output(asJSON, revisions, func() {
for _, rev := range revisions {
when := time.UnixMilli(rev.Time).Format("2006-01-02 15:04")
fmt.Printf("%d %s %s %s@%.10s\n", rev.Seq, when, rev.Machine, rev.MainBranch, rev.MainSHA)
for _, change := range rev.Changes {
action := "M"
if change.Base == "" {
action = "A"
} else if change.Hash == "" {
action = "D"
}
fmt.Printf(" %s %s (%s)\n", action, change.Path, change.Space)
}
}
})
}
func showFile(ctx context.Context, c *client.Client, at, path string) error {
repo, err := gitrepo.Open(".")
if err != nil {
return err
}
state, err := c.State(ctx, repo.Key, at)
if err != nil {
return err
}
for _, space := range []string{protocol.SharedSpace, protocol.BranchSpace(repo.Branch())} {
if file, found := state.Spaces[space][filepath.ToSlash(path)]; found {
data, err := c.Blob(ctx, file.Hash)
if err == nil {
_, err = os.Stdout.Write(data)
}
return err
}
}
return fmt.Errorf("%s did not exist at %s", path, at)
}
@@ -0,0 +1,11 @@
services:
api:
networks:
default: {}
caddy:
aliases:
- lodge-api
networks:
caddy:
external: true
+14
View File
@@ -0,0 +1,14 @@
services:
api:
build: backend
image: lodge/backend
profiles: [api, services]
restart: unless-stopped
env_file:
- path: .env
required: false
- path: backend/.env
required: false
volumes:
- ./frontend/build:/app/static:ro
- ${STORAGE_ROOT:-./backend/storage}:/app/storage
+20
View File
@@ -0,0 +1,20 @@
module lodge
go 1.27.1
require (
github.com/fsnotify/fsnotify v1.10.1
modernc.org/sqlite v1.59.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+52
View File
@@ -0,0 +1,52 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+224
View File
@@ -0,0 +1,224 @@
package client
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
"lodge/internal/protocol"
)
const requestTimeout = 15 * time.Second
var (
ErrNotLoggedIn = errors.New("not logged in: run the install command from the lodge admin page")
errConflict = errors.New("server state changed")
)
type Client struct {
URL string `json:"url"`
Token string `json:"token"`
DataDir string `json:"-"`
}
func configPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(cmp.Or(os.Getenv("XDG_CONFIG_HOME"), filepath.Join(home, ".config")), "lodge", "config.json")
}
func dataDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(cmp.Or(os.Getenv("XDG_DATA_HOME"), filepath.Join(home, ".local", "share")), "lodge")
}
func Load() (*Client, error) {
c := &Client{DataDir: dataDir()}
if data, err := os.ReadFile(configPath()); err == nil {
if err := json.Unmarshal(data, c); err != nil {
return nil, err
}
}
c.URL = strings.TrimSuffix(cmp.Or(os.Getenv("LODGE_URL"), c.URL), "/")
c.Token = cmp.Or(os.Getenv("LODGE_TOKEN"), c.Token)
if c.URL == "" || c.Token == "" {
return nil, ErrNotLoggedIn
}
return c, nil
}
func Login(ctx context.Context, serverURL, code string) error {
name, _ := os.Hostname()
c := &Client{URL: strings.TrimSuffix(serverURL, "/")}
var claim protocol.ClaimResponse
if err := c.call(ctx, http.MethodPost, "/api/claim", protocol.ClaimRequest{Code: code, Name: name}, &claim); err != nil {
return err
}
c.Token = claim.Token
return writeJSONFile(configPath(), c)
}
func writeJSONFile(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
return writeFile(path, data, 0o600)
}
func writeFile(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
temp := path + ".lodge-tmp"
if err := os.WriteFile(temp, data, mode); err != nil {
return err
}
return os.Rename(temp, path)
}
func (c *Client) request(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, c.URL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode < http.StatusBadRequest {
return res, nil
}
defer res.Body.Close()
message, _ := io.ReadAll(io.LimitReader(res.Body, 1024))
if res.StatusCode == http.StatusConflict {
return nil, errConflict
}
return nil, fmt.Errorf("%s %s: %s: %s", method, path, res.Status, strings.TrimSpace(string(message)))
}
func (c *Client) call(ctx context.Context, method, path string, in, out any) error {
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
var body []byte
if in != nil {
body, _ = json.Marshal(in)
}
res, err := c.request(ctx, method, path, body)
if err != nil {
return err
}
defer res.Body.Close()
if out == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(out)
}
func (c *Client) State(ctx context.Context, key, at string) (protocol.State, error) {
var state protocol.State
err := c.call(ctx, http.MethodGet, "/api/repos/"+key+"/state?at="+url.QueryEscape(at), nil, &state)
return state, err
}
func (c *Client) Revisions(ctx context.Context, key, path string) ([]protocol.Revision, error) {
var revisions []protocol.Revision
err := c.call(ctx, http.MethodGet, "/api/repos/"+key+"/revisions?path="+url.QueryEscape(path), nil, &revisions)
return revisions, err
}
func hashOf(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func (c *Client) blobPath(hash string) string {
return filepath.Join(c.DataDir, "blobs", hash[:2], hash)
}
func (c *Client) cacheBlob(data []byte) (string, error) {
hash := hashOf(data)
path := c.blobPath(hash)
if _, err := os.Stat(path); err == nil {
return hash, nil
}
return hash, writeFile(path, data, 0o600)
}
func (c *Client) Blob(ctx context.Context, hash string) ([]byte, error) {
if data, err := os.ReadFile(c.blobPath(hash)); err == nil {
return data, nil
}
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
res, err := c.request(ctx, http.MethodGet, "/api/blobs/"+hash, nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if hashOf(data) != hash {
return nil, fmt.Errorf("blob %s arrived corrupted", hash)
}
_, err = c.cacheBlob(data)
return data, err
}
func (c *Client) uploadBlob(ctx context.Context, hash string) error {
data, err := os.ReadFile(c.blobPath(hash))
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
defer cancel()
res, err := c.request(ctx, http.MethodPut, "/api/blobs/"+hash, data)
if err != nil {
return err
}
return res.Body.Close()
}
func (c *Client) registryPath() string {
return filepath.Join(c.DataDir, "worktrees.json")
}
func (c *Client) Worktrees() []string {
var roots []string
if data, err := os.ReadFile(c.registryPath()); err == nil {
_ = json.Unmarshal(data, &roots)
}
return roots
}
func (c *Client) register(root string) error {
roots := c.Worktrees()
if slices.Contains(roots, root) {
return nil
}
roots = slices.DeleteFunc(roots, func(known string) bool {
_, err := os.Stat(known)
return err != nil
})
return writeJSONFile(c.registryPath(), append(roots, root))
}
func (c *Client) Forget(root string) error {
roots := slices.DeleteFunc(c.Worktrees(), func(known string) bool { return known == root })
return writeJSONFile(c.registryPath(), roots)
}
+222
View File
@@ -0,0 +1,222 @@
package client
import (
"bufio"
"context"
"encoding/json"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"lodge/internal/gitrepo"
"lodge/internal/protocol"
"github.com/fsnotify/fsnotify"
)
const (
debounce = 2 * time.Second
fullSyncEvery = 5 * time.Minute
minBackoff = 5 * time.Second
maxBackoff = time.Minute
headFile = "HEAD"
)
type daemon struct {
client *Client
watcher *fsnotify.Watcher
queue chan string
mu sync.Mutex
owners map[string]string
gitDirs map[string]bool
keys map[string]string
pending map[string]*time.Timer
}
func (c *Client) Daemon(ctx context.Context) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer watcher.Close()
if err := os.MkdirAll(c.DataDir, 0o755); err != nil {
return err
}
if err := watcher.Add(c.DataDir); err != nil {
return err
}
d := &daemon{
client: c,
watcher: watcher,
queue: make(chan string, 256),
owners: map[string]string{},
gitDirs: map[string]bool{},
keys: map[string]string{},
pending: map[string]*time.Timer{},
}
go d.listen(ctx)
go d.work(ctx)
d.syncAll()
ticker := time.NewTicker(fullSyncEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
d.syncAll()
case err := <-watcher.Errors:
log.Printf("watcher: %v", err)
case event := <-watcher.Events:
d.handle(event)
}
}
}
func (d *daemon) handle(event fsnotify.Event) {
if event.Name == d.client.registryPath() {
d.syncAll()
return
}
dir := filepath.Dir(event.Name)
d.mu.Lock()
root, watched := d.owners[dir]
irrelevant := d.gitDirs[dir] && filepath.Base(event.Name) != headFile
d.mu.Unlock()
if watched && !irrelevant && !strings.HasSuffix(event.Name, tempSuffix) {
d.schedule(root)
}
}
func (d *daemon) schedule(root string) {
d.mu.Lock()
defer d.mu.Unlock()
if timer, waiting := d.pending[root]; waiting {
timer.Reset(debounce)
return
}
d.pending[root] = time.AfterFunc(debounce, func() {
d.mu.Lock()
delete(d.pending, root)
d.mu.Unlock()
d.queue <- root
})
}
func (d *daemon) syncAll() {
for _, root := range d.client.Worktrees() {
d.schedule(root)
}
}
func (d *daemon) work(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case root := <-d.queue:
res, err := d.client.Sync(ctx, root, true)
if err != nil {
log.Printf("%s: %v", root, err)
} else if res.Pushed+res.Pulled > 0 {
log.Printf("%s: pushed %d, pulled %d", root, res.Pushed, res.Pulled)
}
d.watch(root)
}
}
}
func (d *daemon) watch(root string) {
repo, err := gitrepo.Open(root)
if err != nil {
return
}
dirs := []string{repo.GitDir}
for _, rule := range LoadRules(repo.Root) {
target := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
info, err := os.Stat(target)
for err != nil && target != repo.Root {
target = filepath.Dir(target)
info, err = os.Stat(target)
}
if err != nil {
continue
}
if !info.IsDir() {
dirs = append(dirs, filepath.Dir(target))
continue
}
_ = filepath.WalkDir(target, func(path string, entry fs.DirEntry, err error) error {
if err != nil || !entry.IsDir() {
return nil
}
if entry.Name() == ".git" {
return filepath.SkipDir
}
dirs = append(dirs, path)
return nil
})
}
d.mu.Lock()
defer d.mu.Unlock()
d.keys[repo.Root] = repo.Key
d.gitDirs[repo.GitDir] = true
for _, dir := range dirs {
if _, watching := d.owners[dir]; !watching && d.watcher.Add(dir) == nil {
d.owners[dir] = repo.Root
}
}
}
func (d *daemon) listen(ctx context.Context) {
backoff := minBackoff
for ctx.Err() == nil {
started := time.Now()
err := d.stream(ctx)
if time.Since(started) > maxBackoff {
backoff = minBackoff
}
log.Printf("event stream closed, retrying in %s: %v", backoff, err)
select {
case <-ctx.Done():
case <-time.After(backoff):
}
backoff = min(backoff*2, maxBackoff)
}
}
func (d *daemon) stream(ctx context.Context) error {
res, err := d.client.request(ctx, http.MethodGet, "/api/events", nil)
if err != nil {
return err
}
defer res.Body.Close()
d.syncAll()
scanner := bufio.NewScanner(res.Body)
for scanner.Scan() {
data, isEvent := strings.CutPrefix(scanner.Text(), "data: ")
var event protocol.Event
if !isEvent || json.Unmarshal([]byte(data), &event) != nil {
continue
}
d.mu.Lock()
var roots []string
for root, key := range d.keys {
if key == event.Repo {
roots = append(roots, root)
}
}
d.mu.Unlock()
for _, root := range roots {
d.schedule(root)
}
}
return scanner.Err()
}
+195
View File
@@ -0,0 +1,195 @@
package client
import (
"context"
_ "embed"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const (
serviceLabel = "com.kotikot.lodge"
systemdUnit = "lodge.service"
hookName = "post-checkout"
)
//go:embed skill.md
var skill []byte
var skillDirs = []string{".claude/skills/lodge", ".agents/skills/lodge"}
func launchdPlist(binary, logFile string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>%s</string>
<key>ProgramArguments</key><array><string>%s</string><string>daemon</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Background</string>
<key>StandardErrorPath</key><string>%s</string>
</dict>
</plist>
`, serviceLabel, binary, logFile)
}
func systemdService(binary string) string {
return fmt.Sprintf(`[Unit]
Description=lodge sync daemon
[Service]
ExecStart=%s daemon
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
`, binary)
}
func command(name string, args ...string) error {
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
func launchdTarget() string {
return "gui/" + strconv.Itoa(os.Getuid())
}
func plistPath(home string) string {
return filepath.Join(home, "Library", "LaunchAgents", serviceLabel+".plist")
}
func unitPath(home string) string {
return filepath.Join(home, ".config", "systemd", "user", systemdUnit)
}
func (c *Client) templateDir() string {
return filepath.Join(c.DataDir, "git-template")
}
func (c *Client) Install() error {
binary, err := os.Executable()
if err != nil {
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
if err := c.installHook(binary); err != nil {
return err
}
for _, dir := range skillDirs {
if err := writeFile(filepath.Join(home, dir, "SKILL.md"), skill, 0o644); err != nil {
return err
}
}
return startService(home, binary, filepath.Join(c.DataDir, "daemon.log"))
}
func (c *Client) installHook(binary string) error {
template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output()
dir := strings.TrimSpace(string(template))
if dir == "" {
dir = c.templateDir()
if err := command("git", "config", "--global", "init.templateDir", dir); err != nil {
return err
}
}
if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(dir, "~/") {
dir = filepath.Join(home, dir[2:])
}
hook := filepath.Join(dir, "hooks", hookName)
if existing, err := os.ReadFile(hook); err == nil && !strings.Contains(string(existing), "lodge hook") {
fmt.Fprintf(os.Stderr, "lodge: %s already exists, add this line to it yourself: %q hook\n", hook, binary)
return nil
}
script := fmt.Sprintf("#!/bin/sh\n[ -x %q ] && %q hook\nexit 0\n", binary, binary)
return writeFile(hook, []byte(script), 0o755)
}
func startService(home, binary, logFile string) error {
if runtime.GOOS == "darwin" {
plist := plistPath(home)
if err := writeFile(plist, []byte(launchdPlist(binary, logFile)), 0o644); err != nil {
return err
}
_ = command("launchctl", "bootout", launchdTarget()+"/"+serviceLabel)
return command("launchctl", "bootstrap", launchdTarget(), plist)
}
if err := writeFile(unitPath(home), []byte(systemdService(binary)), 0o644); err != nil {
return err
}
if err := command("systemctl", "--user", "daemon-reload"); err != nil {
return err
}
return command("systemctl", "--user", "enable", "--now", systemdUnit)
}
func restartService() error {
if runtime.GOOS == "darwin" {
return command("launchctl", "kickstart", "-k", launchdTarget()+"/"+serviceLabel)
}
return command("systemctl", "--user", "restart", systemdUnit)
}
func (c *Client) Uninstall() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
if runtime.GOOS == "darwin" {
_ = command("launchctl", "bootout", launchdTarget()+"/"+serviceLabel)
_ = os.Remove(plistPath(home))
} else {
_ = command("systemctl", "--user", "disable", "--now", systemdUnit)
_ = os.Remove(unitPath(home))
}
template, _ := exec.Command("git", "config", "--global", "--get", "init.templateDir").Output()
if strings.TrimSpace(string(template)) == c.templateDir() {
_ = command("git", "config", "--global", "--unset", "init.templateDir")
}
for _, dir := range skillDirs {
_ = os.RemoveAll(filepath.Join(home, dir))
}
return os.RemoveAll(c.templateDir())
}
func (c *Client) Update(ctx context.Context) (bool, error) {
binary, err := os.Executable()
if err != nil {
return false, err
}
current, err := os.ReadFile(binary)
if err != nil {
return false, err
}
res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/dl/lodge-%s-%s", runtime.GOOS, runtime.GOARCH), nil)
if err != nil {
return false, err
}
defer res.Body.Close()
latest, err := io.ReadAll(res.Body)
if err != nil {
return false, err
}
if hashOf(latest) == hashOf(current) {
return false, nil
}
if err := writeFile(binary, latest, 0o755); err != nil {
return false, err
}
return true, restartService()
}
+89
View File
@@ -0,0 +1,89 @@
package client
import (
"bytes"
"os"
"path"
"path/filepath"
"strings"
"lodge/internal/gitrepo"
"lodge/internal/protocol"
)
const (
RulesFile = ".lodge"
sharedKeyword = "shared"
branchKeyword = "branch"
excludeBegin = "# lodge begin"
excludeEnd = "# lodge end"
DefaultRules = "shared docs/agents\nshared CLAUDE.local.md\nbranch .agent\n"
)
type Rule struct {
Path string `json:"path"`
Shared bool `json:"shared"`
}
type Rules []Rule
func parseRules(text string) Rules {
rules := Rules{{Path: RulesFile, Shared: true}}
for _, line := range strings.Split(text, "\n") {
fields := strings.Fields(line)
if len(fields) != 2 || fields[0] != sharedKeyword && fields[0] != branchKeyword {
continue
}
rulePath := path.Clean(strings.Trim(filepath.ToSlash(fields[1]), "/"))
if protocol.SafePath(rulePath) {
rules = append(rules, Rule{Path: rulePath, Shared: fields[0] == sharedKeyword})
}
}
return rules
}
func LoadRules(root string) Rules {
data, err := os.ReadFile(filepath.Join(root, RulesFile))
if err != nil {
return parseRules(DefaultRules)
}
return parseRules(string(data))
}
func (rules Rules) paths() []string {
paths := make([]string, len(rules))
for i, rule := range rules {
paths[i] = rule.Path
}
return paths
}
func (rules Rules) covers(file string, shared bool) bool {
best := Rule{}
for _, rule := range rules {
matches := file == rule.Path || strings.HasPrefix(file, rule.Path+"/")
if matches && len(rule.Path) > len(best.Path) {
best = rule
}
}
return best.Path != "" && best.Shared == shared
}
func writeExclude(repo *gitrepo.Repo, rules Rules) error {
file := filepath.Join(repo.CommonDir, "info", "exclude")
current, _ := os.ReadFile(file)
kept := current
if before, rest, found := bytes.Cut(current, []byte(excludeBegin)); found {
_, after, _ := bytes.Cut(rest, []byte(excludeEnd+"\n"))
kept = append(bytes.Clone(before), after...)
}
if len(kept) > 0 && !bytes.HasSuffix(kept, []byte("\n")) {
kept = append(kept, '\n')
}
block := excludeBegin + "\n/" + strings.Join(rules.paths(), "\n/") + "\n" + excludeEnd + "\n"
kept = append(kept, block...)
if bytes.Equal(kept, current) {
return nil
}
return writeFile(file, kept, 0o644)
}
+32
View File
@@ -0,0 +1,32 @@
---
name: lodge
description: Where to keep agent notes, plans and working docs so they stay out of git but follow the repository across machines and branches. Use when writing plans, notes, research or handoff docs into a repository, when such files seem to be missing from git status, or when you need what was written earlier at a given commit.
---
# lodge
This machine runs lodge. It syncs agent files that are deliberately kept out of git: they are hidden through `.git/info/exclude`, stored on a separate server, versioned there, and restored automatically after `git clone` on any machine.
## Where to write
Run `lodge status` inside the repository to see the rules in effect. The defaults are:
- `docs/agents/` holds what is true for the whole repository: architecture notes, conventions, research on the codebase. It stays in place when branches change.
- `.agent/` holds what belongs to the current branch: the plan for the feature, what was tried, why something was reverted. It is swapped on `git checkout` together with the branch.
- `CLAUDE.local.md` is shared across branches.
The rules live in `.lodge` at the repository root, one per line, as `shared <path>` or `branch <path>`. Paths are relative to the root, no globs. The file itself is synced too.
## Rules of conduct
- These files are absent from `git status` on purpose. Never `git add -f` them and never add them to `.gitignore`.
- Files tracked by git are never touched by lodge, even under a synced path.
- Files over 5 MB are not synced; `lodge status` lists them.
- A deleted file is deleted on every machine. Its history stays on the server.
- Nothing needs to be run after writing a file: the daemon picks it up within seconds. Without a daemon (CI, cloud sandbox) run `lodge sync` at the start and at the end of the work.
## Reading history
- `lodge log [path]` lists revisions with the machine, the branch and the commit of the main repository at that moment.
- `lodge show <revision|commit-sha> <path>` prints a file as it was at that revision, or as it was while that commit was checked out.
- `--json` on `status`, `log` and `sync` gives machine-readable output.
+371
View File
@@ -0,0 +1,371 @@
package client
import (
"cmp"
"context"
"encoding/json"
"errors"
"io/fs"
"maps"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"lodge/internal/gitrepo"
"lodge/internal/protocol"
)
const (
maxPasses = 4
tempSuffix = ".lodge-tmp"
)
type worktreeState struct {
Branch string `json:"branch"`
Base map[string]protocol.Manifest `json:"base"`
Stash map[string]protocol.Manifest `json:"stash"`
}
type Result struct {
Pushed int `json:"pushed"`
Pulled int `json:"pulled"`
Skipped []string `json:"skipped"`
}
type diskFiles struct {
shared protocol.Manifest
branch protocol.Manifest
skipped []string
tracked map[string]bool
}
func (c *Client) statePath(repo *gitrepo.Repo) string {
return filepath.Join(c.DataDir, "repos", repo.Key, hashOf([]byte(repo.Root))[:16]+".json")
}
func (c *Client) loadState(repo *gitrepo.Repo) *worktreeState {
st := &worktreeState{}
if data, err := os.ReadFile(c.statePath(repo)); err == nil {
_ = json.Unmarshal(data, st)
}
if st.Base == nil {
st.Base = map[string]protocol.Manifest{}
}
if st.Stash == nil {
st.Stash = map[string]protocol.Manifest{}
}
return st
}
func (c *Client) lock(repo *gitrepo.Repo) (func(), error) {
path := c.statePath(repo) + ".lock"
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
file.Close()
return nil, err
}
return func() { file.Close() }, nil
}
func (c *Client) Hook(ctx context.Context, dir string) error {
repo, err := gitrepo.Open(dir)
if err != nil {
return err
}
firstSight := !slices.Contains(c.Worktrees(), repo.Root)
_, err = c.Sync(ctx, repo.Root, firstSight)
return err
}
func (c *Client) Sync(ctx context.Context, dir string, online bool) (Result, error) {
var res Result
repo, err := gitrepo.Open(dir)
if err != nil {
return res, err
}
if err := c.register(repo.Root); err != nil {
return res, err
}
unlock, err := c.lock(repo)
if err != nil {
return res, err
}
defer unlock()
st := c.loadState(repo)
for range maxPasses {
again, err := c.pass(ctx, repo, st, online, &res)
saveErr := writeJSONFile(c.statePath(repo), st)
if err != nil || saveErr != nil || !again {
return res, errors.Join(err, saveErr)
}
}
return res, errors.New("gave up: server state keeps changing")
}
func (c *Client) scan(repo *gitrepo.Repo, rules Rules) diskFiles {
found := diskFiles{shared: protocol.Manifest{}, branch: protocol.Manifest{}, tracked: repo.Tracked(rules.paths())}
for _, rule := range rules {
start := filepath.Join(repo.Root, filepath.FromSlash(rule.Path))
_ = filepath.WalkDir(start, func(full string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if entry.IsDir() && entry.Name() == ".git" {
return filepath.SkipDir
}
if !entry.Type().IsRegular() || strings.HasSuffix(full, tempSuffix) {
return nil
}
rel, _ := filepath.Rel(repo.Root, full)
rel = filepath.ToSlash(rel)
if found.tracked[rel] || !rules.covers(rel, rule.Shared) {
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
if info.Size() > protocol.MaxBlobSize {
found.skipped = append(found.skipped, rel)
return nil
}
data, err := os.ReadFile(full)
if err != nil {
return nil
}
hash, err := c.cacheBlob(data)
if err != nil {
return nil
}
manifest := found.branch
if rule.Shared {
manifest = found.shared
}
manifest[rel] = protocol.File{Hash: hash, Time: info.ModTime().UnixMilli()}
return nil
})
}
return found
}
func reconcile(space string, local, base, remote protocol.Manifest, relevant func(string) bool) (first, second []protocol.Change, pull, next protocol.Manifest) {
pull, next = protocol.Manifest{}, maps.Clone(base)
if next == nil {
next = protocol.Manifest{}
}
paths := map[string]bool{}
for _, manifest := range []protocol.Manifest{local, base, remote} {
for path := range manifest {
paths[path] = true
}
}
for path := range paths {
if !relevant(path) {
continue
}
l, b, r := local[path], base[path], remote[path]
mine := protocol.Change{Space: space, Path: path, Base: r.Hash, Hash: l.Hash}
settled := r
switch {
case l.Hash == r.Hash:
case l.Hash == b.Hash, l.Hash == "" && r.Hash != b.Hash:
pull[path] = r
case r.Hash == b.Hash, r.Hash == "", l.Time >= r.Time:
first = append(first, mine)
settled = l
default:
first = append(first, mine)
second = append(second, protocol.Change{Space: space, Path: path, Base: l.Hash, Hash: r.Hash})
pull[path] = r
}
if settled.Hash == "" {
delete(next, path)
} else {
next[path] = settled
}
}
return first, second, pull, next
}
func (c *Client) pass(ctx context.Context, repo *gitrepo.Repo, st *worktreeState, online bool, res *Result) (bool, error) {
rules := LoadRules(repo.Root)
if err := writeExclude(repo, rules); err != nil {
return false, err
}
var remote *protocol.State
var offlineErr error
if online {
state, err := c.State(ctx, repo.Key, "")
remote, offlineErr = &state, err
if err != nil {
remote = nil
}
}
found := c.scan(repo, rules)
branch := cmp.Or(repo.Branch(), st.Branch)
if st.Branch != "" && branch != st.Branch {
if err := c.switchBranch(ctx, repo, st, rules, branch, found, remote); err != nil {
return false, err
}
found = c.scan(repo, rules)
}
st.Branch = branch
res.Skipped = found.skipped
if remote == nil {
return false, offlineErr
}
if remote.Seq == 0 {
st.Base = map[string]protocol.Manifest{}
}
locals := map[string]protocol.Manifest{protocol.SharedSpace: found.shared}
if branch != "" {
locals[protocol.BranchSpace(branch)] = found.branch
}
active := maps.Clone(locals)
for space, stashed := range st.Stash {
if active[space] == nil {
locals[space] = stashed
}
}
var first, second []protocol.Change
pulls, bases := map[string]protocol.Manifest{}, map[string]protocol.Manifest{}
spaces := map[string]bool{}
for _, group := range []map[string]protocol.Manifest{locals, st.Base, remote.Spaces} {
for space := range group {
spaces[space] = true
}
}
for space := range spaces {
local, syncing := locals[space]
if !syncing {
st.Base[space] = remote.Spaces[space]
if st.Base[space] == nil {
delete(st.Base, space)
}
continue
}
relevant := protocol.SafePath
if active[space] != nil {
relevant = func(path string) bool {
return protocol.SafePath(path) && rules.covers(path, space == protocol.SharedSpace) && !found.tracked[path]
}
}
mine, theirs, pull, next := reconcile(space, local, st.Base[space], remote.Spaces[space], relevant)
first, second, pulls[space], bases[space] = append(first, mine...), append(second, theirs...), pull, next
}
for _, change := range first {
if change.Hash == "" {
continue
}
if err := c.uploadBlob(ctx, change.Hash); err != nil {
return false, err
}
}
for _, changes := range [][]protocol.Change{first, second} {
if len(changes) == 0 {
continue
}
push := protocol.Push{Name: repo.Name, MainSHA: repo.Head(), MainBranch: branch, Changes: changes}
err := c.call(ctx, http.MethodPost, "/api/repos/"+repo.Key+"/revisions", push, nil)
if errors.Is(err, errConflict) {
return true, nil
}
if err != nil {
return false, err
}
}
res.Pushed += len(first)
again := false
for space, pull := range pulls {
for path, file := range pull {
if active[space] != nil {
if err := c.materialize(ctx, repo.Root, path, file); err != nil {
return false, err
}
} else if file.Hash == "" {
delete(st.Stash[space], path)
} else if _, err := c.Blob(ctx, file.Hash); err != nil {
return false, err
} else {
st.Stash[space][path] = file
}
res.Pulled++
again = again || path == RulesFile
}
}
maps.Copy(st.Base, bases)
return again, nil
}
func (c *Client) switchBranch(ctx context.Context, repo *gitrepo.Repo, st *worktreeState, rules Rules, branch string, found diskFiles, remote *protocol.State) error {
space := protocol.BranchSpace(branch)
st.Stash[protocol.BranchSpace(st.Branch)] = found.branch
known, exists := st.Stash[space]
if !exists {
known, exists = st.Base[space]
}
if !exists && remote != nil {
known, exists = remote.Spaces[space]
}
if !exists {
return nil
}
incoming := protocol.Manifest{}
for path, file := range known {
if !protocol.SafePath(path) || !rules.covers(path, false) || found.tracked[path] {
continue
}
if _, err := c.Blob(ctx, file.Hash); err != nil {
return err
}
incoming[path] = file
}
for path := range found.branch {
if err := c.materialize(ctx, repo.Root, path, protocol.File{}); err != nil {
return err
}
}
for path, file := range incoming {
if err := c.materialize(ctx, repo.Root, path, file); err != nil {
return err
}
}
delete(st.Stash, space)
return nil
}
func (c *Client) materialize(ctx context.Context, root, path string, file protocol.File) error {
full := filepath.Join(root, filepath.FromSlash(path))
if file.Hash != "" {
data, err := c.Blob(ctx, file.Hash)
if err != nil {
return err
}
return writeFile(full, data, 0o644)
}
if err := os.Remove(full); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
for dir := filepath.Dir(full); dir != root; dir = filepath.Dir(dir) {
if os.Remove(dir) != nil {
break
}
}
return nil
}
+251
View File
@@ -0,0 +1,251 @@
package client
import (
"context"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"lodge/internal/protocol"
"lodge/internal/server"
)
const adminToken = "admin"
type machine struct {
t *testing.T
client *Client
root string
}
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
func setup(t *testing.T) (*machine, *machine) {
t.Helper()
store, err := server.OpenStore(filepath.Join(t.TempDir(), "lodge.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
api := httptest.NewServer(server.New(store, server.Config{AdminToken: adminToken}))
t.Cleanup(api.Close)
origin := t.TempDir()
git(t, origin, "init", "-b", "main")
write(t, origin, "README.md", "readme")
write(t, origin, "docs/agents/tracked.md", "in git")
git(t, origin, "add", ".")
git(t, origin, "commit", "-m", "root")
machines := [2]*machine{}
for i := range machines {
root := filepath.Join(t.TempDir(), "clone")
git(t, origin, "clone", origin, root)
resolved, _ := filepath.EvalSymlinks(root)
machines[i] = &machine{t: t, root: resolved, client: &Client{URL: api.URL, Token: adminToken, DataDir: t.TempDir()}}
}
return machines[0], machines[1]
}
func write(t *testing.T, root, path, content string) {
t.Helper()
if err := writeFile(filepath.Join(root, path), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func (m *machine) sync() Result {
m.t.Helper()
res, err := m.client.Sync(context.Background(), m.root, true)
if err != nil {
m.t.Fatal(err)
}
return res
}
func (m *machine) expect(path, content string) {
m.t.Helper()
data, err := os.ReadFile(filepath.Join(m.root, path))
if content == "" && err == nil {
m.t.Fatalf("%s should not exist", path)
}
if content != "" && string(data) != content {
m.t.Fatalf("%s = %q (%v), want %q", path, data, err, content)
}
}
func TestFilesTravelBetweenMachinesAndStayOutOfGit(t *testing.T) {
mac, dell := setup(t)
write(t, mac.root, "docs/agents/plan.md", "v1")
write(t, mac.root, "docs/agents/tracked.md", "edited locally")
mac.sync()
if status := git(t, mac.root, "status", "--porcelain", "--", "docs/agents/plan.md", ".lodge"); status != "" {
t.Fatalf("agent files are visible to git: %s", status)
}
if _, err := os.Stat(filepath.Join(mac.root, ".gitignore")); err == nil {
t.Fatal(".gitignore must never be created")
}
dell.sync()
dell.expect("docs/agents/plan.md", "v1")
dell.expect("docs/agents/tracked.md", "in git")
write(t, dell.root, "docs/agents/plan.md", "v2")
dell.sync()
mac.sync()
mac.expect("docs/agents/plan.md", "v2")
os.Remove(filepath.Join(dell.root, "docs/agents/plan.md"))
dell.sync()
mac.sync()
mac.expect("docs/agents/plan.md", "")
}
func TestBranchNotesFollowTheBranchAndSharedStay(t *testing.T) {
mac, dell := setup(t)
write(t, mac.root, "docs/agents/arch.md", "shared")
write(t, mac.root, ".agent/plan.md", "main plan")
mac.sync()
git(t, mac.root, "checkout", "-b", "feature")
mac.sync()
mac.expect(".agent/plan.md", "main plan")
write(t, mac.root, ".agent/plan.md", "feature plan")
write(t, mac.root, ".agent/tried.md", "tried")
mac.sync()
git(t, mac.root, "checkout", "main")
if _, err := mac.client.Sync(context.Background(), mac.root, false); err != nil {
t.Fatal(err)
}
mac.expect(".agent/plan.md", "main plan")
mac.expect(".agent/tried.md", "")
mac.expect("docs/agents/arch.md", "shared")
git(t, mac.root, "checkout", "feature")
mac.sync()
mac.expect(".agent/plan.md", "feature plan")
mac.expect(".agent/tried.md", "tried")
dell.sync()
dell.expect(".agent/plan.md", "main plan")
git(t, dell.root, "checkout", "-b", "feature")
dell.sync()
dell.expect(".agent/plan.md", "feature plan")
dell.expect(".agent/tried.md", "tried")
}
func TestLaterEditWinsAndLoserStaysInHistory(t *testing.T) {
mac, dell := setup(t)
write(t, mac.root, "docs/agents/plan.md", "base")
mac.sync()
dell.sync()
write(t, mac.root, "docs/agents/plan.md", "from mac")
old := time.Now().Add(-time.Hour)
if err := os.Chtimes(filepath.Join(mac.root, "docs/agents/plan.md"), old, old); err != nil {
t.Fatal(err)
}
write(t, dell.root, "docs/agents/plan.md", "from dell")
dell.sync()
mac.sync()
mac.expect("docs/agents/plan.md", "from dell")
revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), "docs/agents/plan.md")
if err != nil {
t.Fatal(err)
}
if len(revisions) != 4 {
t.Fatalf("want base, dell, losing mac and restored dell revisions, got %d", len(revisions))
}
state, err := mac.client.State(context.Background(), repoKey(t, mac), "3")
if err != nil {
t.Fatal(err)
}
lost, err := mac.client.Blob(context.Background(), state.Spaces[protocol.SharedSpace]["docs/agents/plan.md"].Hash)
if err != nil || string(lost) != "from mac" {
t.Fatalf("losing version is not in history: %q %v", lost, err)
}
}
func TestRulesFileTravelsAndHostileServerPathsAreIgnored(t *testing.T) {
mac, dell := setup(t)
write(t, mac.root, RulesFile, "shared notes\n")
write(t, mac.root, "notes/a.md", "a")
mac.sync()
dell.sync()
dell.expect("notes/a.md", "a")
if status := git(t, dell.root, "status", "--porcelain"); status != "" {
t.Fatalf("synced rules did not reach exclude: %s", status)
}
hash, _ := mac.client.cacheBlob([]byte("#!/bin/sh"))
if err := mac.client.uploadBlob(context.Background(), hash); err != nil {
t.Fatal(err)
}
for _, path := range []string{".git/hooks/pre-commit", "notes/../../escape", "/etc/passwd"} {
push := protocol.Push{Changes: []protocol.Change{{Space: protocol.SharedSpace, Path: path, Hash: hash}}}
err := mac.client.call(context.Background(), http.MethodPost, "/api/repos/"+repoKey(t, mac)+"/revisions", push, nil)
if err == nil {
t.Fatalf("server accepted hostile path %s", path)
}
}
}
func TestDeletingRepositoryOnServerKeepsFilesOnMachines(t *testing.T) {
mac, _ := setup(t)
write(t, mac.root, "docs/agents/plan.md", "v1")
mac.sync()
if err := mac.client.call(context.Background(), http.MethodDelete, "/api/repos/"+repoKey(t, mac), nil, nil); err != nil {
t.Fatal(err)
}
mac.sync()
mac.expect("docs/agents/plan.md", "v1")
revisions, err := mac.client.Revisions(context.Background(), repoKey(t, mac), "")
if err != nil || len(revisions) != 1 {
t.Fatalf("want a fresh history of one revision, got %d (%v)", len(revisions), err)
}
}
func TestAdminSessionCookie(t *testing.T) {
mac, _ := setup(t)
res, err := http.Post(mac.client.URL+"/api/session", "application/json", strings.NewReader(`{"password":"`+adminToken+`"}`))
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusNoContent || len(res.Cookies()) != 1 {
t.Fatalf("login failed: %d", res.StatusCode)
}
req, _ := http.NewRequest(http.MethodGet, mac.client.URL+"/api/machines", nil)
req.AddCookie(res.Cookies()[0])
got, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
got.Body.Close()
if got.StatusCode != http.StatusOK {
t.Fatalf("cookie was not accepted: %d", got.StatusCode)
}
}
func repoKey(t *testing.T, m *machine) string {
t.Helper()
return git(t, m.root, "rev-list", "--max-parents=0", "HEAD")[:40]
}
+103
View File
@@ -0,0 +1,103 @@
package gitrepo
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os/exec"
"path"
"sort"
"strings"
)
var ErrNoIdentity = errors.New("repository has no commits and no origin to identify it by")
type Repo struct {
Root string
GitDir string
CommonDir string
Key string
Name string
}
func run(dir string, args ...string) (string, error) {
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
return strings.TrimRight(string(out), "\n"), err
}
func Open(dir string) (*Repo, error) {
root, err := run(dir, "rev-parse", "--show-toplevel")
if err != nil {
return nil, err
}
gitDir, err := run(root, "rev-parse", "--absolute-git-dir")
if err != nil {
return nil, err
}
commonDir, err := run(root, "rev-parse", "--path-format=absolute", "--git-common-dir")
if err != nil {
return nil, err
}
origin, _ := run(root, "config", "--get", "remote.origin.url")
key, err := identity(root, origin)
if err != nil {
return nil, err
}
name := path.Base(normalizeURL(origin))
if origin == "" {
name = path.Base(root)
}
return &Repo{Root: root, GitDir: gitDir, CommonDir: commonDir, Key: key, Name: name}, nil
}
func identity(root, origin string) (string, error) {
shallow, _ := run(root, "rev-parse", "--is-shallow-repository")
if shallow != "true" {
roots, err := run(root, "rev-list", "--max-parents=0", "HEAD")
if err == nil && roots != "" {
hashes := strings.Fields(roots)
sort.Strings(hashes)
return hashes[0], nil
}
}
if origin == "" {
return "", ErrNoIdentity
}
sum := sha256.Sum256([]byte(normalizeURL(origin)))
return "tmp-" + hex.EncodeToString(sum[:20]), nil
}
func normalizeURL(url string) string {
url = strings.TrimSuffix(strings.TrimSuffix(strings.ToLower(url), "/"), ".git")
if _, rest, ok := strings.Cut(url, "://"); ok {
url = rest
}
if _, rest, ok := strings.Cut(url, "@"); ok {
url = rest
}
return strings.ReplaceAll(url, ":", "/")
}
func (r *Repo) Branch() string {
branch, _ := run(r.Root, "symbolic-ref", "--short", "-q", "HEAD")
return branch
}
func (r *Repo) Head() string {
sha, _ := run(r.Root, "rev-parse", "-q", "--verify", "HEAD")
return sha
}
func (r *Repo) Tracked(paths []string) map[string]bool {
tracked := map[string]bool{}
if len(paths) == 0 {
return tracked
}
out, _ := run(r.Root, append([]string{"ls-files", "-z", "--"}, paths...)...)
for _, file := range strings.Split(out, "\x00") {
if file != "" {
tracked[file] = true
}
}
return tracked
}
+102
View File
@@ -0,0 +1,102 @@
package protocol
import (
"path"
"strings"
)
const (
SharedSpace = "shared"
branchPrefix = "branch:"
MaxBlobSize = 5 << 20
)
type File struct {
Hash string `json:"hash"`
Time int64 `json:"time"`
}
type Manifest map[string]File
type State struct {
Seq int64 `json:"seq"`
Spaces map[string]Manifest `json:"spaces"`
}
type Change struct {
Space string `json:"space"`
Path string `json:"path"`
Base string `json:"base"`
Hash string `json:"hash"`
}
type Push struct {
Name string `json:"name"`
MainSHA string `json:"main_sha"`
MainBranch string `json:"main_branch"`
Changes []Change `json:"changes"`
}
type Revision struct {
Seq int64 `json:"seq"`
Machine string `json:"machine"`
Time int64 `json:"time"`
MainSHA string `json:"main_sha"`
MainBranch string `json:"main_branch"`
Changes []Change `json:"changes"`
}
type Repo struct {
Key string `json:"key"`
Name string `json:"name"`
Seq int64 `json:"seq"`
Time int64 `json:"time"`
}
type Machine struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt int64 `json:"created_at"`
LastPush int64 `json:"last_push"`
Revisions int64 `json:"revisions"`
}
type Login struct {
Password string `json:"password"`
}
type Event struct {
Repo string `json:"repo"`
Seq int64 `json:"seq"`
}
type ClaimRequest struct {
Code string `json:"code"`
Name string `json:"name"`
}
type ClaimResponse struct {
Token string `json:"token"`
}
type CodeResponse struct {
Code string `json:"code"`
Command string `json:"command"`
ExpiresAt int64 `json:"expires_at"`
}
func BranchSpace(branch string) string {
return branchPrefix + branch
}
func ValidSpace(space string) bool {
return space == SharedSpace || len(space) > len(branchPrefix) && strings.HasPrefix(space, branchPrefix)
}
func SafePath(p string) bool {
if p == "" || path.IsAbs(p) || path.Clean(p) != p || strings.ContainsAny(p, "\\\x00") {
return false
}
first, _, _ := strings.Cut(p, "/")
return first != ".." && first != "." && !strings.EqualFold(first, ".git")
}
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)
case "$arch" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
*) echo "lodge: unsupported architecture $arch" >&2; exit 1 ;;
esac
bin="$HOME/.local/bin"
mkdir -p "$bin"
curl -fsSL "{{URL}}/dl/lodge-$os-$arch" -o "$bin/lodge.new"
chmod +x "$bin/lodge.new"
mv "$bin/lodge.new" "$bin/lodge"
"$bin/lodge" login "{{URL}}" "{{CODE}}"
"$bin/lodge" install
case ":$PATH:" in
*":$bin:"*) ;;
*) echo "lodge: add $bin to your PATH to use the lodge command" ;;
esac
+409
View File
@@ -0,0 +1,409 @@
package server
import (
"context"
"crypto/sha256"
"crypto/subtle"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"lodge/internal/protocol"
)
const (
heartbeat = 30 * time.Second
sessionCookie = "lodge_session"
loginDelay = time.Second
)
var (
//go:embed install.sh
installScript string
repoKeyPattern = regexp.MustCompile(`^(tmp-)?[0-9a-f]{40,64}$`)
hashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
codePattern = regexp.MustCompile(`^[0-9a-f]+$`)
)
type Config struct {
AdminToken string
PublicURL string
StaticDir string
ClientsDir string
}
type Server struct {
config Config
store *Store
mu sync.Mutex
subscribers map[chan protocol.Event]struct{}
}
type machineKey struct{}
func New(store *Store, config Config) http.Handler {
s := &Server{config: config, store: store, subscribers: map[chan protocol.Event]struct{}{}}
mux := http.NewServeMux()
mux.HandleFunc("GET /i/{code}", s.install)
mux.Handle("GET /dl/", http.StripPrefix("/dl/", http.FileServer(http.Dir(config.ClientsDir))))
mux.HandleFunc("POST /api/claim", s.claim)
mux.HandleFunc("POST /api/session", s.login)
mux.HandleFunc("GET /api/session", s.admin(noContent))
mux.HandleFunc("DELETE /api/session", s.logout)
mux.HandleFunc("POST /api/codes", s.admin(s.createCode))
mux.HandleFunc("GET /api/machines", s.admin(s.machines))
mux.HandleFunc("DELETE /api/machines/{id}", s.admin(s.deleteMachine))
mux.HandleFunc("DELETE /api/repos/{key}", s.admin(s.deleteRepo))
mux.HandleFunc("GET /api/repos", s.auth(s.repos))
mux.HandleFunc("GET /api/repos/{key}/state", s.auth(s.state))
mux.HandleFunc("GET /api/repos/{key}/revisions", s.auth(s.revisions))
mux.HandleFunc("POST /api/repos/{key}/revisions", s.auth(s.push))
mux.HandleFunc("GET /api/blobs/{hash}", s.auth(s.blob))
mux.HandleFunc("PUT /api/blobs/{hash}", s.auth(s.putBlob))
mux.HandleFunc("GET /api/events", s.auth(s.events))
mux.HandleFunc("GET /api/", http.NotFound)
mux.HandleFunc("GET /", s.static)
return mux
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, ErrConflict):
status = http.StatusConflict
case errors.Is(err, ErrNotFound):
status = http.StatusNotFound
}
http.Error(w, err.Error(), status)
}
func (s *Server) isAdminToken(token string) bool {
return s.config.AdminToken != "" && subtle.ConstantTimeCompare([]byte(token), []byte(s.config.AdminToken)) == 1
}
func (s *Server) isAdmin(r *http.Request) bool {
if cookie, err := r.Cookie(sessionCookie); err == nil && s.store.SessionValid(r.Context(), cookie.Value) {
return true
}
return s.isAdminToken(bearer(r))
}
func noContent(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func bearer(r *http.Request) string {
token, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
return token
}
func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var machineID int64
if !s.isAdmin(r) {
id, err := s.store.MachineID(r.Context(), bearer(r))
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
machineID = id
}
if key := r.PathValue("key"); key != "" && !repoKeyPattern.MatchString(key) {
http.Error(w, "bad repository key", http.StatusBadRequest)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), machineKey{}, machineID)))
}
}
func (s *Server) publicURL(r *http.Request) string {
if s.config.PublicURL != "" {
return strings.TrimSuffix(s.config.PublicURL, "/")
}
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
scheme = "http"
}
return scheme + "://" + r.Host
}
func (s *Server) install(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if !codePattern.MatchString(code) || !s.store.CodeValid(r.Context(), code) {
http.Error(w, "echo 'lodge: this install link has expired, create a new one' >&2; exit 1", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/x-shellscript")
_, _ = strings.NewReplacer("{{URL}}", s.publicURL(r), "{{CODE}}", code).WriteString(w, installScript)
}
func (s *Server) setSession(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
Secure: strings.HasPrefix(s.publicURL(r), "https://"),
SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var req protocol.Login
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !s.isAdminToken(req.Password) {
time.Sleep(loginDelay)
http.Error(w, "wrong password", http.StatusUnauthorized)
return
}
token, expires, err := s.store.CreateSession(r.Context())
if err != nil {
writeError(w, err)
return
}
s.setSession(w, r, token, expires)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookie); err == nil {
_ = s.store.DeleteSession(r.Context(), cookie.Value)
}
s.setSession(w, r, "", time.Unix(0, 0))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) machines(w http.ResponseWriter, r *http.Request) {
machines, err := s.store.Machines(r.Context())
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, machines)
}
func (s *Server) deleteMachine(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err == nil {
err = s.store.DeleteMachine(r.Context(), id)
}
if err != nil {
writeError(w, ErrNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) deleteRepo(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if !repoKeyPattern.MatchString(key) {
http.Error(w, "bad repository key", http.StatusBadRequest)
return
}
if err := s.store.DeleteRepo(r.Context(), key); err != nil {
writeError(w, err)
return
}
s.publish(protocol.Event{Repo: key})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) createCode(w http.ResponseWriter, r *http.Request) {
code, expires, err := s.store.CreateCode(r.Context())
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, protocol.CodeResponse{
Code: code,
Command: fmt.Sprintf("curl -fsSL %s/i/%s | sh", s.publicURL(r), code),
ExpiresAt: expires.UnixMilli(),
})
}
func (s *Server) claim(w http.ResponseWriter, r *http.Request) {
var req protocol.ClaimRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
token, err := s.store.Claim(r.Context(), req.Code, req.Name)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, protocol.ClaimResponse{Token: token})
}
func (s *Server) repos(w http.ResponseWriter, r *http.Request) {
repos, err := s.store.Repos(r.Context())
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, repos)
}
func (s *Server) state(w http.ResponseWriter, r *http.Request) {
state, err := s.store.State(r.Context(), r.PathValue("key"), r.URL.Query().Get("at"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, state)
}
func (s *Server) revisions(w http.ResponseWriter, r *http.Request) {
revisions, err := s.store.Revisions(r.Context(), r.PathValue("key"), r.URL.Query().Get("path"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, revisions)
}
func (s *Server) push(w http.ResponseWriter, r *http.Request) {
var push protocol.Push
if err := json.NewDecoder(r.Body).Decode(&push); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(push.Changes) == 0 {
http.Error(w, "no changes", http.StatusBadRequest)
return
}
for _, change := range push.Changes {
validHash := change.Hash == "" || hashPattern.MatchString(change.Hash)
if !protocol.ValidSpace(change.Space) || !protocol.SafePath(change.Path) || !validHash || change.Hash == change.Base {
http.Error(w, "bad change for "+change.Path, http.StatusBadRequest)
return
}
}
key := r.PathValue("key")
machineID, _ := r.Context().Value(machineKey{}).(int64)
seq, err := s.store.Push(r.Context(), key, machineID, push)
if err != nil {
writeError(w, err)
return
}
s.publish(protocol.Event{Repo: key, Seq: seq})
writeJSON(w, http.StatusCreated, protocol.Event{Repo: key, Seq: seq})
}
func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
data, err := s.store.Blob(r.Context(), r.PathValue("hash"))
if err != nil {
writeError(w, err)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
_, _ = w.Write(data)
}
func (s *Server) putBlob(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, protocol.MaxBlobSize))
if err != nil {
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
return
}
sum := sha256.Sum256(data)
hash := hex.EncodeToString(sum[:])
if hash != r.PathValue("hash") {
http.Error(w, "content does not match hash", http.StatusBadRequest)
return
}
if err := s.store.PutBlob(r.Context(), hash, data); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) publish(event protocol.Event) {
s.mu.Lock()
defer s.mu.Unlock()
for subscriber := range s.subscribers {
select {
case subscriber <- event:
default:
}
}
}
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
subscriber := make(chan protocol.Event, 16)
s.mu.Lock()
s.subscribers[subscriber] = struct{}{}
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.subscribers, subscriber)
s.mu.Unlock()
}()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
fmt.Fprint(w, ": connected\n\n")
flusher.Flush()
ticker := time.NewTicker(heartbeat)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
fmt.Fprint(w, ": ping\n\n")
case event := <-subscriber:
data, _ := json.Marshal(event)
fmt.Fprintf(w, "data: %s\n\n", data)
}
flusher.Flush()
}
}
func (s *Server) static(w http.ResponseWriter, r *http.Request) {
file := filepath.Join(s.config.StaticDir, filepath.FromSlash(filepath.Clean("/"+r.URL.Path)))
if info, err := os.Stat(file); err != nil || info.IsDir() {
file = filepath.Join(s.config.StaticDir, "index.html")
}
http.ServeFile(w, r, file)
}
+368
View File
@@ -0,0 +1,368 @@
package server
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strconv"
"time"
"lodge/internal/protocol"
_ "modernc.org/sqlite"
)
const (
codeLifetime = 10 * time.Minute
sessionLifetime = 30 * 24 * time.Hour
revisionLimit = 500
schema = `
create table if not exists machines (
id integer primary key,
name text not null,
token_hash text not null unique,
created_at integer not null
);
create table if not exists codes (
code text primary key,
expires_at integer not null
);
create table if not exists sessions (
token_hash text primary key,
expires_at integer not null
);
create table if not exists repos (
key text primary key,
name text not null,
seq integer not null,
time integer not null
);
create table if not exists blobs (
hash text primary key,
data blob not null
);
create table if not exists revisions (
repo text not null,
seq integer not null,
machine_id integer,
time integer not null,
main_sha text not null,
main_branch text not null,
primary key (repo, seq)
);
create table if not exists changes (
repo text not null,
seq integer not null,
space text not null,
path text not null,
base text not null,
hash text not null,
primary key (repo, seq, space, path)
);
create index if not exists changes_by_path on changes (repo, space, path, seq);
`
)
var (
ErrConflict = errors.New("state changed since base")
ErrNotFound = errors.New("not found")
)
type Store struct {
db *sql.DB
}
func OpenStore(path string) (*Store, error) {
dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func randomHex(bytes int) string {
buf := make([]byte, bytes)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
return hex.EncodeToString(buf)
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s *Store) CreateCode(ctx context.Context) (string, time.Time, error) {
code, expires := randomHex(12), time.Now().Add(codeLifetime)
_, err := s.db.ExecContext(ctx, `insert into codes (code, expires_at) values (?, ?)`, code, expires.UnixMilli())
return code, expires, err
}
func (s *Store) CodeValid(ctx context.Context, code string) bool {
var found int
err := s.db.QueryRowContext(ctx, `select 1 from codes where code = ? and expires_at > ?`, code, time.Now().UnixMilli()).Scan(&found)
return err == nil
}
func (s *Store) Claim(ctx context.Context, code, name string) (string, error) {
now := time.Now().UnixMilli()
res, err := s.db.ExecContext(ctx, `delete from codes where code = ? and expires_at > ?`, code, now)
if err != nil {
return "", err
}
if deleted, _ := res.RowsAffected(); deleted == 0 {
return "", ErrNotFound
}
token := randomHex(32)
_, err = s.db.ExecContext(ctx, `insert into machines (name, token_hash, created_at) values (?, ?, ?)`, name, hashToken(token), now)
return token, err
}
func (s *Store) MachineID(ctx context.Context, token string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx, `select id from machines where token_hash = ?`, hashToken(token)).Scan(&id)
return id, err
}
func (s *Store) CreateSession(ctx context.Context) (string, time.Time, error) {
token, expires := randomHex(32), time.Now().Add(sessionLifetime)
_, err := s.db.ExecContext(ctx, `insert into sessions (token_hash, expires_at) values (?, ?)`, hashToken(token), expires.UnixMilli())
return token, expires, err
}
func (s *Store) SessionValid(ctx context.Context, token string) bool {
var found int
err := s.db.QueryRowContext(ctx, `select 1 from sessions where token_hash = ? and expires_at > ?`, hashToken(token), time.Now().UnixMilli()).Scan(&found)
return err == nil
}
func (s *Store) DeleteSession(ctx context.Context, token string) error {
_, err := s.db.ExecContext(ctx, `delete from sessions where token_hash = ? or expires_at <= ?`, hashToken(token), time.Now().UnixMilli())
return err
}
func (s *Store) Machines(ctx context.Context) ([]protocol.Machine, error) {
rows, err := s.db.QueryContext(ctx, `
select m.id, m.name, m.created_at, coalesce(max(r.time), 0), count(r.seq)
from machines m left join revisions r on r.machine_id = m.id
group by m.id order by m.created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
machines := []protocol.Machine{}
for rows.Next() {
var machine protocol.Machine
if err := rows.Scan(&machine.ID, &machine.Name, &machine.CreatedAt, &machine.LastPush, &machine.Revisions); err != nil {
return nil, err
}
machines = append(machines, machine)
}
return machines, rows.Err()
}
func (s *Store) DeleteMachine(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `delete from machines where id = ?`, id)
if err != nil {
return err
}
if deleted, _ := res.RowsAffected(); deleted == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteRepo(ctx context.Context, repo string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `delete from changes where repo = ?`, repo); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `delete from revisions where repo = ?`, repo); err != nil {
return err
}
res, err := tx.ExecContext(ctx, `delete from repos where key = ?`, repo)
if err != nil {
return err
}
if deleted, _ := res.RowsAffected(); deleted == 0 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `delete from blobs where hash not in (select hash from changes)`); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) PutBlob(ctx context.Context, hash string, data []byte) error {
_, err := s.db.ExecContext(ctx, `insert or ignore into blobs (hash, data) values (?, ?)`, hash, data)
return err
}
func (s *Store) Blob(ctx context.Context, hash string) ([]byte, error) {
var data []byte
err := s.db.QueryRowContext(ctx, `select data from blobs where hash = ?`, hash).Scan(&data)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return data, err
}
func (s *Store) Repos(ctx context.Context) ([]protocol.Repo, error) {
rows, err := s.db.QueryContext(ctx, `select key, name, seq, time from repos order by time desc`)
if err != nil {
return nil, err
}
defer rows.Close()
repos := []protocol.Repo{}
for rows.Next() {
var repo protocol.Repo
if err := rows.Scan(&repo.Key, &repo.Name, &repo.Seq, &repo.Time); err != nil {
return nil, err
}
repos = append(repos, repo)
}
return repos, rows.Err()
}
func (s *Store) resolveSeq(ctx context.Context, repo, at string) (int64, error) {
var seq sql.NullInt64
var err error
if n, convErr := strconv.ParseInt(at, 10, 64); at != "" && convErr == nil {
return n, nil
}
if at == "" {
err = s.db.QueryRowContext(ctx, `select max(seq) from revisions where repo = ?`, repo).Scan(&seq)
} else {
err = s.db.QueryRowContext(ctx, `select max(seq) from revisions where repo = ? and main_sha like ? || '%'`, repo, at).Scan(&seq)
if err == nil && !seq.Valid {
return 0, ErrNotFound
}
}
return seq.Int64, err
}
func (s *Store) State(ctx context.Context, repo, at string) (protocol.State, error) {
state := protocol.State{Spaces: map[string]protocol.Manifest{}}
seq, err := s.resolveSeq(ctx, repo, at)
if err != nil {
return state, err
}
state.Seq = seq
rows, err := s.db.QueryContext(ctx, `
select c.space, c.path, c.hash, r.time
from changes c join revisions r on r.repo = c.repo and r.seq = c.seq
where c.repo = ?1 and c.hash != '' and c.seq = (
select max(seq) from changes
where repo = c.repo and space = c.space and path = c.path and seq <= ?2
)`, repo, seq)
if err != nil {
return state, err
}
defer rows.Close()
for rows.Next() {
var space, path string
var file protocol.File
if err := rows.Scan(&space, &path, &file.Hash, &file.Time); err != nil {
return state, err
}
if state.Spaces[space] == nil {
state.Spaces[space] = protocol.Manifest{}
}
state.Spaces[space][path] = file
}
return state, rows.Err()
}
func (s *Store) Revisions(ctx context.Context, repo, path string) ([]protocol.Revision, error) {
rows, err := s.db.QueryContext(ctx, `
select r.seq, coalesce(m.name, iif(r.machine_id is null, 'web', 'revoked')), r.time, r.main_sha, r.main_branch, c.space, c.path, c.base, c.hash
from revisions r
join changes c on c.repo = r.repo and c.seq = r.seq
left join machines m on m.id = r.machine_id
where r.repo = ?1 and (?2 = '' or c.path = ?2)
order by r.seq desc, c.path
limit ?3`, repo, path, revisionLimit)
if err != nil {
return nil, err
}
defer rows.Close()
revisions := []protocol.Revision{}
for rows.Next() {
var rev protocol.Revision
var change protocol.Change
if err := rows.Scan(&rev.Seq, &rev.Machine, &rev.Time, &rev.MainSHA, &rev.MainBranch, &change.Space, &change.Path, &change.Base, &change.Hash); err != nil {
return nil, err
}
if last := len(revisions) - 1; last >= 0 && revisions[last].Seq == rev.Seq {
revisions[last].Changes = append(revisions[last].Changes, change)
continue
}
rev.Changes = []protocol.Change{change}
revisions = append(revisions, rev)
}
return revisions, rows.Err()
}
func (s *Store) Push(ctx context.Context, repo string, machineID int64, push protocol.Push) (int64, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
for _, change := range push.Changes {
var current string
err := tx.QueryRowContext(ctx, `select hash from changes where repo = ? and space = ? and path = ? order by seq desc limit 1`, repo, change.Space, change.Path).Scan(&current)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
if current != change.Base {
return 0, ErrConflict
}
if change.Hash == "" {
continue
}
var found int
if err := tx.QueryRowContext(ctx, `select 1 from blobs where hash = ?`, change.Hash).Scan(&found); err != nil {
return 0, fmt.Errorf("blob %s was not uploaded: %w", change.Hash, ErrNotFound)
}
}
now := time.Now().UnixMilli()
var seq int64
err = tx.QueryRowContext(ctx, `
insert into repos (key, name, seq, time) values (?, ?, 1, ?)
on conflict (key) do update set seq = seq + 1, name = excluded.name, time = excluded.time
returning seq`, repo, push.Name, now).Scan(&seq)
if err != nil {
return 0, err
}
machine := sql.NullInt64{Int64: machineID, Valid: machineID != 0}
if _, err := tx.ExecContext(ctx, `insert into revisions (repo, seq, machine_id, time, main_sha, main_branch) values (?, ?, ?, ?, ?, ?)`, repo, seq, machine, now, push.MainSHA, push.MainBranch); err != nil {
return 0, err
}
for _, change := range push.Changes {
if _, err := tx.ExecContext(ctx, `insert into changes (repo, seq, space, path, base, hash) values (?, ?, ?, ?, ?, ?)`, repo, seq, change.Space, change.Path, change.Base, change.Hash); err != nil {
return 0, err
}
}
return seq, tx.Commit()
}