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

+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)
}