diff --git a/.gitignore b/.gitignore index e43b0f9..9b6c497 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .DS_Store +.ax +quicknote.md +plan.md diff --git a/CLAUDE.md b/CLAUDE.md index 9c9c3c8..cda0514 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,10 +16,10 @@ go test -run TestName . # Run a single test by name ## Architecture -The codebase has four distinct layers: +The codebase has three distinct layers: ### 1. `cmd/` — CLI layer (Cobra) -Parses flags into typed input structs and calls the service layer. `root.go` handles alias expansion (including `$me`, `$@`, `$1`-`$N` variable substitution) and wires up the `NodeService`. +Parses flags into typed input structs and calls the service layer. `root.go` handles alias expansion (including `$me`, `$@`, `$1`-`$N` variable substitution) and wires up the `NodeService`. Also contains `output.go` for colored terminal output and JSON serialization. ### 2. `service/` — Business logic `NodeService` is the central interface (`service/node_service.go`). The implementation (`node_service_impl.go`) enforces: @@ -29,11 +29,8 @@ Parses flags into typed input structs and calls the service layer. `root.go` han - Single-value relation enforcement (`assignee`, `in_namespace`) - Auto-creation of referenced user/namespace nodes -### 3. `store/` — Persistence -`Store` interface wraps SQLite with graph primitives: nodes, tags, and typed directed edges. Schema is 3 tables (`nodes`, `tags`, `rels`). All multi-step ops use `store.Transaction()`. - -### 4. `output/` — Presentation -Handles both colored terminal output and JSON serialization. Applies sort order: open → due → done, high → medium → low priority. +### 3. `store/` — Persistence and configuration +`GraphStore` interface wraps SQLite with graph primitives: nodes, tags, and typed directed edges. Schema is 3 tables (`nodes`, `tags`, `rels`). All multi-step ops use `store.Transaction()`. Also contains `Config` for user settings, aliases, and session management. ## Core Data Model @@ -47,4 +44,4 @@ Handles both colored terminal output and JSON serialization. Applies sort order: ## Config -The CLI searches upward from CWD for `.axconfig` (like git), falling back to `~/.config/ax/config.json`. The `AX_USER` env var overrides the configured username. The database file `.ax.db` is similarly discovered by walking upward. +The CLI searches upward from CWD for an `.ax` directory (like git), falling back to `~/.config/ax/` for config and `~/.local/share/ax/` for data. The `AX_USER` env var overrides the configured username. The database file `ax.db` is similarly discovered by walking upward. diff --git a/src/cmd/add.go b/src/cmd/add.go index b8abcb2..2ae41f7 100644 --- a/src/cmd/add.go +++ b/src/cmd/add.go @@ -2,7 +2,6 @@ package cmd import ( "axolotl/models" - "axolotl/output" "axolotl/service" "fmt" "os" @@ -65,7 +64,7 @@ var addCmd = &cobra.Command{ return } - output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) + PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } diff --git a/src/cmd/alias.go b/src/cmd/alias.go index f94d805..47287b4 100644 --- a/src/cmd/alias.go +++ b/src/cmd/alias.go @@ -1,8 +1,7 @@ package cmd import ( - "axolotl/output" - "axolotl/service" + "axolotl/store" "fmt" "os" @@ -17,7 +16,7 @@ var aliasCmd = &cobra.Command{ w := cmd.OutOrStdout() if len(args) == 0 { if aliases, err := cfg.ListAliases(); err == nil { - output.PrintAliases(w, aliases, jsonFlag) + PrintAliases(w, aliases, jsonFlag) } return } @@ -30,10 +29,10 @@ var aliasCmd = &cobra.Command{ fmt.Println(a.Command) return } - if err := cfg.SetAlias(&service.Alias{Name: args[0], Command: args[1], Description: aliasDesc}); err != nil { + if err := cfg.SetAlias(&store.Alias{Name: args[0], Command: args[1], Description: aliasDesc}); err != nil { fmt.Fprintln(os.Stderr, "failed to set alias:", err) } else { - output.PrintAction(w, "Alias set", args[0], false) + PrintAction(w, "Alias set", args[0], false) } }, } @@ -45,7 +44,7 @@ var aliasDelCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) os.Exit(1) } - output.PrintAction(cmd.OutOrStdout(), "Alias deleted", args[0], false) + PrintAction(cmd.OutOrStdout(), "Alias deleted", args[0], false) }, } diff --git a/src/cmd/del.go b/src/cmd/del.go index 660f25a..7c62553 100644 --- a/src/cmd/del.go +++ b/src/cmd/del.go @@ -1,7 +1,6 @@ package cmd import ( - "axolotl/output" "axolotl/service" "bufio" "fmt" @@ -39,7 +38,7 @@ var delCmd = &cobra.Command{ if err := svc.Delete(args[0]); err != nil { fmt.Fprintf(os.Stderr, "failed to delete: %v", err) } else { - output.PrintAction(cmd.OutOrStdout(), "Deleted", args[0], true) + PrintAction(cmd.OutOrStdout(), "Deleted", args[0], true) } }, } diff --git a/src/cmd/edit.go b/src/cmd/edit.go index 38e4777..4086d9f 100644 --- a/src/cmd/edit.go +++ b/src/cmd/edit.go @@ -1,7 +1,6 @@ package cmd import ( - "axolotl/output" "axolotl/service" "fmt" "os" @@ -34,11 +33,7 @@ var editCmd = &cobra.Command{ tmp.Close() defer os.Remove(tmp.Name()) - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vi" - } - c := exec.Command(editor, tmp.Name()) + c := exec.Command(cfg.GetEditor(), tmp.Name()) c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr if err := c.Run(); err != nil { fmt.Fprintln(os.Stderr, "editor failed:", err) @@ -56,7 +51,7 @@ var editCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, "failed to update:", err) return } - output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) + PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } diff --git a/src/cmd/init.go b/src/cmd/init.go index 0bb7e92..bd4dca9 100644 --- a/src/cmd/init.go +++ b/src/cmd/init.go @@ -1,8 +1,8 @@ package cmd import ( - "axolotl/output" "axolotl/service" + "axolotl/store" "fmt" "os" "path/filepath" @@ -13,11 +13,14 @@ import ( var initCmd = &cobra.Command{ Use: "init [path]", Short: "Initialize a new database", Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - p := "." + dataRoot, err := store.FindDataRoot(".local", "share") if len(args) > 0 { - p = args[0] + dataRoot = args[0] + } else if err != nil { + fmt.Fprintln(os.Stderr, "failed to find data dir:", err) + os.Exit(1) } - dbPath := filepath.Join(p, ".ax.db") + dbPath := filepath.Join(dataRoot, "ax.db") if _, err := os.Stat(dbPath); err == nil { fmt.Fprintln(os.Stderr, "database already exists:", dbPath) os.Exit(1) @@ -26,7 +29,7 @@ var initCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, "failed to initialize:", err) os.Exit(1) } - output.PrintAction(cmd.OutOrStdout(), "Created", dbPath, false) + PrintAction(cmd.OutOrStdout(), "Created", dbPath, false) }, } diff --git a/src/cmd/list.go b/src/cmd/list.go index 481d195..6e6e941 100644 --- a/src/cmd/list.go +++ b/src/cmd/list.go @@ -2,7 +2,6 @@ package cmd import ( "axolotl/models" - "axolotl/output" "axolotl/service" "fmt" "os" @@ -59,7 +58,7 @@ var listCmd = &cobra.Command{ } if nodes, err := svc.List(filter); err == nil { - output.PrintNodes(cmd.OutOrStdout(), svc, nodes, jsonFlag) + PrintNodes(cmd.OutOrStdout(), svc, nodes, jsonFlag) } else { fmt.Fprintf(os.Stderr, "err: %v\n", err) } diff --git a/src/cmd/login.go b/src/cmd/login.go index 7444da5..54cd478 100644 --- a/src/cmd/login.go +++ b/src/cmd/login.go @@ -1,7 +1,7 @@ package cmd import ( - "axolotl/service" + "axolotl/store" "encoding/json" "fmt" "net/http" @@ -66,7 +66,13 @@ var loginCmd = &cobra.Command{ json.NewDecoder(resp.Body).Decode(&result) resp.Body.Close() - if err := service.SaveSession(&service.Session{Token: result.Token}); err != nil { + session, err := store.LoadSession() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err) + os.Exit(1) + } + session.Token = result.Token + if err := session.Save(); err != nil { fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err) os.Exit(1) } diff --git a/src/output/output.go b/src/cmd/output.go similarity index 98% rename from src/output/output.go rename to src/cmd/output.go index 3f2a269..ed68aab 100644 --- a/src/output/output.go +++ b/src/cmd/output.go @@ -1,8 +1,9 @@ -package output +package cmd import ( "axolotl/models" "axolotl/service" + "axolotl/store" "encoding/json" "fmt" "io" @@ -156,7 +157,7 @@ func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut boo return nil } -func PrintAliases(w io.Writer, aliases []*service.Alias, jsonOut bool) error { +func PrintAliases(w io.Writer, aliases []*store.Alias, jsonOut bool) error { if jsonOut { return json.NewEncoder(w).Encode(aliases) } diff --git a/src/cmd/root.go b/src/cmd/root.go index db83a54..92abe74 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "axolotl/models" "axolotl/service" + "axolotl/store" "fmt" "os" "strings" @@ -11,17 +12,17 @@ import ( ) var jsonFlag bool -var cfg service.Config +var cfg *store.Config var rootCmd = &cobra.Command{Use: "ax", Short: "The axolotl issue tracker"} func Execute() { var err error - cfg, err = service.LoadConfigFile() + cfg, err = store.LoadConfigFile() if err != nil { fmt.Fprintln(os.Stderr, "failed to load config:", err) os.Exit(1) } - registerAliasCommands() + RegisterAliasCommands() if err := rootCmd.Execute(); err != nil { os.Exit(1) } @@ -31,7 +32,7 @@ func init() { rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "") } -func registerAliasCommands() { +func RegisterAliasCommands() { rootCmd.AddGroup(&cobra.Group{ID: "aliases", Title: "Aliases:"}) aliases, _ := cfg.ListAliases() for _, a := range aliases { @@ -87,7 +88,6 @@ func registerAliasCommands() { } // parseRelInput parses a rel string into a RelInput. -// // Formats: // - "prefix::value" → property rel with no target (tag) // - "relname:target" → edge rel with a target node diff --git a/src/cmd/serve.go b/src/cmd/serve.go index 04bb054..11b62ce 100644 --- a/src/cmd/serve.go +++ b/src/cmd/serve.go @@ -3,6 +3,7 @@ package cmd import ( "axolotl/serve" "axolotl/service" + "axolotl/store" "fmt" "net/http" "os" @@ -17,9 +18,9 @@ var serveCmd = &cobra.Command{ sc := cfg.GetServerConfig() addr := fmt.Sprintf("%s:%d", sc.Host, sc.Port) - var oidcCfg *service.OIDCConfig + var oidcCfg *store.OIDCConfig if oc, ok := cfg.GetOIDCConfig(); ok { - oidcCfg = &oc + oidcCfg = oc } handler, err := serve.New(service.GetNodeServiceForUser, oidcCfg) diff --git a/src/cmd/show.go b/src/cmd/show.go index a9b80da..4fedad4 100644 --- a/src/cmd/show.go +++ b/src/cmd/show.go @@ -1,7 +1,6 @@ package cmd import ( - "axolotl/output" "axolotl/service" "fmt" "os" @@ -23,7 +22,7 @@ var showCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, "node not found:", args[0]) os.Exit(1) } - output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) + PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } diff --git a/src/cmd/update.go b/src/cmd/update.go index 8c4141a..e9ed194 100644 --- a/src/cmd/update.go +++ b/src/cmd/update.go @@ -2,7 +2,6 @@ package cmd import ( "axolotl/models" - "axolotl/output" "axolotl/service" "fmt" "os" @@ -11,10 +10,10 @@ import ( ) var ( - uTitle, uContent, uDue string - uClearDue bool - uStatus, uPrio, uType string - uNamespace, uAssignee string + uTitle, uContent, uDue string + uClearDue bool + uStatus, uPrio, uType string + uNamespace, uAssignee string uAddTags, uRmTags, uAddRels, uRmRels []string ) @@ -90,7 +89,7 @@ var updateCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, err) os.Exit(1) } - output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) + PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) }, } diff --git a/src/models/node.go b/src/models/node.go index 0af9568..3ba2249 100644 --- a/src/models/node.go +++ b/src/models/node.go @@ -85,9 +85,7 @@ func (n *Node) RemoveRelation(relType RelType, target string) { func (n *Node) GetProperty(k string) string { prefix := "_" + k + "::" for _, t := range n.Tags { - if strings.HasPrefix(t, prefix) { - return strings.TrimPrefix(t, prefix) - } + return strings.TrimPrefix(t, prefix) } return "" } diff --git a/src/serve/auth.go b/src/serve/auth.go index 58c7138..b1444ae 100644 --- a/src/serve/auth.go +++ b/src/serve/auth.go @@ -1,7 +1,7 @@ package serve import ( - "axolotl/service" + "axolotl/store" "context" "crypto/rand" "crypto/sha256" @@ -30,12 +30,12 @@ type authHandler struct { pending map[string]*pendingLogin // loginID → pending state sessions map[string]string // serverToken → username - cfg service.OIDCConfig + cfg store.OIDCConfig provider *oidc.Provider oauth2 oauth2.Config } -func newAuthHandler(cfg service.OIDCConfig) (*authHandler, error) { +func newAuthHandler(cfg store.OIDCConfig) (*authHandler, error) { if cfg.PublicURL == "" { return nil, fmt.Errorf("oidc.public_url must be set to the externally reachable base URL of this server") } diff --git a/src/serve/server.go b/src/serve/server.go index 0d50301..a40909b 100644 --- a/src/serve/server.go +++ b/src/serve/server.go @@ -3,6 +3,7 @@ package serve import ( "axolotl/models" "axolotl/service" + "axolotl/store" "encoding/json" "net/http" "strings" @@ -12,7 +13,7 @@ import ( // When oidcCfg is non-nil, every request must carry a valid Bearer token; // the authenticated username is derived from the token claim configured in // OIDCConfig.UserClaim. Without OIDC, the X-Ax-User header is used instead. -func New(newSvc func(user string) (service.NodeService, error), oidcCfg *service.OIDCConfig) (http.Handler, error) { +func New(newSvc func(user string) (service.NodeService, error), oidcCfg *store.OIDCConfig) (http.Handler, error) { s := &server{newSvc: newSvc} mux := http.NewServeMux() mux.HandleFunc("GET /nodes", s.listNodes) @@ -191,8 +192,9 @@ func parseRel(s string) service.RelInput { if strings.Contains(s, "::") { return service.RelInput{Type: models.RelType(s)} } - if idx := strings.Index(s, ":"); idx >= 0 { - return service.RelInput{Type: models.RelType(s[:idx]), Target: s[idx+1:]} + + if before, after, found := strings.Cut(s, ":"); found { + return service.RelInput{Type: models.RelType(before), Target: after} } return service.RelInput{Type: models.RelType(s)} } diff --git a/src/service/api_client.go b/src/service/api_client.go index 0ab5a48..3ccef2c 100644 --- a/src/service/api_client.go +++ b/src/service/api_client.go @@ -2,6 +2,7 @@ package service import ( "axolotl/models" + "axolotl/store" "bytes" "encoding/json" "fmt" @@ -40,7 +41,7 @@ func (c *apiClient) do(method, path string, body any) (*http.Response, error) { // setAuth attaches either a Bearer token (when a session exists) or the // X-Ax-User header (no session / non-OIDC servers). func (c *apiClient) setAuth(req *http.Request) error { - sess, err := LoadSession() + sess, err := store.LoadSession() if err != nil || sess == nil || sess.Token == "" { req.Header.Set("X-Ax-User", c.user) return nil diff --git a/src/service/config.go b/src/service/config.go deleted file mode 100644 index e646a99..0000000 --- a/src/service/config.go +++ /dev/null @@ -1,37 +0,0 @@ -package service - -type Alias struct { - Name string `json:"name"` - Command string `json:"command"` - Description string `json:"description,omitempty"` -} - -type ServerConfig struct { - Host string `json:"host"` - Port int `json:"port"` -} - -type OIDCConfig struct { - Issuer string `json:"issuer"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - // PublicURL is the externally reachable base URL of this server, used to - // construct the OIDC redirect URI (e.g. "https://ax.example.com:7000"). - PublicURL string `json:"public_url"` - UserClaim string `json:"user_claim"` // default "preferred_username" -} - -type Config interface { - GetUser() string - SetUser(username string) error - GetAlias(name string) (*Alias, error) - SetAlias(alias *Alias) error - DeleteAlias(name string) error - ListAliases() ([]*Alias, error) - GetServerConfig() ServerConfig - // GetRemoteConfig returns the remote server address and whether remote mode is enabled. - GetRemoteConfig() (ServerConfig, bool) - // GetOIDCConfig returns the OIDC configuration and whether OIDC is enabled. - GetOIDCConfig() (OIDCConfig, bool) - Save() error -} diff --git a/src/service/node_service.go b/src/service/node_service.go index 10f4061..9a28d5e 100644 --- a/src/service/node_service.go +++ b/src/service/node_service.go @@ -72,7 +72,7 @@ func InitNodeService(path string) error { return store.InitSQLiteStore(path) } -func GetNodeService(cfg Config) (NodeService, error) { +func GetNodeService(cfg *store.Config) (NodeService, error) { user := cfg.GetUser() if user == "" { return nil, fmt.Errorf("no user configured: run 'ax user set ' first") diff --git a/src/service/node_service_impl.go b/src/service/node_service_impl.go index e3ebd8b..cbd4901 100644 --- a/src/service/node_service_impl.go +++ b/src/service/node_service_impl.go @@ -12,7 +12,7 @@ import ( ) type nodeServiceImpl struct { - store store.Store + store store.GraphStore userID string } @@ -303,7 +303,7 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { return nil, err } - err = s.store.Transaction(func(st store.Store) error { + err = s.store.Transaction(func(st store.GraphStore) error { now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, input.Title, input.Content, input.DueDate, now, now); err != nil { return err @@ -489,7 +489,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er } } - err = s.store.Transaction(func(st store.Store) error { + err = s.store.Transaction(func(st store.GraphStore) error { current, err := st.GetNode(id) if err != nil { return err @@ -609,14 +609,14 @@ func (s *nodeServiceImpl) Delete(id string) error { if !pc.canWrite(id) { return fmt.Errorf("permission denied: no write access to node %s", id) } - return s.store.Transaction(func(st store.Store) error { + return s.store.Transaction(func(st store.GraphStore) error { return s.cascadeDelete(st, id, make(map[string]bool)) }) } // cascadeDelete deletes id and all nodes it owns (recursively). // visited prevents infinite loops from ownership cycles. -func (s *nodeServiceImpl) cascadeDelete(st store.Store, id string, visited map[string]bool) error { +func (s *nodeServiceImpl) cascadeDelete(st store.GraphStore, id string, visited map[string]bool) error { if visited[id] { return nil } @@ -644,7 +644,7 @@ func (s *nodeServiceImpl) cascadeDelete(st store.Store, id string, visited map[s func (s *nodeServiceImpl) AddUser(name string) (*models.Node, error) { var id string - err := s.store.Transaction(func(st store.Store) error { + err := s.store.Transaction(func(st store.GraphStore) error { var err error id, err = s.ensureUser(st, name) return err @@ -678,7 +678,7 @@ func (s *nodeServiceImpl) checkBlockers(id string) error { return nil } -func (s *nodeServiceImpl) syncMentions(st store.Store, id string, current *models.Node, newTitle, newContent string) error { +func (s *nodeServiceImpl) syncMentions(st store.GraphStore, id string, current *models.Node, newTitle, newContent string) error { existingMentionIDs := make(map[string]bool) for _, uid := range current.Relations[string(models.RelMentions)] { existingMentionIDs[uid] = true @@ -708,7 +708,7 @@ func (s *nodeServiceImpl) syncMentions(st store.Store, id string, current *model // resolveRelTarget resolves a RelInput target to a node ID, auto-creating users // and namespaces as needed. Use only inside a transaction. -func (s *nodeServiceImpl) resolveRelTarget(st store.Store, ri RelInput) (string, error) { +func (s *nodeServiceImpl) resolveRelTarget(st store.GraphStore, ri RelInput) (string, error) { switch ri.Type { case models.RelAssignee, models.RelCreated, models.RelMentions: return s.resolveUserRef(st, ri.Target) @@ -744,7 +744,7 @@ func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) } // resolveIDByNameAndType finds a node by title and _type property without creating it. -func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType string) (string, error) { +func (s *nodeServiceImpl) resolveIDByNameAndType(st store.GraphStore, title, nodeType string) (string, error) { nodes, err := st.FindNodes([]*models.Rel{{Type: models.RelType("_type::" + nodeType), Target: ""}}) if err != nil { return "", err @@ -757,14 +757,14 @@ func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType return "", nil } -func (s *nodeServiceImpl) resolveUserRef(st store.Store, ref string) (string, error) { +func (s *nodeServiceImpl) resolveUserRef(st store.GraphStore, ref string) (string, error) { if exists, _ := st.NodeExists(ref); exists { return ref, nil } return s.ensureUser(st, ref) } -func (s *nodeServiceImpl) ensureUser(st store.Store, username string) (string, error) { +func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (string, error) { userID, err := s.resolveIDByNameAndType(st, username, "user") if err != nil { return "", err @@ -790,14 +790,14 @@ func (s *nodeServiceImpl) ensureUser(st store.Store, username string) (string, e return id, nil } -func (s *nodeServiceImpl) resolveNamespaceRef(st store.Store, ref string) (string, error) { +func (s *nodeServiceImpl) resolveNamespaceRef(st store.GraphStore, ref string) (string, error) { if exists, _ := st.NodeExists(ref); exists { return ref, nil } return s.ensureNamespace(st, ref) } -func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, error) { +func (s *nodeServiceImpl) ensureNamespace(st store.GraphStore, name string) (string, error) { nsID, err := s.resolveIDByNameAndType(st, name, "namespace") if err != nil { return "", err diff --git a/src/service/config_file.go b/src/store/config.go similarity index 54% rename from src/service/config_file.go rename to src/store/config.go index caaf19c..1372c08 100644 --- a/src/service/config_file.go +++ b/src/store/config.go @@ -1,4 +1,4 @@ -package service +package store import ( "encoding/json" @@ -9,31 +9,68 @@ import ( "slices" ) -type fileConfig struct { +type Alias struct { + Name string `json:"name"` + Command string `json:"command"` + Description string `json:"description,omitempty"` +} + +type ServerConfig struct { + Host string `json:"host"` + Port int `json:"port"` +} + +type OIDCConfig struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + PublicURL string `json:"public_url"` + UserClaim string `json:"user_claim"` +} + +type Config struct { path string User string `json:"user"` + Editor string `json:"editor"` UserAliases []*Alias `json:"aliases"` Serve ServerConfig `json:"serve"` Remote ServerConfig `json:"remote"` OIDC OIDCConfig `json:"oidc"` } -var defaultAliases = []*Alias{ - {Name: "mine", Command: "list --assignee $me --type issue --status open", Description: "Show open issues assigned to you"}, - {Name: "due", Command: "list --type issue --status open", Description: "Show open issues"}, - {Name: "inbox", Command: "list --mention $me", Description: "Show your inbox"}, +func FindDataRoot(std ...string) (string, error) { + dir, err := filepath.Abs(".") + if err != nil { + return "", err + } + for { + p := filepath.Join(dir, ".ax") + if stat, err := os.Stat(p); err == nil { + if stat.IsDir() { + return p, nil + } + } + if parent := filepath.Dir(dir); parent == dir { + break + } else { + dir = parent + } + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + stdpath := filepath.Join(std...) + return filepath.Join(home, stdpath, "ax"), nil } -func LoadConfigFile() (Config, error) { - path, err := findConfigPath() +func LoadConfigFile() (*Config, error) { + configRoot, err := FindDataRoot(".config") if err != nil { return nil, err } - return loadConfig(path) -} - -func loadConfig(path string) (*fileConfig, error) { - fc := &fileConfig{path: path, UserAliases: []*Alias{}} + path := filepath.Join(configRoot, "config.json") + fc := &Config{path: path, UserAliases: []*Alias{}} data, err := os.ReadFile(path) if err != nil { if !os.IsNotExist(err) { @@ -47,30 +84,7 @@ func loadConfig(path string) (*fileConfig, error) { return fc, nil } -func findConfigPath() (string, error) { - dir, err := filepath.Abs(".") - if err != nil { - return "", err - } - for { - p := filepath.Join(dir, ".axconfig") - if _, err := os.Stat(p); err == nil { - return p, nil - } - if parent := filepath.Dir(dir); parent == dir { - break - } else { - dir = parent - } - } - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".config", "ax", "config.json"), nil -} - -func (c *fileConfig) GetUser() string { +func (c *Config) GetUser() string { if c.User != "" { return c.User } @@ -83,26 +97,26 @@ func (c *fileConfig) GetUser() string { return "unknown" } -func (c *fileConfig) SetUser(username string) error { - c.User = username - return c.Save() +func (c *Config) GetEditor() string { + if c.Editor != "" { + return c.User + } + if u := os.Getenv("EDITOR"); u != "" { + return u + } + return "vi" } -func (c *fileConfig) GetAlias(name string) (*Alias, error) { +func (c *Config) GetAlias(name string) (*Alias, error) { for _, a := range c.UserAliases { if a.Name == name { return a, nil } } - for _, a := range defaultAliases { - if a.Name == name { - return a, nil - } - } return nil, errors.New("alias not found") } -func (c *fileConfig) SetAlias(alias *Alias) error { +func (c *Config) SetAlias(alias *Alias) error { for i, a := range c.UserAliases { if a.Name == alias.Name { c.UserAliases[i] = alias @@ -113,59 +127,49 @@ func (c *fileConfig) SetAlias(alias *Alias) error { return c.Save() } -func (c *fileConfig) DeleteAlias(name string) error { +func (c *Config) DeleteAlias(name string) error { for i, a := range c.UserAliases { if a.Name == name { c.UserAliases = slices.Delete(c.UserAliases, i, i+1) return c.Save() } } - for _, a := range defaultAliases { - if a.Name == name { - return errors.New("cannot delete default alias") - } - } return errors.New("alias not found") } -func (c *fileConfig) ListAliases() ([]*Alias, error) { +func (c *Config) ListAliases() ([]*Alias, error) { seen := make(map[string]bool) var result []*Alias for _, a := range c.UserAliases { result = append(result, a) seen[a.Name] = true } - for _, a := range defaultAliases { - if !seen[a.Name] { - result = append(result, a) - } - } return result, nil } -func (c *fileConfig) GetOIDCConfig() (OIDCConfig, bool) { +func (c *Config) GetOIDCConfig() (*OIDCConfig, bool) { if c.OIDC.Issuer == "" { - return OIDCConfig{}, false + return nil, false } cfg := c.OIDC if cfg.UserClaim == "" { cfg.UserClaim = "preferred_username" } - return cfg, true + return &cfg, true } -func (c *fileConfig) GetRemoteConfig() (ServerConfig, bool) { +func (c *Config) GetRemoteConfig() (*ServerConfig, bool) { if c.Remote.Host == "" { - return ServerConfig{}, false + return nil, false } port := c.Remote.Port if port == 0 { port = 7000 } - return ServerConfig{Host: c.Remote.Host, Port: port}, true + return &ServerConfig{Host: c.Remote.Host, Port: port}, true } -func (c *fileConfig) GetServerConfig() ServerConfig { +func (c *Config) GetServerConfig() *ServerConfig { host := c.Serve.Host if host == "" { host = "localhost" @@ -174,10 +178,10 @@ func (c *fileConfig) GetServerConfig() ServerConfig { if port == 0 { port = 7000 } - return ServerConfig{Host: host, Port: port} + return &ServerConfig{Host: host, Port: port} } -func (c *fileConfig) Save() error { +func (c *Config) Save() error { if err := os.MkdirAll(filepath.Dir(c.path), 0755); err != nil { return err } diff --git a/src/store/store.go b/src/store/graph_store.go similarity index 89% rename from src/store/store.go rename to src/store/graph_store.go index 73ff1ee..0e9ec32 100644 --- a/src/store/store.go +++ b/src/store/graph_store.go @@ -2,10 +2,10 @@ package store import "axolotl/models" -// Store is a primitive graph persistence interface. It provides basic +// GraphStore is a primitive graph persistence interface. It provides basic // operations for nodes and directed rels. No business logic lives here. // "Tag" rels are rels with an empty toID (e.g. "_type::issue" or "backend"). -type Store interface { +type GraphStore interface { // Nodes AddNode(id, title, content, dueDate, createdAt, updatedAt string) error GetNode(id string) (*models.Node, error) // returns node with tags and rels populated @@ -26,5 +26,5 @@ type Store interface { // Transaction runs fn inside an atomic transaction. If fn returns an error // the transaction is rolled back; otherwise it is committed. // Calls to Transaction inside fn reuse the same transaction (no nesting). - Transaction(fn func(Store) error) error + Transaction(fn func(GraphStore) error) error } diff --git a/src/store/sqlite.go b/src/store/graph_store_sqlite.go similarity index 85% rename from src/store/sqlite.go rename to src/store/graph_store_sqlite.go index 8a97cc1..8c78f23 100644 --- a/src/store/sqlite.go +++ b/src/store/graph_store_sqlite.go @@ -3,7 +3,6 @@ package store import ( "axolotl/models" "database/sql" - "errors" "fmt" "math/rand" "os" @@ -27,8 +26,8 @@ type querier interface { QueryRow(query string, args ...any) *sql.Row } -// SQLiteStore is the top-level Store backed by a SQLite database file. -type SQLiteStore struct { +// GraphStoreSqlite is the top-level Store backed by a SQLite database file. +type GraphStoreSqlite struct { db *sql.DB } @@ -62,31 +61,28 @@ func InitSQLiteStore(path string) error { // FindAndOpenSQLiteStore opens the SQLite database. If the AX_DB_PATH environment // variable is set, it uses that path directly. Otherwise, it walks up from the // current working directory to find an .ax.db file. -func FindAndOpenSQLiteStore() (Store, error) { +func FindAndOpenSQLiteStore() (GraphStore, error) { if dbpath := os.Getenv("AX_DB_PATH"); dbpath != "" { return NewSQLiteStore(dbpath) } - dir, err := filepath.Abs(".") + + dataRoot, err := FindDataRoot(".local", "share") if err != nil { - return nil, err + fmt.Fprintln(os.Stderr, "failed to find data dir:", err) + os.Exit(1) } - for { - dbpath := filepath.Join(dir, ".ax.db") - if _, err := os.Stat(dbpath); err == nil { - return NewSQLiteStore(dbpath) - } - if parent := filepath.Dir(dir); parent == dir { - return nil, errors.New("no .ax.db found (run 'ax init' first)") - } else { - dir = parent - } + dbPath := filepath.Join(dataRoot, "ax.db") + if _, err := os.Stat(dbPath); err == nil { + fmt.Fprintln(os.Stderr, "database already exists:", dbPath) + os.Exit(1) } + return NewSQLiteStore(dbPath) } // FindOrInitSQLiteStore is like FindAndOpenSQLiteStore but intended for server // mode: if no .ax.db is found it creates and initialises one in the current // working directory instead of returning an error. -func FindOrInitSQLiteStore() (Store, error) { +func FindOrInitSQLiteStore() (GraphStore, error) { if dbpath := os.Getenv("AX_DB_PATH"); dbpath != "" { if err := InitSQLiteStore(dbpath); err != nil { return nil, err @@ -119,7 +115,7 @@ func FindOrInitSQLiteStore() (Store, error) { // NewSQLiteStore opens a SQLite database at the given path, runs a one-time // schema migration if needed, then applies per-connection PRAGMAs. -func NewSQLiteStore(path string) (Store, error) { +func NewSQLiteStore(path string) (GraphStore, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) @@ -139,7 +135,7 @@ func NewSQLiteStore(path string) (Store, error) { db.Close() return nil, err } - return &SQLiteStore{db: db}, nil + return &GraphStoreSqlite{db: db}, nil } // migrateSchema migrates from the legacy two-table (tags + rels) schema to the @@ -171,7 +167,7 @@ func migrateSchema(db *sql.DB) error { // --- Transaction --- -func (s *SQLiteStore) Transaction(fn func(Store) error) error { +func (s *GraphStoreSqlite) Transaction(fn func(GraphStore) error) error { tx, err := s.db.Begin() if err != nil { return err @@ -183,14 +179,14 @@ func (s *SQLiteStore) Transaction(fn func(Store) error) error { return tx.Commit() } -func (s *txStore) Transaction(fn func(Store) error) error { +func (s *txStore) Transaction(fn func(GraphStore) error) error { return fn(s) // already in a transaction — reuse it } // --- Node operations --- func addNode(q querier, id, title, content, dueDate, createdAt, updatedAt string) error { - var dd interface{} + var dd any if dueDate != "" { dd = dueDate } @@ -227,7 +223,7 @@ func getNode(q querier, id string) (*models.Node, error) { } func updateNode(q querier, id, title, content, dueDate, updatedAt string) error { - var dd interface{} + var dd any if dueDate != "" { dd = dueDate } @@ -249,15 +245,15 @@ func nodeExists(q querier, id string) (bool, error) { return e, err } -func (s *SQLiteStore) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error { +func (s *GraphStoreSqlite) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error { return addNode(s.db, id, title, content, dueDate, createdAt, updatedAt) } -func (s *SQLiteStore) GetNode(id string) (*models.Node, error) { return getNode(s.db, id) } -func (s *SQLiteStore) UpdateNode(id, title, content, dueDate, updatedAt string) error { +func (s *GraphStoreSqlite) GetNode(id string) (*models.Node, error) { return getNode(s.db, id) } +func (s *GraphStoreSqlite) UpdateNode(id, title, content, dueDate, updatedAt string) error { return updateNode(s.db, id, title, content, dueDate, updatedAt) } -func (s *SQLiteStore) DeleteNode(id string) error { return deleteNode(s.db, id) } -func (s *SQLiteStore) NodeExists(id string) (bool, error) { return nodeExists(s.db, id) } +func (s *GraphStoreSqlite) DeleteNode(id string) error { return deleteNode(s.db, id) } +func (s *GraphStoreSqlite) NodeExists(id string) (bool, error) { return nodeExists(s.db, id) } func (s *txStore) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error { return addNode(s.tx, id, title, content, dueDate, createdAt, updatedAt) @@ -292,8 +288,8 @@ func generateID(q querier) (string, error) { } } -func (s *SQLiteStore) GenerateID() (string, error) { return generateID(s.db) } -func (s *txStore) GenerateID() (string, error) { return generateID(s.db) } +func (s *GraphStoreSqlite) GenerateID() (string, error) { return generateID(s.db) } +func (s *txStore) GenerateID() (string, error) { return generateID(s.db) } // --- Rel operations --- @@ -307,10 +303,10 @@ func removeRel(q querier, nodeID, relName, toID string) error { return err } -func (s *SQLiteStore) AddRel(nodeID, relName, toID string) error { +func (s *GraphStoreSqlite) AddRel(nodeID, relName, toID string) error { return addRel(s.db, nodeID, relName, toID) } -func (s *SQLiteStore) RemoveRel(nodeID, relName, toID string) error { +func (s *GraphStoreSqlite) RemoveRel(nodeID, relName, toID string) error { return removeRel(s.db, nodeID, relName, toID) } func (s *txStore) AddRel(nodeID, relName, toID string) error { @@ -377,7 +373,7 @@ func findNodes(q querier, filters []*models.Rel) ([]*models.Node, error) { return nodes, nil } -func (s *SQLiteStore) FindNodes(filters []*models.Rel) ([]*models.Node, error) { +func (s *GraphStoreSqlite) FindNodes(filters []*models.Rel) ([]*models.Node, error) { return findNodes(s.db, filters) } diff --git a/src/service/session.go b/src/store/session.go similarity index 57% rename from src/service/session.go rename to src/store/session.go index d0455ea..9294b4c 100644 --- a/src/service/session.go +++ b/src/store/session.go @@ -1,4 +1,4 @@ -package service +package store import ( "encoding/json" @@ -9,22 +9,16 @@ import ( // Session holds the server-issued token returned by POST /auth/poll. // The ax server owns the full OIDC flow; the client only needs this token. type Session struct { + path string Token string `json:"token"` } -func sessionPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".config", "ax", "session.json"), nil -} - func LoadSession() (*Session, error) { - path, err := sessionPath() + sessionRoot, err := FindDataRoot(".local", "share") if err != nil { return nil, err } + path := filepath.Join(sessionRoot, "session.json") data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -36,30 +30,23 @@ func LoadSession() (*Session, error) { if err := json.Unmarshal(data, &s); err != nil { return nil, err } + s.path = path return &s, nil } -func SaveSession(s *Session) error { - path, err := sessionPath() - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { +func (s *Session) Save() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { return err } data, err := json.MarshalIndent(s, "", " ") if err != nil { return err } - return os.WriteFile(path, data, 0600) + return os.WriteFile(s.path, data, 0600) } -func ClearSession() error { - path, err := sessionPath() - if err != nil { - return err - } - err = os.Remove(path) +func (s *Session) ClearSession() error { + err := os.Remove(s.path) if os.IsNotExist(err) { return nil }