Add Phase 3 CLI tools: exo client and exod server binaries

- exod: gRPC daemon with auto-migration, graceful shutdown (SIGINT/SIGTERM),
  configurable listen address and data directory via flags
- exo: CLI client with import (YAML artifacts), tag add/list, cat add/list,
  search by tag commands; connects via EXO_ADDR env var
- Add bin/ to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 09:59:54 -07:00
parent b64177baa8
commit a336dc1ebb
5 changed files with 436 additions and 2 deletions

88
cmd/exod/main.go Normal file
View File

@@ -0,0 +1,88 @@
// Command exod is the gRPC backend daemon for the exo system.
// It is the sole owner of the SQLite database and blob store.
package main
import (
"flag"
"log"
"net"
"os"
"os/signal"
"syscall"
"google.golang.org/grpc"
"git.wntrmute.dev/kyle/exo/blob"
"git.wntrmute.dev/kyle/exo/config"
"git.wntrmute.dev/kyle/exo/db"
pb "git.wntrmute.dev/kyle/exo/proto/exo/v1"
"git.wntrmute.dev/kyle/exo/server"
)
var version = "dev"
func main() {
var (
listenAddr = flag.String("listen", "", "gRPC listen address (default from config)")
basePath = flag.String("base", "", "base data directory (default $HOME/exo)")
showVer = flag.Bool("version", false, "print version and exit")
)
flag.Parse()
if *showVer {
log.Printf("exod %s", version)
os.Exit(0)
}
cfg := config.Default()
if *basePath != "" {
cfg = config.FromBasePath(*basePath)
}
if *listenAddr != "" {
cfg.GRPCListenAddr = *listenAddr
}
// Ensure data directories exist.
if err := os.MkdirAll(cfg.BasePath, 0o750); err != nil {
log.Fatalf("exod: failed to create base directory: %v", err)
}
if err := os.MkdirAll(cfg.BlobStorePath, 0o750); err != nil {
log.Fatalf("exod: failed to create blob store directory: %v", err)
}
// Open and migrate the database.
database, err := db.Open(cfg.DatabasePath)
if err != nil {
log.Fatalf("exod: failed to open database: %v", err)
}
defer func() { _ = database.Close() }()
if err := db.Migrate(database); err != nil {
log.Fatalf("exod: failed to migrate database: %v", err)
}
blobStore := blob.NewStore(cfg.BlobStorePath)
// Start gRPC server.
lis, err := net.Listen("tcp", cfg.GRPCListenAddr)
if err != nil {
log.Fatalf("exod: failed to listen on %s: %v", cfg.GRPCListenAddr, err)
}
grpcServer := grpc.NewServer()
pb.RegisterArtifactServiceServer(grpcServer, server.NewArtifactServer(database, blobStore))
// Graceful shutdown on SIGINT/SIGTERM.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
log.Printf("exod: received %s, shutting down", sig)
grpcServer.GracefulStop()
}()
log.Printf("exod %s listening on %s", version, cfg.GRPCListenAddr)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("exod: gRPC server error: %v", err)
}
}