diff --git a/.gitignore b/.gitignore index 43e5643..0a4170b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /ark -*.db \ No newline at end of file +*.db +bin/ \ No newline at end of file diff --git a/PROGRESS.md b/PROGRESS.md index 8568253..53cb9e4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -46,7 +46,19 @@ Tracks implementation progress against the phases in `PROJECT_PLAN.md`. - `proto/exo/v1/*.pb.go` (generated) - `server/server.go`, `server/server_test.go` -## Phase 3: CLI Tools — NOT STARTED +## Phase 3: CLI Tools — COMPLETE + +**Deliverables:** +- [x] `exod` server binary: gRPC daemon with startup, graceful shutdown (SIGINT/SIGTERM), auto-migration, flag-based configuration (`-listen`, `-base`, `-version`) +- [x] `exo` CLI binary: `import` (YAML artifacts), `tag add/list`, `cat add/list`, `search tag` +- [x] YAML import parser in CLI (converts to proto messages for gRPC transport) +- [x] Environment variable support (`EXO_ADDR` for server address) +- [x] Makefile `build` target produces both binaries in `bin/` +- [x] Version injection via `-ldflags` + +**Files created:** +- `cmd/exod/main.go` +- `cmd/exo/main.go`, `cmd/exo/yaml.go` ## Phase 4: Knowledge Graph — NOT STARTED diff --git a/cmd/exo/main.go b/cmd/exo/main.go new file mode 100644 index 0000000..eff638d --- /dev/null +++ b/cmd/exo/main.go @@ -0,0 +1,205 @@ +// Command exo is the CLI client for the exo system. +// It connects to exod via gRPC for all operations. +package main + +import ( + "context" + "fmt" + "log" + "os" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + pb "git.wntrmute.dev/kyle/exo/proto/exo/v1" +) + +var version = "dev" + +func usage() { + fmt.Fprintf(os.Stderr, `exo — kExocortex CLI client (version %s) + +Usage: + exo [arguments] + +Commands: + import [...] Import artifacts from YAML files + tag add [...] Create tags + tag list List all tags + cat add [...] Create categories + cat list List all categories + search tag Search artifacts by tag + version Print version + +Environment: + EXO_ADDR gRPC server address (default: localhost:9090) +`, version) + os.Exit(1) +} + +func main() { + if len(os.Args) < 2 { + usage() + } + + switch os.Args[1] { + case "version": + fmt.Printf("exo %s\n", version) + case "import": + runImport(os.Args[2:]) + case "tag": + runTag(os.Args[2:]) + case "cat": + runCat(os.Args[2:]) + case "search": + runSearch(os.Args[2:]) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + usage() + } +} + +func dial() pb.ArtifactServiceClient { + addr := os.Getenv("EXO_ADDR") + if addr == "" { + addr = "localhost:9090" + } + + conn, err := grpc.NewClient(addr, + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Fatalf("exo: failed to connect to %s: %v", addr, err) + } + // Connection will be closed when the process exits. + return pb.NewArtifactServiceClient(conn) +} + +func runImport(args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: exo import [...]") + os.Exit(1) + } + + client := dial() + ctx := context.Background() + + for _, path := range args { + yamlArt, err := loadYAMLArtifact(path) + if err != nil { + log.Fatalf("exo: failed to load %s: %v", path, err) + } + + resp, err := client.CreateArtifact(ctx, &pb.CreateArtifactRequest{ + Artifact: yamlArt, + }) + if err != nil { + log.Fatalf("exo: failed to import %s: %v", path, err) + } + fmt.Printf("imported %s -> %s\n", path, resp.Id) + } +} + +func runTag(args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: exo tag [...]") + os.Exit(1) + } + + client := dial() + ctx := context.Background() + + switch args[0] { + case "add": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: exo tag add [...]") + os.Exit(1) + } + for _, tag := range args[1:] { + _, err := client.CreateTag(ctx, &pb.CreateTagRequest{Tag: tag}) + if err != nil { + log.Fatalf("exo: failed to create tag %q: %v", tag, err) + } + fmt.Printf("created tag: %s\n", tag) + } + + case "list": + resp, err := client.ListTags(ctx, &pb.ListTagsRequest{}) + if err != nil { + log.Fatalf("exo: failed to list tags: %v", err) + } + for _, tag := range resp.Tags { + fmt.Println(tag) + } + + default: + fmt.Fprintf(os.Stderr, "unknown tag subcommand: %s\n", args[0]) + os.Exit(1) + } +} + +func runCat(args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: exo cat [...]") + os.Exit(1) + } + + client := dial() + ctx := context.Background() + + switch args[0] { + case "add": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: exo cat add [...]") + os.Exit(1) + } + for _, cat := range args[1:] { + _, err := client.CreateCategory(ctx, &pb.CreateCategoryRequest{Category: cat}) + if err != nil { + log.Fatalf("exo: failed to create category %q: %v", cat, err) + } + fmt.Printf("created category: %s\n", cat) + } + + case "list": + resp, err := client.ListCategories(ctx, &pb.ListCategoriesRequest{}) + if err != nil { + log.Fatalf("exo: failed to list categories: %v", err) + } + for _, cat := range resp.Categories { + fmt.Println(cat) + } + + default: + fmt.Fprintf(os.Stderr, "unknown cat subcommand: %s\n", args[0]) + os.Exit(1) + } +} + +func runSearch(args []string) { + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: exo search tag ") + os.Exit(1) + } + + client := dial() + ctx := context.Background() + + switch args[0] { + case "tag": + resp, err := client.SearchByTag(ctx, &pb.SearchByTagRequest{Tag: args[1]}) + if err != nil { + log.Fatalf("exo: search failed: %v", err) + } + if len(resp.ArtifactIds) == 0 { + fmt.Println("no results") + return + } + for _, id := range resp.ArtifactIds { + fmt.Println(id) + } + + default: + fmt.Fprintf(os.Stderr, "unknown search type: %s\n", args[0]) + os.Exit(1) + } +} diff --git a/cmd/exo/yaml.go b/cmd/exo/yaml.go new file mode 100644 index 0000000..5a660af --- /dev/null +++ b/cmd/exo/yaml.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" + + pb "git.wntrmute.dev/kyle/exo/proto/exo/v1" +) + +// yamlArtifact mirrors the YAML format from ark/go-v2 for import compatibility. +type yamlArtifact struct { + ID string `yaml:"id"` + Type string `yaml:"type"` + Citation *yamlCitation `yaml:"citation"` + Latest string `yaml:"latest"` + Tags []string `yaml:"tags"` + Categories []string `yaml:"categories"` + Snapshots []yamlSnapshot `yaml:"snapshots"` + Metadata []yamlMetadata `yaml:"metadata"` +} + +type yamlCitation struct { + ID string `yaml:"id"` + DOI string `yaml:"doi"` + Title string `yaml:"title"` + Year int `yaml:"year"` + Published string `yaml:"published"` + Authors []string `yaml:"authors"` + Publisher *yamlPublisher `yaml:"publisher"` + Source string `yaml:"source"` + Abstract string `yaml:"abstract"` +} + +type yamlPublisher struct { + Name string `yaml:"name"` + Address string `yaml:"address"` +} + +type yamlSnapshot struct { + ID string `yaml:"id"` + Stored int64 `yaml:"stored"` + Datetime string `yaml:"datetime"` + Citation *yamlCitation `yaml:"citation"` + Source string `yaml:"source"` + Blobs []yamlBlobRef `yaml:"blobs"` +} + +type yamlBlobRef struct { + Format string `yaml:"format"` + Path string `yaml:"path"` +} + +type yamlMetadata struct { + Key string `yaml:"key"` + Contents string `yaml:"contents"` + Type string `yaml:"type"` +} + +func loadYAMLArtifact(path string) (*pb.Artifact, error) { + data, err := os.ReadFile(path) //nolint:gosec // user-provided import file + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + var ya yamlArtifact + if err := yaml.Unmarshal(data, &ya); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", path, err) + } + + art := &pb.Artifact{ + Id: ya.ID, + Type: ya.Type, + Latest: ya.Latest, + Tags: ya.Tags, + Categories: ya.Categories, + } + + if ya.Citation != nil { + art.Citation = yamlCitationToProto(ya.Citation) + } + + for _, m := range ya.Metadata { + art.Metadata = append(art.Metadata, &pb.MetadataEntry{ + Key: m.Key, + Value: &pb.Value{Contents: m.Contents, Type: m.Type}, + }) + } + + for _, s := range ya.Snapshots { + snap := &pb.Snapshot{ + Id: s.ID, + StoredAt: s.Stored, + Datetime: s.Datetime, + Source: s.Source, + } + if s.Citation != nil { + snap.Citation = yamlCitationToProto(s.Citation) + } + // Note: blob file reading is handled by the server via the blob store. + // For import, we could read and send blob data here, but that would + // require streaming. For now, blob paths are noted but not sent. + art.Snapshots = append(art.Snapshots, snap) + } + + return art, nil +} + +func yamlCitationToProto(yc *yamlCitation) *pb.Citation { + c := &pb.Citation{ + Id: yc.ID, + Doi: yc.DOI, + Title: yc.Title, + Year: int32(yc.Year), //nolint:gosec // year values are always small + Published: yc.Published, + Authors: yc.Authors, + Source: yc.Source, + Abstract: yc.Abstract, + } + if yc.Publisher != nil { + c.Publisher = &pb.Publisher{ + Name: yc.Publisher.Name, + Address: yc.Publisher.Address, + } + } + return c +} diff --git a/cmd/exod/main.go b/cmd/exod/main.go new file mode 100644 index 0000000..7849d43 --- /dev/null +++ b/cmd/exod/main.go @@ -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) + } +}