diff --git a/cmd/add.go b/cmd/add.go index 29df469..b0c009f 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,7 +1,6 @@ package cmd import ( - "axolotl/db" "axolotl/models" "axolotl/output" "axolotl/service" @@ -19,12 +18,6 @@ var cTags, cRels []string var addCmd = &cobra.Command{ Use: "add ", Short: "Create a new node", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - d, err := db.GetDB() - if err != nil { - fmt.Fprintln(os.Stderr, err) - return - } - if !slices.ContainsFunc(cTags, func(e string) bool { return strings.HasPrefix(e, "_type::") }) { cTags = append(cTags, "_type::issue") } @@ -49,11 +42,13 @@ var addCmd = &cobra.Command{ rels[models.RelInNamespace] = append(rels[models.RelInNamespace], cfg.GetUser()) } - svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) - if n, err := svc.Create(args[0], cContent, cDue, cTags, rels); err != nil { + svc, err := service.GetNodeService(cfg) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to create:", err) + } else if n, err := svc.Create(args[0], cContent, cDue, cTags, rels); err != nil { fmt.Fprintln(os.Stderr, "failed to create:", err) } else { - output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) + output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) } }, } diff --git a/cmd/del.go b/cmd/del.go index f1f16e3..40a4e26 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -1,7 +1,6 @@ package cmd import ( - "axolotl/db" "axolotl/output" "axolotl/service" "bufio" @@ -16,12 +15,11 @@ var dForce bool var delCmd = &cobra.Command{ Use: "del <id>", Short: "Delete a node", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - d, err := db.GetDB() + svc, err := service.GetNodeService(cfg) if err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, "failed to create service: %v", err) return } - svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) n, err := svc.GetByID(args[0]) if err != nil { fmt.Fprintln(os.Stderr, " node not found:", args[0]) diff --git a/cmd/edit.go b/cmd/edit.go index 3945c66..dbe9bad 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -1,7 +1,6 @@ package cmd import ( - "axolotl/db" "axolotl/output" "axolotl/service" "fmt" @@ -14,12 +13,11 @@ import ( var editCmd = &cobra.Command{ Use: "edit <id>", Short: "Edit node content in $EDITOR", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - d, err := db.GetDB() + svc, err := service.GetNodeService(cfg) if err != nil { fmt.Fprintln(os.Stderr, err) return } - svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) n, err := svc.GetByID(args[0]) if err != nil { fmt.Fprintln(os.Stderr, "node not found:", args[0]) @@ -53,7 +51,7 @@ var editCmd = &cobra.Command{ return } n, _ = svc.GetByID(args[0]) - output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) + output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) } else { fmt.Fprintln(os.Stderr, "failed to read temp file:", err) } diff --git a/cmd/init.go b/cmd/init.go index 1e2657f..0bb7e92 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -1,8 +1,8 @@ package cmd import ( - "axolotl/db" "axolotl/output" + "axolotl/service" "fmt" "os" "path/filepath" @@ -22,7 +22,7 @@ var initCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, "database already exists:", dbPath) os.Exit(1) } - if err := db.Init(dbPath); err != nil { + if err := service.InitNodeService(dbPath); err != nil { fmt.Fprintln(os.Stderr, "failed to initialize:", err) os.Exit(1) } diff --git a/cmd/list.go b/cmd/list.go index e313bed..d364bc5 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,7 +1,6 @@ package cmd import ( - "axolotl/db" "axolotl/output" "axolotl/service" "fmt" @@ -17,12 +16,11 @@ var lMention string var listCmd = &cobra.Command{ Use: "list", Short: "List nodes", Run: func(cmd *cobra.Command, args []string) { - d, err := db.GetDB() + svc, err := service.GetNodeService(cfg) if err != nil { fmt.Fprintln(os.Stderr, err) return } - svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) opts := []service.ListOption{} if len(lTags) > 0 { opts = append(opts, service.WithTags(lTags...)) @@ -35,7 +33,7 @@ var listCmd = &cobra.Command{ } if nodes, err := svc.List(opts...); err == nil { - output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag) + output.PrintNodes(cmd.OutOrStdout(), svc, nodes, jsonFlag) } else { fmt.Fprintf(os.Stderr, "err: %v\n", err) } diff --git a/cmd/root.go b/cmd/root.go index 7cac711..786c5fe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,7 +15,7 @@ var rootCmd = &cobra.Command{Use: "ax", Short: "The axolotl issue tracker"} func Execute() { var err error - cfg, err = service.LoadConfig() + cfg, err = service.LoadConfigFile() if err != nil { fmt.Fprintln(os.Stderr, "failed to load config:", err) os.Exit(1) @@ -27,18 +27,48 @@ func Execute() { } } +func expandAlias(alias *service.Alias, args []string, currentUser string) []string { + cmd := alias.Command + cmd = strings.ReplaceAll(cmd, "$me", currentUser) + + parts := strings.Fields(cmd) + var result []string + + for _, part := range parts { + if part == "$@" { + result = append(result, args...) + continue + } + + hasCatchAll := strings.Contains(part, "$@") + replaced := part + + if hasCatchAll { + replaced = strings.ReplaceAll(replaced, "$@", strings.Join(args, " ")) + } + + for i := len(args) - 1; i >= 0; i-- { + placeholder := fmt.Sprintf("$%d", i+1) + replaced = strings.ReplaceAll(replaced, placeholder, args[i]) + } + + result = append(result, replaced) + } + + return result +} + func registerAliasCommands() { rootCmd.AddGroup(&cobra.Group{ID: "aliases", Title: "Aliases:"}) aliases, _ := cfg.ListAliases() for _, a := range aliases { - a := a rootCmd.AddCommand(&cobra.Command{ Use: a.Name, Short: a.Description, GroupID: "aliases", DisableFlagParsing: true, Run: func(cmd *cobra.Command, args []string) { - expanded := service.ExpandAlias(a, args, cfg.GetUser()) + expanded := expandAlias(a, args, cfg.GetUser()) rootCmd.SetArgs(transformArgs(expanded)) if err := rootCmd.Execute(); err != nil { os.Exit(1) diff --git a/cmd/show.go b/cmd/show.go index 8390fb8..fbe619b 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -1,7 +1,6 @@ package cmd import ( - "axolotl/db" "axolotl/output" "axolotl/service" "fmt" @@ -13,14 +12,13 @@ import ( var showCmd = &cobra.Command{ Use: "show <id>", Short: "Show node details", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - d, err := db.GetDB() + svc, err := service.GetNodeService(cfg) if err != nil { fmt.Fprintln(os.Stderr, err) return } - svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) if n, err := svc.GetByID(args[0]); err == nil { - output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) + output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) } else { fmt.Fprintln(os.Stderr, "node not found:", args[0]) } diff --git a/cmd/update.go b/cmd/update.go index d24d279..3b3b4d8 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -1,7 +1,6 @@ package cmd import ( - "axolotl/db" "axolotl/models" "axolotl/output" "axolotl/service" @@ -14,7 +13,7 @@ import ( ) var ( - uTitle, uContent, uDue string + uTitle, uContent, uDue string uClearDue bool uAddTags, uRmTags, uAddRels, uRmRels []string ) @@ -22,12 +21,11 @@ var ( var updateCmd = &cobra.Command{ Use: "update <id>", Short: "Update a node", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - d, err := db.GetDB() + svc, err := service.GetNodeService(cfg) if err != nil { fmt.Fprintln(os.Stderr, err) return } - svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser()) node, err := svc.GetByID(args[0]) if err != nil { @@ -65,7 +63,7 @@ var updateCmd = &cobra.Command{ } else if slices.Contains(uAddTags, "_status::open") { uRmTags = append(uRmTags, "_status::done") } - + for _, prefix := range []string{"_type::", "_status::", "_prio::", "_namespace::"} { if slices.ContainsFunc(uAddTags, func(e string) bool { return strings.HasPrefix(e, prefix) }) { for _, existing := range node.Tags { @@ -76,7 +74,6 @@ var updateCmd = &cobra.Command{ } } - if cmd.Flags().Changed("title") { node.Title = uTitle } @@ -117,7 +114,7 @@ var updateCmd = &cobra.Command{ return } if n, err := svc.GetByID(args[0]); err == nil { - output.PrintNode(cmd.OutOrStdout(), n, jsonFlag) + output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag) } else { fmt.Fprintln(os.Stderr, "failed to fetch node:", err) } diff --git a/db/db.go b/db/db.go deleted file mode 100644 index 0dedbd9..0000000 --- a/db/db.go +++ /dev/null @@ -1,72 +0,0 @@ -package db - -import ( - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - - _ "modernc.org/sqlite" -) - -type DB struct { - *sql.DB - path string -} - -var ( - database *DB - migrations = []string{ - `CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS tags (node_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (node_id, tag), FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS rels (from_id TEXT NOT NULL, to_id TEXT NOT NULL, rel_type TEXT NOT NULL, PRIMARY KEY (from_id, to_id, rel_type), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (to_id) REFERENCES nodes(id) ON DELETE CASCADE)`, - `CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)`, `CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`, `CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`, - } -) - -func GetDB() (*DB, error) { - if database != nil { - return database, nil - } - dir, err := filepath.Abs(".") - if err != nil { - return nil, err - } - for { - if _, err := os.Stat(filepath.Join(dir, ".ax.db")); err == nil { - if database, err = Open(filepath.Join(dir, ".ax.db")); err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - return database, nil - } - if parent := filepath.Dir(dir); parent == dir { - return nil, errors.New("no .ax.db found (run 'ax init' first)") - } else { - dir = parent - } - } -} - -func Init(path string) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - var err error - database, err = Open(path) - return err -} - -func Open(path string) (*DB, error) { - db, err := sql.Open("sqlite", path) - if err != nil { - return nil, err - } - for _, q := range append([]string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}, migrations...) { - if _, err := db.Exec(q); err != nil { - return nil, err - } - } - return &DB{DB: db, path: path}, nil -} - diff --git a/db/rel.go b/db/rel.go deleted file mode 100644 index e6fe95b..0000000 --- a/db/rel.go +++ /dev/null @@ -1,19 +0,0 @@ -package db - -import "axolotl/models" - -func (db *DB) GetRelNames(n *models.Node, r models.RelType) ([]string, error) { - ids := n.Relations[string(r)] - if len(ids) == 0 { - return nil, nil - } - result := make([]string, 0, len(ids)) - for _, id := range ids { - var title string - if err := db.QueryRow("SELECT title FROM nodes WHERE id = ?", id).Scan(&title); err != nil { - return nil, err - } - result = append(result, title) - } - return result, nil -} diff --git a/models/node.go b/models/node.go index 0c17883..5b8ee8c 100644 --- a/models/node.go +++ b/models/node.go @@ -13,18 +13,6 @@ type Node struct { Relations map[string][]string `json:"relations,omitempty"` } -type RelType string - -const ( - RelBlocks RelType = "blocks" - RelSubtask RelType = "subtask" - RelRelated RelType = "related" - RelCreated RelType = "created" - RelAssignee RelType = "assignee" - RelInNamespace RelType = "in_namespace" - RelMentions RelType = "mentions" -) - func (n *Node) GetProperty(k string) string { for _, t := range n.Tags { if strings.HasPrefix(t, "_") { diff --git a/models/rel_type.go b/models/rel_type.go new file mode 100644 index 0000000..bd59e8d --- /dev/null +++ b/models/rel_type.go @@ -0,0 +1,13 @@ +package models + +type RelType string + +const ( + RelBlocks RelType = "blocks" + RelSubtask RelType = "subtask" + RelRelated RelType = "related" + RelCreated RelType = "created" + RelAssignee RelType = "assignee" + RelInNamespace RelType = "in_namespace" + RelMentions RelType = "mentions" +) diff --git a/output/output.go b/output/output.go index 1f28a2a..eb7b2f3 100644 --- a/output/output.go +++ b/output/output.go @@ -1,7 +1,6 @@ package output import ( - "axolotl/db" "axolotl/models" "axolotl/service" "encoding/json" @@ -59,21 +58,7 @@ const ( iconNamespace = "\uf07b" ) -func render(rm RenderMap, key string, short bool) string { - v, ok := rm[key] - if !ok { - v, ok = rm[""] - if !ok { - return "" - } - } - if short { - return v.c.Sprint(v.s) - } - return v.c.Sprint(v.l) -} - -func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error { +func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, jsonOut bool) error { if jsonOut { return json.NewEncoder(w).Encode(nodes) } @@ -82,11 +67,6 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error { return nil } - d, err := db.GetDB() - if err != nil { - return err - } - fmt.Fprintln(w) sort.Slice(nodes, func(i, j int) bool { si, sj := nodes[i].GetProperty("status"), nodes[j].GetProperty("status") @@ -97,9 +77,14 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error { }) for _, n := range nodes { - ns_rels, err := d.GetRelNames(n, models.RelInNamespace) - if err != nil { - return err + ns_rel_node_ids := n.Relations[string(models.RelInNamespace)] + ns_rel_node_titles := make([]string, 0, len(ns_rel_node_ids)) + for _, id := range ns_rel_node_ids { + ns_rel_node, err := svc.GetByID(id) + if err != nil { + fmt.Fprintf(w, "err: %v", err) + } + ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title) } fmt.Fprintf(w, " %s %s %s %s %s %s", cDim.Sprint(n.ID), @@ -107,7 +92,7 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error { render(statusRM, n.GetProperty("status"), true), render(typeRM, n.GetProperty("type"), true), cTitle.Sprint(truncate(n.Title, 80)), - cDim.Sprint("["+strings.Join(ns_rels, ",")+"]"), + cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"), ) tags := n.GetDisplayTags() if len(tags) > 0 { @@ -119,7 +104,7 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error { return nil } -func PrintNode(w io.Writer, n *models.Node, jsonOut bool) error { +func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut bool) error { if jsonOut { return json.NewEncoder(w).Encode(n) } @@ -138,20 +123,17 @@ func PrintNode(w io.Writer, n *models.Node, jsonOut bool) error { fmt.Fprintf(w, "\n tags: %s\n", cPrimary.Sprint(strings.Join(tags, " • "))) } - if db, err := db.GetDB(); err != nil { - fmt.Fprintf(w, "failed to attach to db: %v", err) - } else { - for relType := range n.Relations { - names, err := db.GetRelNames(n, models.RelType(relType)) + for relType := range n.Relations { + rel_node_ids := n.Relations[string(relType)] + if len(rel_node_ids) > 0 { + fmt.Fprintf(w, "\n %s\n", string(relType)) + } + for _, id := range rel_node_ids { + rel_node, err := svc.GetByID(id) if err != nil { fmt.Fprintf(w, "err: %v", err) } - if len(names) > 0 { - fmt.Fprintf(w, "\n %s\n", string(relType)) - } - for _, name := range names { - fmt.Fprintf(w, " %s %s\n", relIcons[relType], name) - } + fmt.Fprintf(w, " %s %s\n", relIcons[relType], rel_node.Title) } } @@ -201,6 +183,20 @@ func PrintAction(w io.Writer, action, detail string, isError bool) { fmt.Fprintln(w, cGood.Sprint(icon+" "+action+" ")+cDim.Sprint(detail)) } +func render(rm RenderMap, key string, short bool) string { + v, ok := rm[key] + if !ok { + v, ok = rm[""] + if !ok { + return "" + } + } + if short { + return v.c.Sprint(v.s) + } + return v.c.Sprint(v.l) +} + func truncate(s string, max int) string { if len(s) <= max { return s diff --git a/service/fileconfig.go b/service/config_file.go similarity index 73% rename from service/fileconfig.go rename to service/config_file.go index ee5edae..ee0cf65 100644 --- a/service/fileconfig.go +++ b/service/config_file.go @@ -3,12 +3,10 @@ package service import ( "encoding/json" "errors" - "fmt" "os" "os/user" "path/filepath" "slices" - "strings" ) type fileConfig struct { @@ -18,13 +16,12 @@ type fileConfig struct { } var defaultAliases = []*Alias{ - {Name: "mine", Command: "list --assignee $me --tag _status::open", Description: "Show open tasks assigned to you"}, - {Name: "due", Command: "list --tag _status::open --tag _due", Description: "Show open tasks with due dates"}, - {Name: "new", Command: "add $@", Description: "Create a new task"}, + {Name: "mine", Command: "list --assignee $me --tag _type::issue --tag _status::open", Description: "Show open issues assigned to you"}, + {Name: "due", Command: "list --tag _type::issue --tag _status::open", Description: "Show open issues"}, {Name: "inbox", Command: "list --mention $me", Description: "Show your inbox"}, } -func LoadConfig() (Config, error) { +func LoadConfigFile() (Config, error) { path, err := findConfigPath() if err != nil { return nil, err @@ -153,34 +150,3 @@ func (c *fileConfig) Save() error { } return os.WriteFile(c.path, data, 0644) } - -func ExpandAlias(alias *Alias, args []string, currentUser string) []string { - cmd := alias.Command - cmd = strings.ReplaceAll(cmd, "$me", currentUser) - - parts := strings.Fields(cmd) - var result []string - - for _, part := range parts { - if part == "$@" { - result = append(result, args...) - continue - } - - hasCatchAll := strings.Contains(part, "$@") - replaced := part - - if hasCatchAll { - replaced = strings.ReplaceAll(replaced, "$@", strings.Join(args, " ")) - } - - for i := len(args) - 1; i >= 0; i-- { - placeholder := fmt.Sprintf("$%d", i+1) - replaced = strings.ReplaceAll(replaced, placeholder, args[i]) - } - - result = append(result, replaced) - } - - return result -} diff --git a/service/node_service.go b/service/node_service.go index d5d1588..e68c56f 100644 --- a/service/node_service.go +++ b/service/node_service.go @@ -12,6 +12,18 @@ type NodeService interface { CanClose(id string) (bool, []string, error) } +func InitNodeService(path string) error { + return InitSqliteDB(path) +} + +func GetNodeService(cfg Config) (NodeService, error) { + db, err := GetSqliteDB(cfg) + if err != nil { + return nil, err + } + return &sqliteNodeService{db: db, userID: cfg.GetUser()}, nil +} + type listFilter struct { tagPrefixes []string assignee string diff --git a/service/node_service_sqlite.go b/service/node_service_sqlite.go index 7cca4eb..338bfd3 100644 --- a/service/node_service_sqlite.go +++ b/service/node_service_sqlite.go @@ -3,10 +3,16 @@ package service import ( "axolotl/models" "database/sql" + "errors" + "fmt" "math/rand" + "os" + "path/filepath" "slices" "strings" "time" + + _ "modernc.org/sqlite" ) type sqliteNodeService struct { @@ -14,8 +20,50 @@ type sqliteNodeService struct { userID string } -func NewSQLiteNodeService(db *sql.DB, userID string) NodeService { - return &sqliteNodeService{db: db, userID: userID} +var migrations = []string{ + `CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + `CREATE TABLE IF NOT EXISTS tags (node_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (node_id, tag), FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS rels (from_id TEXT NOT NULL, to_id TEXT NOT NULL, rel_type TEXT NOT NULL, PRIMARY KEY (from_id, to_id, rel_type), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (to_id) REFERENCES nodes(id) ON DELETE CASCADE)`, + `CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)`, `CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`, `CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`, +} + +func InitSqliteDB(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + var err error + db, err := sql.Open("sqlite", path) + if err != nil { + return err + } + for _, q := range append([]string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}, migrations...) { + if _, err := db.Exec(q); err != nil { + return err + } + } + return err +} + +func GetSqliteDB(cfg Config) (*sql.DB, error) { + dir, err := filepath.Abs(".") + if err != nil { + return nil, err + } + for { + dbpath := filepath.Join(dir, ".ax.db") + if _, err := os.Stat(dbpath); err == nil { + db, err := sql.Open("sqlite", dbpath) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + return db, nil + } + if parent := filepath.Dir(dir); parent == dir { + return nil, errors.New("no .ax.db found (run 'ax init' first)") + } else { + dir = parent + } + } } func (s *sqliteNodeService) GetByID(id string) (*models.Node, error) {