move src file to seperate direcotry

This commit is contained in:
2026-04-01 22:29:20 +02:00
parent 228cefb921
commit e42397cc7a
30 changed files with 0 additions and 0 deletions

84
src/cmd/add.go Normal file
View File

@@ -0,0 +1,84 @@
package cmd
import (
"axolotl/models"
"axolotl/output"
"axolotl/service"
"fmt"
"os"
"github.com/spf13/cobra"
)
var cDue, cContent, cType, cStatus, cPrio, cNamespace, cAssignee string
var cTags, cRels []string
var addCmd = &cobra.Command{
Use: "add <title>", Short: "Create a new node", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
input := service.AddInput{
Title: args[0],
Content: cContent,
DueDate: cDue,
}
// --tag is an alias for --rel with no target.
for _, tag := range cTags {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType(tag), Target: ""})
}
// Shorthand flags expand to property rels or edge rels.
if cType != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_type::" + cType), Target: ""})
}
if cStatus != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_status::" + cStatus), Target: ""})
}
if cPrio != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_prio::" + cPrio), Target: ""})
}
if cNamespace != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelInNamespace, Target: cNamespace})
}
if cAssignee != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee})
}
for _, r := range cRels {
ri, err := parseRelInput(r)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
input.Rels = append(input.Rels, ri)
}
n, err := svc.Add(input)
if err != nil {
fmt.Fprintln(os.Stderr, "failed to create:", err)
return
}
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
},
}
func init() {
rootCmd.AddCommand(addCmd)
f := addCmd.Flags()
f.StringVar(&cType, "type", "", "node type (issue, note, …)")
f.StringVar(&cStatus, "status", "", "initial status (open, done)")
f.StringVar(&cPrio, "prio", "", "priority (high, medium, low)")
f.StringVar(&cNamespace, "namespace", "", "namespace name or ID")
f.StringVar(&cAssignee, "assignee", "", "assignee username or ID")
f.StringVar(&cDue, "due", "", "due date")
f.StringVar(&cContent, "content", "", "node body")
f.StringArrayVar(&cTags, "tag", nil, "label tag (alias for --rel tagname)")
f.StringArrayVar(&cRels, "rel", nil, "relation (prefix::value or relname:target)")
}

56
src/cmd/alias.go Normal file
View File

@@ -0,0 +1,56 @@
package cmd
import (
"axolotl/output"
"axolotl/service"
"fmt"
"os"
"github.com/spf13/cobra"
)
var aliasDesc string
var aliasCmd = &cobra.Command{
Use: "alias [name] [command]", Short: "Manage aliases", Args: cobra.MaximumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
w := cmd.OutOrStdout()
if len(args) == 0 {
if aliases, err := cfg.ListAliases(); err == nil {
output.PrintAliases(w, aliases, jsonFlag)
}
return
}
if len(args) == 1 {
a, err := cfg.GetAlias(args[0])
if err != nil {
fmt.Fprintln(os.Stderr, "alias not found:", args[0])
os.Exit(1)
}
fmt.Println(a.Command)
return
}
if err := cfg.SetAlias(&service.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)
}
},
}
var aliasDelCmd = &cobra.Command{
Use: "del <name>", Short: "Delete an alias", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := cfg.DeleteAlias(args[0]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
output.PrintAction(cmd.OutOrStdout(), "Alias deleted", args[0], false)
},
}
func init() {
rootCmd.AddCommand(aliasCmd)
aliasCmd.AddCommand(aliasDelCmd)
aliasCmd.Flags().StringVar(&aliasDesc, "desc", "", "description for the alias")
}

50
src/cmd/del.go Normal file
View File

@@ -0,0 +1,50 @@
package cmd
import (
"axolotl/output"
"axolotl/service"
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
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) {
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
n, err := svc.GetByID(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "node not found: %s\n", args[0])
os.Exit(1)
}
if !dForce {
fmt.Printf("Delete %s '%s'? [y/N]: ", n.GetProperty("type"), n.Title)
r, _ := bufio.NewReader(os.Stdin).ReadString('\n')
if r = strings.TrimSpace(strings.ToLower(r)); r != "y" && r != "yes" {
fmt.Println("Cancelled.")
return
}
}
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)
}
},
}
func init() {
rootCmd.AddCommand(delCmd)
delCmd.Flags().BoolVarP(&dForce, "force", "f", false, "")
}

65
src/cmd/edit.go Normal file
View File

@@ -0,0 +1,65 @@
package cmd
import (
"axolotl/output"
"axolotl/service"
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
)
var editCmd = &cobra.Command{
Use: "edit <id>", Short: "Edit node content in $EDITOR", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
n, err := svc.GetByID(args[0])
if err != nil {
fmt.Fprintln(os.Stderr, "node not found:", args[0])
return
}
tmp, err := os.CreateTemp("", "ax-*.md")
if err != nil {
fmt.Fprintln(os.Stderr, "failed to create temp file:", err)
return
}
tmp.WriteString(n.Content)
tmp.Close()
defer os.Remove(tmp.Name())
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
c := exec.Command(editor, 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)
return
}
content, err := os.ReadFile(tmp.Name())
if err != nil {
fmt.Fprintln(os.Stderr, "failed to read temp file:", err)
return
}
s := string(content)
n, err = svc.Update(args[0], service.UpdateInput{Content: &s})
if err != nil {
fmt.Fprintln(os.Stderr, "failed to update:", err)
return
}
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
},
}
func init() {
rootCmd.AddCommand(editCmd)
}

33
src/cmd/init.go Normal file
View File

@@ -0,0 +1,33 @@
package cmd
import (
"axolotl/output"
"axolotl/service"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
)
var initCmd = &cobra.Command{
Use: "init [path]", Short: "Initialize a new database", Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
p := "."
if len(args) > 0 {
p = args[0]
}
dbPath := filepath.Join(p, ".ax.db")
if _, err := os.Stat(dbPath); err == nil {
fmt.Fprintln(os.Stderr, "database already exists:", dbPath)
os.Exit(1)
}
if err := service.InitNodeService(dbPath); err != nil {
fmt.Fprintln(os.Stderr, "failed to initialize:", err)
os.Exit(1)
}
output.PrintAction(cmd.OutOrStdout(), "Created", dbPath, false)
},
}
func init() { rootCmd.AddCommand(initCmd) }

80
src/cmd/list.go Normal file
View File

@@ -0,0 +1,80 @@
package cmd
import (
"axolotl/models"
"axolotl/output"
"axolotl/service"
"fmt"
"os"
"github.com/spf13/cobra"
)
var lTags, lRels []string
var lStatus, lPrio, lType, lNamespace, lAssignee, lMention string
var listCmd = &cobra.Command{
Use: "list", Short: "List nodes",
Run: func(cmd *cobra.Command, args []string) {
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
var filter service.ListFilter
// --tag is an alias for a label filter with no target.
for _, tag := range lTags {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType(tag), Target: ""})
}
// Shorthand flags expand to property filters or edge filters.
if lStatus != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_status::" + lStatus), Target: ""})
}
if lPrio != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_prio::" + lPrio), Target: ""})
}
if lType != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_type::" + lType), Target: ""})
}
if lNamespace != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: lNamespace})
}
if lAssignee != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee})
}
if lMention != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: lMention})
}
for _, r := range lRels {
ri, err := parseRelInput(r)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to parse relation flag: %v", err)
return
}
filter.Rels = append(filter.Rels, ri)
}
if nodes, err := svc.List(filter); err == nil {
output.PrintNodes(cmd.OutOrStdout(), svc, nodes, jsonFlag)
} else {
fmt.Fprintf(os.Stderr, "err: %v\n", err)
}
},
}
func init() {
rootCmd.AddCommand(listCmd)
f := listCmd.Flags()
f.StringArrayVar(&lTags, "tag", nil, "filter by label tag")
f.StringArrayVar(&lRels, "rel", nil, "filter by relation (prefix::value or relname:target)")
f.StringVar(&lStatus, "status", "", "filter by status")
f.StringVar(&lPrio, "prio", "", "filter by priority")
f.StringVar(&lType, "type", "", "filter by type")
f.StringVar(&lNamespace, "namespace", "", "filter by namespace")
f.StringVar(&lAssignee, "assignee", "", "filter by assignee")
f.StringVar(&lMention, "mention", "", "filter by mention")
}

84
src/cmd/login.go Normal file
View File

@@ -0,0 +1,84 @@
package cmd
import (
"axolotl/service"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
)
var loginCmd = &cobra.Command{
Use: "login",
Short: "Authenticate with the remote server via OIDC",
Run: func(cmd *cobra.Command, args []string) {
rc, ok := cfg.GetRemoteConfig()
if !ok {
fmt.Fprintln(os.Stderr, "no remote server configured; set remote.host in your config")
os.Exit(1)
}
base := fmt.Sprintf("http://%s:%d", rc.Host, rc.Port)
resp, err := http.Post(base+"/auth/start", "application/json", nil)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to contact server: %v\n", err)
os.Exit(1)
}
var start struct {
URL string `json:"url"`
SessionID string `json:"session_id"`
}
json.NewDecoder(resp.Body).Decode(&start)
resp.Body.Close()
if start.URL == "" {
fmt.Fprintln(os.Stderr, "server did not return an auth URL; is OIDC configured on the server?")
os.Exit(1)
}
fmt.Printf("Open this URL in your browser:\n\n %s\n\nWaiting for login...\n", start.URL)
deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) {
time.Sleep(2 * time.Second)
resp, err := http.Get(fmt.Sprintf("%s/auth/poll?session_id=%s", base, start.SessionID))
if err != nil {
continue
}
if resp.StatusCode == http.StatusAccepted {
resp.Body.Close()
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
fmt.Fprintln(os.Stderr, "login failed")
os.Exit(1)
}
var result struct {
Token string `json:"token"`
Username string `json:"username"`
}
json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
if err := service.SaveSession(&service.Session{Token: result.Token}); err != nil {
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
os.Exit(1)
}
fmt.Printf("Logged in as %s\n", result.Username)
return
}
fmt.Fprintln(os.Stderr, "login timed out")
os.Exit(1)
},
}
func init() {
rootCmd.AddCommand(loginCmd)
}

26
src/cmd/rel.go Normal file
View File

@@ -0,0 +1,26 @@
package cmd
import (
"axolotl/models"
"axolotl/service"
"strings"
)
// 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
// - "tagname" → simple label rel with no target (alias for --tag)
func parseRelInput(s string) (service.RelInput, error) {
if strings.Contains(s, "::") {
// Property: name::value — no target node.
return service.RelInput{Type: models.RelType(s), Target: ""}, nil
}
if idx := strings.Index(s, ":"); idx >= 0 {
// Edge rel: relname:target.
return service.RelInput{Type: models.RelType(s[:idx]), Target: s[idx+1:]}, nil
}
// Simple label tag — no target node.
return service.RelInput{Type: models.RelType(s), Target: ""}, nil
}

86
src/cmd/root.go Normal file
View File

@@ -0,0 +1,86 @@
package cmd
import (
"axolotl/service"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
var jsonFlag bool
var cfg service.Config
var rootCmd = &cobra.Command{Use: "ax", Short: "The axolotl issue tracker"}
func Execute() {
var err error
cfg, err = service.LoadConfigFile()
if err != nil {
fmt.Fprintln(os.Stderr, "failed to load config:", err)
os.Exit(1)
}
registerAliasCommands()
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func init() {
rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "")
}
func registerAliasCommands() {
rootCmd.AddGroup(&cobra.Group{ID: "aliases", Title: "Aliases:"})
aliases, _ := cfg.ListAliases()
for _, a := range aliases {
rootCmd.AddCommand(&cobra.Command{
Use: a.Name,
Short: a.Description,
GroupID: "aliases",
DisableFlagParsing: true,
Run: func(ccmd *cobra.Command, args []string) {
acmd := a.Command
acmd = strings.ReplaceAll(acmd, "$me", cfg.GetUser())
parts := strings.Fields(acmd)
var expanded []string
usedArgs := make([]bool, len(args))
for _, part := range parts {
if part == "$@" {
expanded = append(expanded, args...)
for i := range usedArgs {
usedArgs[i] = true
}
continue
}
hasCatchAll := strings.Contains(part, "$@")
replaced := part
if hasCatchAll {
replaced = strings.ReplaceAll(replaced, "$@", strings.Join(args, " "))
for i := range usedArgs {
usedArgs[i] = true
}
}
for i := len(args) - 1; i >= 0; i-- {
placeholder := fmt.Sprintf("$%d", i+1)
if strings.Contains(replaced, placeholder) {
replaced = strings.ReplaceAll(replaced, placeholder, args[i])
usedArgs[i] = true
}
}
expanded = append(expanded, replaced)
}
// Forward any unconsumed args (e.g. --json flag).
for i, arg := range args {
if !usedArgs[i] {
expanded = append(expanded, arg)
}
}
rootCmd.SetArgs(expanded)
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
},
})
}
}

40
src/cmd/serve.go Normal file
View File

@@ -0,0 +1,40 @@
package cmd
import (
"axolotl/serve"
"axolotl/service"
"fmt"
"net/http"
"os"
"github.com/spf13/cobra"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the JSON API server",
Run: func(cmd *cobra.Command, args []string) {
sc := cfg.GetServerConfig()
addr := fmt.Sprintf("%s:%d", sc.Host, sc.Port)
var oidcCfg *service.OIDCConfig
if oc, ok := cfg.GetOIDCConfig(); ok {
oidcCfg = &oc
}
handler, err := serve.New(service.GetNodeServiceForUser, oidcCfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Fprintf(os.Stdout, "listening on %s\n", addr)
if err := http.ListenAndServe(addr, handler); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(serveCmd)
}

32
src/cmd/show.go Normal file
View File

@@ -0,0 +1,32 @@
package cmd
import (
"axolotl/output"
"axolotl/service"
"fmt"
"os"
"github.com/spf13/cobra"
)
var showCmd = &cobra.Command{
Use: "show <id>", Short: "Show node details", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
n, err := svc.GetByID(args[0])
if err != nil {
fmt.Fprintln(os.Stderr, "node not found:", args[0])
os.Exit(1)
}
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
},
}
func init() {
rootCmd.AddCommand(showCmd)
}

113
src/cmd/update.go Normal file
View File

@@ -0,0 +1,113 @@
package cmd
import (
"axolotl/models"
"axolotl/output"
"axolotl/service"
"fmt"
"os"
"github.com/spf13/cobra"
)
var (
uTitle, uContent, uDue string
uClearDue bool
uStatus, uPrio, uType string
uNamespace, uAssignee string
uAddTags, uRmTags, uAddRels, uRmRels []string
)
var updateCmd = &cobra.Command{
Use: "update <id>", Short: "Update a node", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
var input service.UpdateInput
if cmd.Flags().Changed("title") {
input.Title = &uTitle
}
if cmd.Flags().Changed("content") {
input.Content = &uContent
}
if cmd.Flags().Changed("due") {
input.DueDate = &uDue
}
if uClearDue {
empty := ""
input.DueDate = &empty
}
// --tag / --tag-remove are aliases for --rel / --rel-remove with no target.
for _, tag := range uAddTags {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType(tag), Target: ""})
}
for _, tag := range uRmTags {
input.RemoveRels = append(input.RemoveRels, service.RelInput{Type: models.RelType(tag), Target: ""})
}
// Shorthand flags expand to property rels or edge rels.
if cmd.Flags().Changed("type") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_type::" + uType), Target: ""})
}
if cmd.Flags().Changed("status") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_status::" + uStatus), Target: ""})
}
if cmd.Flags().Changed("prio") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_prio::" + uPrio), Target: ""})
}
if cmd.Flags().Changed("namespace") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelInNamespace, Target: uNamespace})
}
if cmd.Flags().Changed("assignee") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee})
}
for _, r := range uAddRels {
ri, err := parseRelInput(r)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
input.AddRels = append(input.AddRels, ri)
}
for _, r := range uRmRels {
ri, err := parseRelInput(r)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
input.RemoveRels = append(input.RemoveRels, ri)
}
n, err := svc.Update(args[0], input)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
},
}
func init() {
rootCmd.AddCommand(updateCmd)
f := updateCmd.Flags()
f.StringVar(&uTitle, "title", "", "new title")
f.StringVar(&uContent, "content", "", "new content")
f.StringVar(&uDue, "due", "", "due date")
f.BoolVar(&uClearDue, "clear-due", false, "clear due date")
f.StringVar(&uStatus, "status", "", "status (open, done)")
f.StringVar(&uPrio, "prio", "", "priority (high, medium, low)")
f.StringVar(&uType, "type", "", "node type")
f.StringVar(&uNamespace, "namespace", "", "namespace name or ID")
f.StringVar(&uAssignee, "assignee", "", "assignee username or ID")
f.StringArrayVar(&uAddTags, "tag", nil, "add label tag (alias for --rel tagname)")
f.StringArrayVar(&uRmTags, "tag-remove", nil, "remove label tag")
f.StringArrayVar(&uAddRels, "rel", nil, "add relation (prefix::value or relname:target)")
f.StringArrayVar(&uRmRels, "rel-remove", nil, "remove relation (prefix::value or relname:target)")
}

1030
src/e2e_test.go Normal file

File diff suppressed because it is too large Load Diff

31
src/go.mod Normal file
View File

@@ -0,0 +1,31 @@
module axolotl
go 1.25.0
require (
github.com/fatih/color v1.18.0
github.com/spf13/cobra v1.10.2
modernc.org/sqlite v1.29.10
)
require (
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/gc/v3 v3.1.2 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/strutil v1.2.1 // indirect
modernc.org/token v1.1.0 // indirect
)

73
src/go.sum Normal file
View File

@@ -0,0 +1,73 @@
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

7
src/main.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "axolotl/cmd"
func main() {
cmd.Execute()
}

103
src/models/node.go Normal file
View File

@@ -0,0 +1,103 @@
package models
import (
"slices"
"strings"
)
type Node struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
DueDate string `json:"due_date,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Tags []string `json:"tags,omitempty"`
Relations map[string][]string `json:"relations,omitempty"`
}
func NewNode() *Node {
return &Node{
Relations: make(map[string][]string),
}
}
func (n *Node) AddTag(tag string) {
if tag == "" {
return
}
// If it's a property (name::value format), replace any existing tag with the same prefix.
if idx := strings.Index(tag, "::"); idx >= 0 {
prefix := tag[:idx+2]
var newTags []string
for _, t := range n.Tags {
if !strings.HasPrefix(t, prefix) {
newTags = append(newTags, t)
}
}
n.Tags = newTags
}
if !slices.Contains(n.Tags, tag) {
n.Tags = append(n.Tags, tag)
}
}
func (n *Node) RemoveTag(tag string) {
var newTags []string
for _, t := range n.Tags {
if t != tag {
newTags = append(newTags, t)
}
}
n.Tags = newTags
}
func (n *Node) AddRelation(relType RelType, target string) {
if n.Relations == nil {
n.Relations = make(map[string][]string)
}
if relType == RelAssignee || relType == RelCreated || relType == RelInNamespace {
n.Relations[string(relType)] = []string{target}
return
}
if !slices.Contains(n.Relations[string(relType)], target) {
n.Relations[string(relType)] = append(n.Relations[string(relType)], target)
}
}
func (n *Node) RemoveRelation(relType RelType, target string) {
if n.Relations == nil {
return
}
var newTgts []string
for _, tgt := range n.Relations[string(relType)] {
if tgt != target {
newTgts = append(newTgts, tgt)
}
}
if len(newTgts) == 0 {
delete(n.Relations, string(relType))
} else {
n.Relations[string(relType)] = newTgts
}
}
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 ""
}
func (n *Node) GetDisplayTags() []string {
var tags []string
for _, t := range n.Tags {
if !strings.HasPrefix(t, "_") {
tags = append(tags, t)
}
}
return tags
}

24
src/models/rel_type.go Normal file
View File

@@ -0,0 +1,24 @@
package models
type RelType string
type Rel struct {
Type RelType
Target string
}
const (
RelBlocks RelType = "blocks"
RelSubtask RelType = "subtask"
RelRelated RelType = "related"
RelCreated RelType = "created"
RelAssignee RelType = "assignee"
RelInNamespace RelType = "in_namespace"
RelMentions RelType = "mentions"
// Permission rels (subject → object). Levels are inclusive and transitive.
RelCanRead RelType = "can_read" // level 1: visible in list/show
RelCanCreateRel RelType = "can_create_rel" // level 2: can create relations between nodes
RelCanWrite RelType = "can_write" // level 3: can update/delete
RelHasOwnership RelType = "has_ownership" // level 4: sole owner; deletion cascades to owned nodes
)

209
src/output/output.go Normal file
View File

@@ -0,0 +1,209 @@
package output
import (
"axolotl/models"
"axolotl/service"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"github.com/fatih/color"
)
type RenderMap map[string]struct {
s string
l string
c *color.Color
}
var (
cPrimary = color.New(color.FgCyan)
cSecond = color.New(color.FgMagenta)
cDim = color.New(color.FgHiBlack)
cText = color.New(color.FgWhite)
cTitle = color.New(color.FgWhite, color.Bold)
cGood = color.New(color.FgGreen)
cWarn = color.New(color.FgYellow)
cBad = color.New(color.FgRed)
typeRM = RenderMap{
"issue": {" ", "\uf188 issue", cSecond},
"note": {"\uf15c", "\uf15c note", cPrimary},
"user": {"\uf007", "\uf007 user", cGood},
"namespace": {"\uf07b", "\uf07b namespace", cWarn},
"": {" ", "n/a", cDim},
}
statusRM = RenderMap{
"open": {"●", "● open", cPrimary},
"done": {"○", "○ done", cDim},
"": {"—", "n/a", cDim},
}
prioRM = RenderMap{
"high": {"\uf0e7", "high", cBad},
"medium": {"\uf0e7", "medium", cWarn},
"low": {" ", "low", cDim},
"": {" ", "n/a", cDim},
}
relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007", "in_namespace": "\uf07b"}
prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1}
statusRanks = map[string]int{"open": 2, "": 1, "done": 0}
)
const (
iconCalendar = "\uf133"
iconCheck = "\uf00c"
iconCross = "\uf00d"
iconNamespace = "\uf07b"
)
func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, jsonOut bool) error {
if jsonOut {
return json.NewEncoder(w).Encode(nodes)
}
if len(nodes) == 0 {
fmt.Fprintln(w, cDim.Sprint("No results."))
return nil
}
fmt.Fprintln(w)
sort.Slice(nodes, func(i, j int) bool {
si, sj := nodes[i].GetProperty("status"), nodes[j].GetProperty("status")
if si != sj {
return statusRanks[si] > statusRanks[sj]
}
return prioRanks[nodes[i].GetProperty("prio")] > prioRanks[nodes[j].GetProperty("prio")]
})
for _, n := range nodes {
n_rels := n.Relations
ns_rel_node_ids := n_rels[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 {
ns_rel_node_titles = append(ns_rel_node_titles, id)
continue
}
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),
render(prioRM, n.GetProperty("prio"), true),
render(statusRM, n.GetProperty("status"), true),
render(typeRM, n.GetProperty("type"), true),
cTitle.Sprint(truncate(n.Title, 80)),
cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"),
)
tags := n.GetDisplayTags()
if len(tags) > 0 {
fmt.Fprintf(w, " %s", cPrimary.Sprint("#"+strings.Join(tags, " #")))
}
fmt.Fprintln(w)
}
fmt.Fprintln(w)
return nil
}
func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut bool) error {
if jsonOut {
return json.NewEncoder(w).Encode(n)
}
fmt.Fprintln(w)
fmt.Fprintf(w, " %s %s %s\n", render(typeRM, n.GetProperty("type"), false), cDim.Sprint(n.ID), cTitle.Sprint(n.Title))
fmt.Fprintln(w, cDim.Sprint(" ───────────────────────────────"))
fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false))
fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false))
if n.DueDate != "" {
fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate)
}
fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt))
fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt))
if tags := n.GetDisplayTags(); len(tags) > 0 {
fmt.Fprintf(w, "\n tags: %s\n", cPrimary.Sprint(strings.Join(tags, " • ")))
}
n_rels := n.Relations
for relType := range n_rels {
rel_node_ids := n_rels[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, " %s %s\n", relIcons[relType], cDim.Sprint(id))
continue
}
fmt.Fprintf(w, " %s %s\n", relIcons[relType], rel_node.Title)
}
}
if n.Content != "" {
fmt.Fprintln(w, "\n"+cPrimary.Sprint(" Content:"))
for i, line := range strings.Split(n.Content, "\n") {
if i > 5 {
fmt.Fprintf(w, "%s ...\n", cDim.Sprint(" │ "))
break
}
fmt.Fprintf(w, "%s%s\n", cDim.Sprint(" │ "), cText.Sprint(line))
}
}
fmt.Fprintln(w)
return nil
}
func PrintAliases(w io.Writer, aliases []*service.Alias, jsonOut bool) error {
if jsonOut {
return json.NewEncoder(w).Encode(aliases)
}
if len(aliases) == 0 {
fmt.Fprintln(w, cDim.Sprint("No aliases defined."))
return nil
}
fmt.Fprintln(w)
for _, a := range aliases {
fmt.Fprintf(w, " %s %s\n", cPrimary.Sprint(a.Name), cDim.Sprint(a.Command))
if a.Description != "" {
fmt.Fprintf(w, " %s\n", cDim.Sprint(a.Description))
}
}
fmt.Fprintln(w)
return nil
}
func PrintAction(w io.Writer, action, detail string, isError bool) {
if isError {
fmt.Fprintln(w, cBad.Sprint(iconCross+" "+action+" ")+cDim.Sprint(detail))
return
}
icon := iconCheck
if action == "Created" {
icon = iconNamespace
}
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
}
return s[:max-1] + "…"
}

210
src/serve/auth.go Normal file
View File

@@ -0,0 +1,210 @@
package serve
import (
"axolotl/service"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// pendingLogin tracks an in-progress authorization code flow.
type pendingLogin struct {
verifier string
state string
created time.Time
serverToken string // set by callback when complete; empty while pending
}
// authHandler owns the OIDC provider connection, the pending login store,
// and the active server-side session map.
type authHandler struct {
mu sync.Mutex
pending map[string]*pendingLogin // loginID → pending state
sessions map[string]string // serverToken → username
cfg service.OIDCConfig
provider *oidc.Provider
oauth2 oauth2.Config
}
func newAuthHandler(cfg service.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")
}
provider, err := oidc.NewProvider(context.Background(), cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("OIDC provider: %w", err)
}
h := &authHandler{
pending: make(map[string]*pendingLogin),
sessions: make(map[string]string),
cfg: cfg,
provider: provider,
oauth2: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: cfg.PublicURL + "/auth/callback",
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "offline_access"},
},
}
go h.cleanup()
return h, nil
}
func (h *authHandler) cleanup() {
for range time.Tick(5 * time.Minute) {
h.mu.Lock()
for id, p := range h.pending {
if time.Since(p.created) > 15*time.Minute {
delete(h.pending, id)
}
}
h.mu.Unlock()
}
}
// lookupSession returns the username for a server-issued token, or "".
func (h *authHandler) lookupSession(token string) string {
h.mu.Lock()
defer h.mu.Unlock()
return h.sessions[token]
}
// POST /auth/start → {url, session_id}
func (h *authHandler) start(w http.ResponseWriter, r *http.Request) {
loginID := randomToken(16)
verifier := randomToken(32)
state := randomToken(16)
authURL := h.oauth2.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
h.mu.Lock()
h.pending[loginID] = &pendingLogin{
verifier: verifier,
state: state,
created: time.Now(),
}
h.mu.Unlock()
writeJSON(w, map[string]string{"url": authURL, "session_id": loginID})
}
// GET /auth/callback — OIDC provider redirects here after user authenticates.
func (h *authHandler) callback(w http.ResponseWriter, r *http.Request) {
stateParam := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
h.mu.Lock()
var loginID string
var pending *pendingLogin
for id, p := range h.pending {
if p.state == stateParam {
loginID, pending = id, p
break
}
}
h.mu.Unlock()
if pending == nil {
http.Error(w, "invalid or expired state", http.StatusBadRequest)
return
}
token, err := h.oauth2.Exchange(r.Context(), code,
oauth2.SetAuthURLParam("code_verifier", pending.verifier),
)
if err != nil {
http.Error(w, "token exchange failed: "+err.Error(), http.StatusBadRequest)
return
}
username, err := h.extractUsername(r.Context(), token)
if err != nil {
http.Error(w, "failed to identify user: "+err.Error(), http.StatusInternalServerError)
return
}
serverToken := randomToken(32)
h.mu.Lock()
h.sessions[serverToken] = username
if p := h.pending[loginID]; p != nil {
p.serverToken = serverToken
}
h.mu.Unlock()
fmt.Fprintln(w, "Login successful! You can close this tab.")
}
// GET /auth/poll?session_id=...
// Returns 202 while pending, 200 {token, username} when done, 404 if expired.
func (h *authHandler) poll(w http.ResponseWriter, r *http.Request) {
loginID := r.URL.Query().Get("session_id")
h.mu.Lock()
p := h.pending[loginID]
h.mu.Unlock()
if p == nil {
writeError(w, http.StatusNotFound, "session not found or expired")
return
}
h.mu.Lock()
serverToken := p.serverToken
if serverToken != "" {
delete(h.pending, loginID) // consume once delivered
}
h.mu.Unlock()
if serverToken == "" {
w.WriteHeader(http.StatusAccepted)
return
}
username := h.lookupSession(serverToken)
writeJSON(w, map[string]string{"token": serverToken, "username": username})
}
func (h *authHandler) extractUsername(ctx context.Context, token *oauth2.Token) (string, error) {
rawID, ok := token.Extra("id_token").(string)
if !ok {
return "", fmt.Errorf("no id_token in response")
}
idToken, err := h.provider.Verifier(&oidc.Config{ClientID: h.cfg.ClientID}).Verify(ctx, rawID)
if err != nil {
return "", err
}
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
return "", err
}
user, _ := claims[h.cfg.UserClaim].(string)
if user == "" {
return "", fmt.Errorf("claim %q not found in token", h.cfg.UserClaim)
}
return user, nil
}
func randomToken(n int) string {
b := make([]byte, n)
rand.Read(b) //nolint:errcheck
return base64.RawURLEncoding.EncodeToString(b)
}
func pkceChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}

41
src/serve/oidc.go Normal file
View File

@@ -0,0 +1,41 @@
package serve
import (
"context"
"net/http"
"strings"
)
type contextKey string
const userContextKey contextKey = "ax_user"
// withSessionAuth wraps a handler with ax session token authentication.
// Auth endpoints (/auth/*) are passed through without a token check.
// All other requests must supply Authorization: Bearer <server_token>.
func withSessionAuth(ah *authHandler, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/auth/") {
next.ServeHTTP(w, r)
return
}
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
writeError(w, http.StatusUnauthorized, "Bearer token required")
return
}
token := strings.TrimPrefix(auth, "Bearer ")
username := ah.lookupSession(token)
if username == "" {
writeError(w, http.StatusUnauthorized, "invalid or expired session; run 'ax login'")
return
}
ctx := context.WithValue(r.Context(), userContextKey, username)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func userFromContext(r *http.Request) string {
v, _ := r.Context().Value(userContextKey).(string)
return v
}

209
src/serve/server.go Normal file
View File

@@ -0,0 +1,209 @@
package serve
import (
"axolotl/models"
"axolotl/service"
"encoding/json"
"net/http"
"strings"
)
// New returns an HTTP handler that exposes NodeService as a JSON API.
// 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) {
s := &server{newSvc: newSvc}
mux := http.NewServeMux()
mux.HandleFunc("GET /nodes", s.listNodes)
mux.HandleFunc("POST /nodes", s.addNode)
mux.HandleFunc("GET /nodes/{id}", s.getNode)
mux.HandleFunc("PATCH /nodes/{id}", s.updateNode)
mux.HandleFunc("DELETE /nodes/{id}", s.deleteNode)
mux.HandleFunc("GET /users", s.listUsers)
mux.HandleFunc("POST /users", s.addUser)
if oidcCfg != nil {
ah, err := newAuthHandler(*oidcCfg)
if err != nil {
return nil, err
}
mux.HandleFunc("POST /auth/start", ah.start)
mux.HandleFunc("GET /auth/callback", ah.callback)
mux.HandleFunc("GET /auth/poll", ah.poll)
return withSessionAuth(ah, mux), nil
}
return mux, nil
}
type server struct {
newSvc func(user string) (service.NodeService, error)
}
func (s *server) svc(w http.ResponseWriter, r *http.Request) (service.NodeService, bool) {
user := userFromContext(r)
if user == "" {
user = r.Header.Get("X-Ax-User")
}
if user == "" {
writeError(w, http.StatusUnauthorized, "X-Ax-User header required")
return nil, false
}
svc, err := s.newSvc(user)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return nil, false
}
return svc, true
}
func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
q := r.URL.Query()
var filter service.ListFilter
for _, tag := range q["tag"] {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType(tag)})
}
for _, rel := range q["rel"] {
filter.Rels = append(filter.Rels, parseRel(rel))
}
for k, prefix := range map[string]string{"type": "_type::", "status": "_status::", "prio": "_prio::"} {
if v := q.Get(k); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType(prefix + v)})
}
}
if v := q.Get("namespace"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: v})
}
if v := q.Get("assignee"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v})
}
if v := q.Get("mention"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: v})
}
nodes, err := svc.List(filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, nodes)
}
func (s *server) addNode(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
var input service.AddInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
n, err := svc.Add(input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, n)
}
func (s *server) getNode(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
n, err := svc.GetByID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, n)
}
func (s *server) updateNode(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
var input service.UpdateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
n, err := svc.Update(r.PathValue("id"), input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, n)
}
func (s *server) deleteNode(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
if err := svc.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) listUsers(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
users, err := svc.ListUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, users)
}
func (s *server) addUser(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
n, err := svc.AddUser(body.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, n)
}
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:]}
}
return service.RelInput{Type: models.RelType(s)}
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

136
src/service/api_client.go Normal file
View File

@@ -0,0 +1,136 @@
package service
import (
"axolotl/models"
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
type apiClient struct {
base string
user string
http *http.Client
}
func (c *apiClient) User() string { return c.user }
func (c *apiClient) do(method, path string, body any) (*http.Response, error) {
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return nil, err
}
}
req, err := http.NewRequest(method, c.base+path, &buf)
if err != nil {
return nil, err
}
if err := c.setAuth(req); err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return c.http.Do(req)
}
// 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()
if err != nil || sess == nil || sess.Token == "" {
req.Header.Set("X-Ax-User", c.user)
return nil
}
req.Header.Set("Authorization", "Bearer "+sess.Token)
return nil
}
func apiDecode[T any](resp *http.Response) (T, error) {
var v T
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var e struct{ Error string }
json.NewDecoder(resp.Body).Decode(&e)
return v, fmt.Errorf("%s", e.Error)
}
return v, json.NewDecoder(resp.Body).Decode(&v)
}
func (c *apiClient) GetByID(id string) (*models.Node, error) {
resp, err := c.do("GET", "/nodes/"+id, nil)
if err != nil {
return nil, err
}
return apiDecode[*models.Node](resp)
}
func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
q := url.Values{}
for _, r := range filter.Rels {
if r.Target == "" {
q.Add("rel", string(r.Type))
} else {
q.Add("rel", string(r.Type)+":"+r.Target)
}
}
path := "/nodes"
if len(q) > 0 {
path += "?" + q.Encode()
}
resp, err := c.do("GET", path, nil)
if err != nil {
return nil, err
}
return apiDecode[[]*models.Node](resp)
}
func (c *apiClient) Add(input AddInput) (*models.Node, error) {
resp, err := c.do("POST", "/nodes", input)
if err != nil {
return nil, err
}
return apiDecode[*models.Node](resp)
}
func (c *apiClient) Update(id string, input UpdateInput) (*models.Node, error) {
resp, err := c.do("PATCH", "/nodes/"+id, input)
if err != nil {
return nil, err
}
return apiDecode[*models.Node](resp)
}
func (c *apiClient) Delete(id string) error {
resp, err := c.do("DELETE", "/nodes/"+id, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var e struct{ Error string }
json.NewDecoder(resp.Body).Decode(&e)
return fmt.Errorf("%s", e.Error)
}
return nil
}
func (c *apiClient) ListUsers() ([]*models.Node, error) {
resp, err := c.do("GET", "/users", nil)
if err != nil {
return nil, err
}
return apiDecode[[]*models.Node](resp)
}
func (c *apiClient) AddUser(name string) (*models.Node, error) {
resp, err := c.do("POST", "/users", map[string]string{"name": name})
if err != nil {
return nil, err
}
return apiDecode[*models.Node](resp)
}

37
src/service/config.go Normal file
View File

@@ -0,0 +1,37 @@
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
}

189
src/service/config_file.go Normal file
View File

@@ -0,0 +1,189 @@
package service
import (
"encoding/json"
"errors"
"os"
"os/user"
"path/filepath"
"slices"
)
type fileConfig struct {
path string
User string `json:"user"`
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 LoadConfigFile() (Config, error) {
path, err := findConfigPath()
if err != nil {
return nil, err
}
return loadConfig(path)
}
func loadConfig(path string) (*fileConfig, error) {
fc := &fileConfig{path: path, UserAliases: []*Alias{}}
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
} else {
if err := json.Unmarshal(data, fc); err != nil {
return nil, err
}
}
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 {
if c.User != "" {
return c.User
}
if u := os.Getenv("AX_USER"); u != "" {
return u
}
if u, err := user.Current(); err == nil {
return u.Username
}
return "unknown"
}
func (c *fileConfig) SetUser(username string) error {
c.User = username
return c.Save()
}
func (c *fileConfig) 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 {
for i, a := range c.UserAliases {
if a.Name == alias.Name {
c.UserAliases[i] = alias
return c.Save()
}
}
c.UserAliases = append(c.UserAliases, alias)
return c.Save()
}
func (c *fileConfig) 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) {
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) {
if c.OIDC.Issuer == "" {
return OIDCConfig{}, false
}
cfg := c.OIDC
if cfg.UserClaim == "" {
cfg.UserClaim = "preferred_username"
}
return cfg, true
}
func (c *fileConfig) GetRemoteConfig() (ServerConfig, bool) {
if c.Remote.Host == "" {
return ServerConfig{}, false
}
port := c.Remote.Port
if port == 0 {
port = 7000
}
return ServerConfig{Host: c.Remote.Host, Port: port}, true
}
func (c *fileConfig) GetServerConfig() ServerConfig {
host := c.Serve.Host
if host == "" {
host = "localhost"
}
port := c.Serve.Port
if port == 0 {
port = 7000
}
return ServerConfig{Host: host, Port: port}
}
func (c *fileConfig) Save() error {
if err := os.MkdirAll(filepath.Dir(c.path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0644)
}

100
src/service/node_service.go Normal file
View File

@@ -0,0 +1,100 @@
package service
import (
"axolotl/models"
"axolotl/store"
"fmt"
"net/http"
)
// NodeService is the single entry point for all node operations.
// All data-model integrity rules are enforced here; callers cannot produce
// invalid state by interacting with this interface alone.
//
// Every NodeService instance is bound to a specific user (see User()).
// GetNodeService returns an error when no user is configured.
type NodeService interface {
// User returns the name/ID of the user this service instance acts on behalf of.
User() string
// Query
GetByID(id string) (*models.Node, error)
List(filter ListFilter) ([]*models.Node, error)
// Lifecycle
Add(input AddInput) (*models.Node, error)
Update(id string, input UpdateInput) (*models.Node, error)
Delete(id string) error
// User management
AddUser(name string) (*models.Node, error)
ListUsers() ([]*models.Node, error)
}
// AddInput describes a new node to create.
// Rels may contain tag rels (Target == ""), property rels (Target == "",
// Type is "prefix::value"), and edge rels (Target is a node name or ID).
// The service applies defaults (type=issue, status=open for issues) and validates.
type AddInput struct {
Title string
Content string
DueDate string
Rels []RelInput
}
// UpdateInput describes changes to apply to an existing node.
// AddRels and RemoveRels accept both tag rels (Target == "") and edge rels.
// Setting _status::done in AddRels is rejected when the node has open blockers.
// Adding assignee or in_namespace rels replaces the previous single target.
type UpdateInput struct {
Title *string
Content *string
DueDate *string // nil = no change; pointer to "" = clear due date
AddRels []RelInput
RemoveRels []RelInput
}
// ListFilter specifies which nodes to return. Empty slices are ignored.
// Tag filters (Target == "") match by rel_name prefix.
// Edge filters (Target != "") are resolved to node IDs.
type ListFilter struct {
Rels []RelInput
}
// RelInput is a typed, directed rel with a target that may be a name or node ID.
// Target == "" means this is a tag or property rel (no target node).
type RelInput struct {
Type models.RelType
Target string // name or node ID; the service resolves names. Empty = tag rel.
}
func InitNodeService(path string) error {
return store.InitSQLiteStore(path)
}
func GetNodeService(cfg Config) (NodeService, error) {
user := cfg.GetUser()
if user == "" {
return nil, fmt.Errorf("no user configured: run 'ax user set <username>' first")
}
if rc, ok := cfg.GetRemoteConfig(); ok {
base := fmt.Sprintf("http://%s:%d", rc.Host, rc.Port)
return &apiClient{base: base, user: user, http: &http.Client{}}, nil
}
st, err := store.FindAndOpenSQLiteStore()
if err != nil {
return nil, err
}
return &nodeServiceImpl{store: st, userID: user}, nil
}
func GetNodeServiceForUser(user string) (NodeService, error) {
if user == "" {
return nil, fmt.Errorf("user is required")
}
st, err := store.FindOrInitSQLiteStore()
if err != nil {
return nil, err
}
return &nodeServiceImpl{store: st, userID: user}, nil
}

View File

@@ -0,0 +1,834 @@
package service
import (
"axolotl/models"
"axolotl/store"
"fmt"
"maps"
"regexp"
"slices"
"strings"
"time"
)
type nodeServiceImpl struct {
store store.Store
userID string
}
var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`)
func mentions(t string) []string {
seen := make(map[string]bool)
for _, m := range mentionRegex.FindAllStringSubmatch(t, -1) {
seen[m[1]] = true
}
return slices.Collect(maps.Keys(seen))
}
func (s *nodeServiceImpl) User() string { return s.userID }
// --- Permission model ---
//
// Four levels (inclusive: higher includes lower):
// 1 can_read visible in list/show
// 2 can_create_rel can create non-permission relations between nodes
// 3 can_write can update/delete a node
// 4 has_ownership sole owner; deletion cascades to owned nodes
//
// Permissions are transitive: if A has level L on B, and B has level M on C,
// then A has level min(L, M) on C. Computed by BFS from the user's own node.
// Users have self-ownership (has_ownership → self), so BFS starts at level 4.
//
// Rules for adding edge rels in Add/Update:
// Non-perm rel A → B : need can_create_rel on A, can_read on B
// Perm rel A --perm_P→ B : need perm_P on B (resource owner grants to any subject)
// Ownership A --has_ownership→ B : need has_ownership on B + can_create_rel on A
// → also removes existing ownership rels pointing to B
//
// Field/tag changes and rel removals require can_write on the node.
const (
permRead = 1
permCreateRel = 2
permWrite = 3
permOwnership = 4
)
// isReferenceRel returns true for rels that point to "identity" nodes (users, namespaces).
// For these rels, the target only needs can_read (not can_create_rel), because users and
// namespaces are globally readable and any node can reference them.
func isReferenceRel(t models.RelType) bool {
switch t {
case models.RelAssignee, models.RelCreated, models.RelMentions, models.RelInNamespace:
return true
}
return false
}
// permRelLevels maps permission rel types to their numeric level.
var permRelLevels = map[models.RelType]int{
models.RelCanRead: permRead,
models.RelCanCreateRel: permCreateRel,
models.RelCanWrite: permWrite,
models.RelHasOwnership: permOwnership,
}
type permContext struct {
levels map[string]int
}
func (pc *permContext) level(nodeID string) int { return pc.levels[nodeID] }
func (pc *permContext) canRead(nodeID string) bool { return pc.levels[nodeID] >= permRead }
func (pc *permContext) canCreateRel(nodeID string) bool { return pc.levels[nodeID] >= permCreateRel }
func (pc *permContext) canWrite(nodeID string) bool { return pc.levels[nodeID] >= permWrite }
func (pc *permContext) hasOwnership(nodeID string) bool { return pc.levels[nodeID] >= permOwnership }
// getPermContext builds a permContext by BFS from the current user's node,
// following permission rels and taking the minimum level along each path.
// User and namespace nodes are made globally readable after the BFS.
// If the user node doesn't exist yet, returns an empty permContext (no access);
// Add operations still work because unresolved targets skip the permission check.
func (s *nodeServiceImpl) getPermContext() (*permContext, error) {
userNodeID, err := s.resolveIDByNameAndType(s.store, s.userID, "user")
if err != nil {
return nil, err
}
pc := &permContext{levels: make(map[string]int)}
if userNodeID == "" {
return pc, nil // user not bootstrapped yet; Add will auto-create user node
}
type entry struct {
nodeID string
level int
}
// Start at the user's own node at ownership level (users have self-ownership).
queue := []entry{{userNodeID, permOwnership}}
for len(queue) > 0 {
curr := queue[0]
queue = queue[1:]
if pc.levels[curr.nodeID] >= curr.level {
continue // already reached at a higher or equal level
}
pc.levels[curr.nodeID] = curr.level
node, err := s.store.GetNode(curr.nodeID)
if err != nil {
continue // node may have been deleted; skip
}
for relType, pLevel := range permRelLevels {
for _, tgt := range node.Relations[string(relType)] {
eff := curr.level
if pLevel < eff {
eff = pLevel
}
if eff > pc.levels[tgt] {
queue = append(queue, entry{tgt, eff})
}
}
}
}
// User and namespace nodes are globally readable (they represent identities,
// and anyone can reference or assign to them).
for _, nodeType := range []string{"user", "namespace"} {
nodes, _ := s.store.FindNodes([]*models.Rel{{Type: models.RelType("_type::" + nodeType), Target: ""}})
for _, n := range nodes {
if pc.levels[n.ID] < permRead {
pc.levels[n.ID] = permRead
}
}
}
return pc, nil
}
// --- Validation ---
var (
validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true}
validStatuses = map[string]bool{"open": true, "done": true}
validPrios = map[string]bool{"high": true, "medium": true, "low": true}
)
// validateRels checks that any _ -prefixed rel names are known system properties
// and that their values are valid. Users may not define custom _ -prefixed rels.
func validateRels(rels []RelInput) error {
for _, r := range rels {
name := string(r.Type)
if !strings.HasPrefix(name, "_") {
continue
}
if v, ok := strings.CutPrefix(name, "_type::"); ok {
if !validTypes[v] {
return fmt.Errorf("invalid type %q: must be one of issue, note, user, namespace", v)
}
} else if v, ok := strings.CutPrefix(name, "_status::"); ok {
if !validStatuses[v] {
return fmt.Errorf("invalid status %q: must be one of open, done", v)
}
} else if v, ok := strings.CutPrefix(name, "_prio::"); ok {
if !validPrios[v] {
return fmt.Errorf("invalid priority %q: must be one of high, medium, low", v)
}
} else {
return fmt.Errorf("invalid relation %q: custom _ prefix not allowed", name)
}
}
return nil
}
// --- Query ---
func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) {
n, err := s.store.GetNode(id)
if err != nil {
return nil, err
}
pc, err := s.getPermContext()
if err != nil {
return nil, err
}
if !pc.canRead(id) {
return nil, fmt.Errorf("permission denied: no read access to node %s", id)
}
return n, nil
}
func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
var storeFilters []*models.Rel
for _, ri := range filter.Rels {
if ri.Target == "" {
storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: ""})
} else {
id, ok := s.lookupRelTarget(ri.Type, ri.Target)
if !ok {
return nil, nil
}
storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: id})
}
}
nodes, err := s.store.FindNodes(storeFilters)
if err != nil {
return nil, err
}
pc, err := s.getPermContext()
if err != nil {
return nil, err
}
var result []*models.Node
for _, n := range nodes {
if pc.canRead(n.ID) {
result = append(result, n)
}
}
return result, nil
}
// --- Lifecycle ---
func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
// Build tag set from tag rels (Target == ""), applying property-replacement semantics.
tmp := models.NewNode()
for _, r := range input.Rels {
if r.Target == "" {
tmp.AddTag(string(r.Type))
}
}
// Apply defaults.
if tmp.GetProperty("type") == "" {
tmp.AddTag("_type::issue")
}
if tmp.GetProperty("type") == "issue" && tmp.GetProperty("status") == "" {
tmp.AddTag("_status::open")
}
// Validate all rels (including the resolved default tags).
tagRels := make([]RelInput, len(tmp.Tags))
for i, t := range tmp.Tags {
tagRels[i] = RelInput{Type: models.RelType(t)}
}
if err := validateRels(append(tagRels, input.Rels...)); err != nil {
return nil, err
}
// Permission checks for edge rels.
pc, err := s.getPermContext()
if err != nil {
return nil, err
}
for _, ri := range input.Rels {
if ri.Target == "" {
continue // tag rel, no target to check
}
targetID, found := s.lookupRelTarget(ri.Type, ri.Target)
if !found {
continue // will be auto-created; skip check
}
permLevel, isPerm := permRelLevels[ri.Type]
switch {
case ri.Type == models.RelHasOwnership:
if !pc.hasOwnership(targetID) {
return nil, fmt.Errorf("permission denied: no ownership of %q to transfer", ri.Target)
}
case isPerm:
if pc.level(targetID) < permLevel {
return nil, fmt.Errorf("permission denied: cannot grant %s on %q", ri.Type, ri.Target)
}
default:
// Non-perm rel: source is the new node (creator gets ownership = can_create_rel).
// Target: reference rels (assignee/mentions/in_namespace) need can_read; others need can_create_rel.
if isReferenceRel(ri.Type) {
if !pc.canRead(targetID) {
return nil, fmt.Errorf("permission denied: no read access to %q", ri.Target)
}
} else {
if !pc.canCreateRel(targetID) {
return nil, fmt.Errorf("permission denied: no create_rel access to %q", ri.Target)
}
}
}
}
hasNamespace := false
for _, ri := range input.Rels {
if ri.Type == models.RelInNamespace && ri.Target != "" {
hasNamespace = true
}
}
id, err := s.store.GenerateID()
if err != nil {
return nil, err
}
err = s.store.Transaction(func(st store.Store) 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
}
// Store tag rels.
for _, t := range tmp.Tags {
if err := st.AddRel(id, t, ""); err != nil {
return err
}
}
// Mentions.
for _, m := range mentions(input.Title + " " + input.Content) {
userID, err := s.resolveUserRef(st, m)
if err != nil {
return err
}
if err := st.AddRel(id, string(models.RelMentions), userID); err != nil {
return err
}
}
// Edge rels.
hasCreated := false
for _, ri := range input.Rels {
if ri.Target == "" {
continue // already stored as tag
}
if ri.Type == models.RelCreated {
hasCreated = true
}
resolved, err := s.resolveRelTarget(st, ri)
if err != nil {
return err
}
if ri.Type == models.RelHasOwnership {
// Ownership transfer: remove existing owner of the target.
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
for _, owner := range existingOwners {
st.RemoveRel(owner.ID, string(models.RelHasOwnership), resolved) //nolint:errcheck
}
}
if err := st.AddRel(id, string(ri.Type), resolved); err != nil {
return err
}
}
// Default namespace.
if !hasNamespace {
nsID, err := s.resolveNamespaceRef(st, s.userID)
if err != nil {
return err
}
if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil {
return err
}
}
// Default created.
if !hasCreated {
userID, err := s.resolveUserRef(st, s.userID)
if err != nil {
return err
}
if err := st.AddRel(id, string(models.RelCreated), userID); err != nil {
return err
}
}
// Grant creator ownership of the new node.
creatorID, err := s.resolveUserRef(st, s.userID)
if err != nil {
return err
}
if err := st.AddRel(creatorID, string(models.RelHasOwnership), id); err != nil {
return err
}
// Namespace bootstrap: when creating a namespace node directly, apply the
// same setup as ensureNamespace — self in_namespace and creator ownership.
if tmp.GetProperty("type") == "namespace" {
if !hasNamespace {
// Replace the default namespace rel (user's ns) with self-reference.
userNsID, _ := s.resolveIDByNameAndType(st, s.userID, "namespace")
if userNsID != "" {
if err := st.RemoveRel(id, string(models.RelInNamespace), userNsID); err != nil {
return err
}
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return err
}
}
// Creator already gets ownership via the block above; nothing more to do.
}
return nil
})
if err != nil {
return nil, err
}
return s.store.GetNode(id)
}
func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, error) {
// Validate rels before doing any I/O.
if err := validateRels(input.AddRels); err != nil {
return nil, err
}
// --- Permission checks ---
pc, err := s.getPermContext()
if err != nil {
return nil, err
}
// Field/tag changes and rel removals require can_write on the node.
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil
for _, ri := range input.AddRels {
if ri.Target == "" {
needsWrite = true
break
}
}
if len(input.RemoveRels) > 0 {
needsWrite = true
}
if needsWrite && !pc.canWrite(id) {
return nil, fmt.Errorf("permission denied: no write access to node %s", id)
}
// Check each edge rel being added.
for _, ri := range input.AddRels {
if ri.Target == "" {
continue // tag — handled above
}
permLevel, isPerm := permRelLevels[ri.Type]
targetID, found := s.lookupRelTarget(ri.Type, ri.Target)
switch {
case ri.Type == models.RelHasOwnership:
if !found {
return nil, fmt.Errorf("ownership target %q not found", ri.Target)
}
if !pc.hasOwnership(targetID) {
return nil, fmt.Errorf("permission denied: no ownership of %q to transfer", ri.Target)
}
if !pc.canCreateRel(id) {
return nil, fmt.Errorf("permission denied: no create_rel access to node %s", id)
}
case isPerm:
// Perm rel: need perm_P on target; no check on source.
if found && pc.level(targetID) < permLevel {
return nil, fmt.Errorf("permission denied: insufficient permission on %q to grant %s", ri.Target, ri.Type)
}
default:
// Non-perm rel: need can_create_rel on source.
// Target: reference rels (assignee/mentions/in_namespace) need can_read; others need can_create_rel.
if !pc.canCreateRel(id) {
return nil, fmt.Errorf("permission denied: no create_rel access to node %s", id)
}
if found {
if isReferenceRel(ri.Type) {
if !pc.canRead(targetID) {
return nil, fmt.Errorf("permission denied: no read access to %s target %q", ri.Type, ri.Target)
}
} else {
if !pc.canCreateRel(targetID) {
return nil, fmt.Errorf("permission denied: no create_rel access to %s target %q", ri.Type, ri.Target)
}
}
}
}
}
// Enforce blocking constraint before allowing status=done.
for _, r := range input.AddRels {
if r.Target == "" && string(r.Type) == "_status::done" {
if err := s.checkBlockers(id); err != nil {
return nil, err
}
break
}
}
err = s.store.Transaction(func(st store.Store) error {
current, err := st.GetNode(id)
if err != nil {
return err
}
title, content, dueDate := current.Title, current.Content, current.DueDate
if input.Title != nil {
title = *input.Title
}
if input.Content != nil {
content = *input.Content
}
if input.DueDate != nil {
dueDate = *input.DueDate
}
now := time.Now().UTC().Format(time.RFC3339)
if err := st.UpdateNode(id, title, content, dueDate, now); err != nil {
return err
}
// Compute new tag set using the model's AddTag/RemoveTag to preserve
// property-prefix replacement semantics.
tmp := models.NewNode()
for _, t := range current.Tags {
tmp.AddTag(t)
}
for _, r := range input.AddRels {
if r.Target == "" {
tmp.AddTag(string(r.Type))
}
}
for _, r := range input.RemoveRels {
if r.Target == "" {
tmp.RemoveTag(string(r.Type))
}
}
currentTags, newTags := current.Tags, tmp.Tags
for _, t := range currentTags {
if !slices.Contains(newTags, t) {
if err := st.RemoveRel(id, t, ""); err != nil {
return err
}
}
}
for _, t := range newTags {
if !slices.Contains(currentTags, t) {
if err := st.AddRel(id, t, ""); err != nil {
return err
}
}
}
// Sync mention edges when title or content changed.
if input.Title != nil || input.Content != nil {
if err := s.syncMentions(st, id, current, title, content); err != nil {
return err
}
}
currentRels := current.Relations
for _, ri := range input.AddRels {
if ri.Target == "" {
continue // already handled as tag
}
resolved, err := s.resolveRelTarget(st, ri)
if err != nil {
return err
}
// Single-value relations replace the previous target.
if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace {
for _, oldTgt := range currentRels[string(ri.Type)] {
if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil {
return err
}
}
}
// Ownership transfer: enforce single-owner constraint.
if ri.Type == models.RelHasOwnership {
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
for _, owner := range existingOwners {
st.RemoveRel(owner.ID, string(models.RelHasOwnership), resolved) //nolint:errcheck
}
}
if err := st.AddRel(id, string(ri.Type), resolved); err != nil {
return err
}
}
for _, ri := range input.RemoveRels {
if ri.Target == "" {
continue // already handled as tag
}
resolved, err := s.resolveRelTarget(st, ri)
if err != nil {
return err
}
if err := st.RemoveRel(id, string(ri.Type), resolved); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return s.store.GetNode(id)
}
func (s *nodeServiceImpl) Delete(id string) error {
pc, err := s.getPermContext()
if err != nil {
return err
}
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.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 {
if visited[id] {
return nil
}
visited[id] = true
node, err := st.GetNode(id)
if err != nil {
return err
}
// Capture owned node IDs before deleting (DeleteNode cascades the rels).
ownedIDs := make([]string, len(node.Relations[string(models.RelHasOwnership)]))
copy(ownedIDs, node.Relations[string(models.RelHasOwnership)])
if err := st.DeleteNode(id); err != nil {
return err
}
for _, ownedID := range ownedIDs {
if ownedID == id {
continue // skip self-ownership
}
s.cascadeDelete(st, ownedID, visited) //nolint:errcheck — node may already be gone
}
return nil
}
// --- User management ---
func (s *nodeServiceImpl) AddUser(name string) (*models.Node, error) {
var id string
err := s.store.Transaction(func(st store.Store) error {
var err error
id, err = s.ensureUser(st, name)
return err
})
if err != nil {
return nil, err
}
return s.store.GetNode(id)
}
func (s *nodeServiceImpl) ListUsers() ([]*models.Node, error) {
return s.store.FindNodes([]*models.Rel{{Type: "_type::user", Target: ""}})
}
// --- Internal helpers ---
func (s *nodeServiceImpl) checkBlockers(id string) error {
blockers, err := s.store.FindNodes([]*models.Rel{{Type: models.RelBlocks, Target: id}})
if err != nil {
return err
}
var blocking []string
for _, b := range blockers {
if b.GetProperty("status") == "open" {
blocking = append(blocking, b.ID)
}
}
if len(blocking) > 0 {
return fmt.Errorf("cannot close: blocked by %v", blocking)
}
return nil
}
func (s *nodeServiceImpl) syncMentions(st store.Store, 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
}
mentionedUserIDs := make(map[string]bool)
for _, m := range mentions(newTitle + " " + newContent) {
userID, err := s.resolveUserRef(st, m)
if err != nil {
return err
}
mentionedUserIDs[userID] = true
if !existingMentionIDs[userID] {
if err := st.AddRel(id, string(models.RelMentions), userID); err != nil {
return err
}
}
}
for uid := range existingMentionIDs {
if !mentionedUserIDs[uid] {
if err := st.RemoveRel(id, string(models.RelMentions), uid); err != nil {
return err
}
}
}
return nil
}
// 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) {
switch ri.Type {
case models.RelAssignee, models.RelCreated, models.RelMentions:
return s.resolveUserRef(st, ri.Target)
case models.RelInNamespace:
return s.resolveNamespaceRef(st, ri.Target)
default:
// Permission rels and all other edge rels expect raw node IDs.
return ri.Target, nil
}
}
// lookupRelTarget resolves a filter target to a node ID without creating anything.
// Returns ("", false) when the target doesn't exist.
func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) (string, bool) {
if exists, _ := s.store.NodeExists(target); exists {
return target, true
}
var nodeType string
switch relType {
case models.RelAssignee, models.RelCreated, models.RelMentions:
nodeType = "user"
case models.RelInNamespace:
nodeType = "namespace"
default:
// Permission rels and other edge rels use raw node IDs.
return "", false
}
id, err := s.resolveIDByNameAndType(s.store, target, nodeType)
if err != nil || id == "" {
return "", false
}
return id, true
}
// resolveIDByNameAndType finds a node by title and _type property without creating it.
func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType string) (string, error) {
nodes, err := st.FindNodes([]*models.Rel{{Type: models.RelType("_type::" + nodeType), Target: ""}})
if err != nil {
return "", err
}
for _, n := range nodes {
if n.Title == title {
return n.ID, nil
}
}
return "", nil
}
func (s *nodeServiceImpl) resolveUserRef(st store.Store, 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) {
userID, err := s.resolveIDByNameAndType(st, username, "user")
if err != nil {
return "", err
}
if userID != "" {
return userID, nil
}
id, err := st.GenerateID()
if err != nil {
return "", err
}
now := time.Now().UTC().Format(time.RFC3339)
if err := st.AddNode(id, username, "", "", now, now); err != nil {
return "", err
}
if err := st.AddRel(id, "_type::user", ""); err != nil {
return "", err
}
// Users have self-ownership by default.
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
return "", err
}
return id, nil
}
func (s *nodeServiceImpl) resolveNamespaceRef(st store.Store, 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) {
nsID, err := s.resolveIDByNameAndType(st, name, "namespace")
if err != nil {
return "", err
}
if nsID != "" {
return nsID, nil
}
id, err := st.GenerateID()
if err != nil {
return "", err
}
now := time.Now().UTC().Format(time.RFC3339)
if err := st.AddNode(id, name, "", "", now, now); err != nil {
return "", err
}
if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
userID, err := s.resolveUserRef(st, s.userID)
if err != nil {
return "", err
}
if err := st.AddRel(id, string(models.RelCreated), userID); err != nil {
return "", err
}
// Creator owns the namespace.
if err := st.AddRel(userID, string(models.RelHasOwnership), id); err != nil {
return "", err
}
return id, nil
}

67
src/service/session.go Normal file
View File

@@ -0,0 +1,67 @@
package service
import (
"encoding/json"
"os"
"path/filepath"
)
// 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 {
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()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var s Session
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
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 {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func ClearSession() error {
path, err := sessionPath()
if err != nil {
return err
}
err = os.Remove(path)
if os.IsNotExist(err) {
return nil
}
return err
}

386
src/store/sqlite.go Normal file
View File

@@ -0,0 +1,386 @@
package store
import (
"axolotl/models"
"database/sql"
"errors"
"fmt"
"math/rand"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
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 rels (from_id TEXT NOT NULL, rel_name TEXT NOT NULL, to_id TEXT NOT NULL DEFAULT '', PRIMARY KEY (from_id, rel_name, to_id), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE)`,
`CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`,
`CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`,
}
// querier abstracts *sql.DB and *sql.Tx so SQL helpers work for both.
type querier interface {
Exec(query string, args ...any) (sql.Result, error)
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
}
// SQLiteStore is the top-level Store backed by a SQLite database file.
type SQLiteStore struct {
db *sql.DB
}
// txStore wraps an active transaction. Its Transaction method is a no-op
// passthrough so nested calls reuse the same transaction.
type txStore struct {
db *sql.DB
tx *sql.Tx
}
// InitSQLiteStore creates the database file and applies the schema.
// It is idempotent (uses CREATE TABLE IF NOT EXISTS).
func InitSQLiteStore(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return err
}
defer db.Close()
pragmas := []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}
for _, q := range append(pragmas, migrations...) {
if _, err := db.Exec(q); err != nil {
return err
}
}
return nil
}
// 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) {
if dbpath := os.Getenv("AX_DB_PATH"); dbpath != "" {
return NewSQLiteStore(dbpath)
}
dir, err := filepath.Abs(".")
if err != nil {
return nil, err
}
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
}
}
}
// 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) {
if dbpath := os.Getenv("AX_DB_PATH"); dbpath != "" {
if err := InitSQLiteStore(dbpath); err != nil {
return nil, err
}
return NewSQLiteStore(dbpath)
}
dir, err := filepath.Abs(".")
if err != nil {
return nil, err
}
for {
dbpath := filepath.Join(dir, ".ax.db")
if _, err := os.Stat(dbpath); err == nil {
return NewSQLiteStore(dbpath)
}
if parent := filepath.Dir(dir); parent == dir {
break
} else {
dir = parent
}
}
// Not found — create and initialise in CWD.
cwd, _ := filepath.Abs(".")
dbpath := filepath.Join(cwd, ".ax.db")
if err := InitSQLiteStore(dbpath); err != nil {
return nil, err
}
return NewSQLiteStore(dbpath)
}
// 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) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// FK must be OFF during migration (table drops/renames).
for _, q := range []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=OFF"} {
if _, err := db.Exec(q); err != nil {
db.Close()
return nil, err
}
}
if err := migrateSchema(db); err != nil {
db.Close()
return nil, fmt.Errorf("schema migration failed: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
db.Close()
return nil, err
}
return &SQLiteStore{db: db}, nil
}
// migrateSchema migrates from the legacy two-table (tags + rels) schema to the
// unified rels-only schema. It is a no-op when migration has already been applied.
func migrateSchema(db *sql.DB) error {
var tagsExists int
if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='tags'").Scan(&tagsExists); err != nil {
return err
}
if tagsExists == 0 {
return nil // already on new schema
}
for _, stmt := range []string{
`CREATE TABLE rels_new (from_id TEXT NOT NULL, rel_name TEXT NOT NULL, to_id TEXT NOT NULL DEFAULT '', PRIMARY KEY (from_id, rel_name, to_id), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE)`,
`INSERT OR IGNORE INTO rels_new (from_id, rel_name, to_id) SELECT from_id, rel_type, to_id FROM rels`,
`INSERT OR IGNORE INTO rels_new (from_id, rel_name, to_id) SELECT node_id, tag, '' FROM tags`,
`DROP TABLE rels`,
`DROP TABLE tags`,
`ALTER TABLE rels_new RENAME TO rels`,
`CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`,
`CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`,
} {
if _, err := db.Exec(stmt); err != nil {
return err
}
}
return nil
}
// --- Transaction ---
func (s *SQLiteStore) Transaction(fn func(Store) error) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := fn(&txStore{db: s.db, tx: tx}); err != nil {
return err
}
return tx.Commit()
}
func (s *txStore) Transaction(fn func(Store) 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{}
if dueDate != "" {
dd = dueDate
}
_, err := q.Exec(
"INSERT INTO nodes (id, title, content, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
id, title, content, dd, createdAt, updatedAt,
)
return err
}
func getNode(q querier, id string) (*models.Node, error) {
n := models.NewNode()
err := q.QueryRow(
"SELECT id, title, COALESCE(content, ''), COALESCE(due_date, ''), created_at, updated_at FROM nodes WHERE id = ?", id,
).Scan(&n.ID, &n.Title, &n.Content, &n.DueDate, &n.CreatedAt, &n.UpdatedAt)
if err != nil {
return nil, err
}
rows, err := q.Query("SELECT rel_name, to_id FROM rels WHERE from_id = ?", id)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var relName, toID string
rows.Scan(&relName, &toID)
if toID == "" {
n.AddTag(relName)
} else {
n.AddRelation(models.RelType(relName), toID)
}
}
return n, nil
}
func updateNode(q querier, id, title, content, dueDate, updatedAt string) error {
var dd interface{}
if dueDate != "" {
dd = dueDate
}
_, err := q.Exec(
"UPDATE nodes SET title = ?, content = ?, due_date = ?, updated_at = ? WHERE id = ?",
title, content, dd, updatedAt, id,
)
return err
}
func deleteNode(q querier, id string) error {
_, err := q.Exec("DELETE FROM nodes WHERE id = ?", id)
return err
}
func nodeExists(q querier, id string) (bool, error) {
var e bool
err := q.QueryRow("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = ?)", id).Scan(&e)
return e, err
}
func (s *SQLiteStore) 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 {
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 *txStore) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error {
return addNode(s.tx, id, title, content, dueDate, createdAt, updatedAt)
}
func (s *txStore) GetNode(id string) (*models.Node, error) { return getNode(s.tx, id) }
func (s *txStore) UpdateNode(id, title, content, dueDate, updatedAt string) error {
return updateNode(s.tx, id, title, content, dueDate, updatedAt)
}
func (s *txStore) DeleteNode(id string) error { return deleteNode(s.tx, id) }
func (s *txStore) NodeExists(id string) (bool, error) { return nodeExists(s.db, id) }
// --- ID generation ---
func genID() string {
b := make([]byte, 5)
for i := range b {
b[i] = "abcdefghijklmnopqrstuvwxyz"[rand.Intn(26)]
}
return string(b)
}
func generateID(q querier) (string, error) {
for {
id := genID()
exists, err := nodeExists(q, id)
if err != nil {
return "", err
}
if !exists {
return id, nil
}
}
}
func (s *SQLiteStore) GenerateID() (string, error) { return generateID(s.db) }
func (s *txStore) GenerateID() (string, error) { return generateID(s.db) }
// --- Rel operations ---
func addRel(q querier, nodeID, relName, toID string) error {
_, err := q.Exec("INSERT OR IGNORE INTO rels (from_id, rel_name, to_id) VALUES (?, ?, ?)", nodeID, relName, toID)
return err
}
func removeRel(q querier, nodeID, relName, toID string) error {
_, err := q.Exec("DELETE FROM rels WHERE from_id = ? AND rel_name = ? AND to_id = ?", nodeID, relName, toID)
return err
}
func (s *SQLiteStore) AddRel(nodeID, relName, toID string) error {
return addRel(s.db, nodeID, relName, toID)
}
func (s *SQLiteStore) RemoveRel(nodeID, relName, toID string) error {
return removeRel(s.db, nodeID, relName, toID)
}
func (s *txStore) AddRel(nodeID, relName, toID string) error {
return addRel(s.tx, nodeID, relName, toID)
}
func (s *txStore) RemoveRel(nodeID, relName, toID string) error {
return removeRel(s.tx, nodeID, relName, toID)
}
// --- FindNodes ---
func findNodes(q querier, filters []*models.Rel) ([]*models.Node, error) {
query := "SELECT DISTINCT n.id FROM nodes n"
var joins []string
var args []any
for i, f := range filters {
alias := fmt.Sprintf("r%d", i)
if f.Target == "" {
// Tag/property filter: match rels with empty to_id by rel_name prefix.
joins = append(joins, fmt.Sprintf(
"JOIN rels %s ON n.id = %s.from_id AND %s.to_id = '' AND %s.rel_name LIKE ? || '%%'",
alias, alias, alias, alias,
))
args = append(args, string(f.Type))
} else {
// Edge filter: match rel by exact rel_name and to_id.
joins = append(joins, fmt.Sprintf(
"JOIN rels %s ON n.id = %s.from_id AND %s.rel_name = ? AND %s.to_id = ?",
alias, alias, alias, alias,
))
args = append(args, string(f.Type), f.Target)
}
}
if len(joins) > 0 {
query += " " + strings.Join(joins, " ")
}
query += " ORDER BY n.created_at DESC"
rows, err := q.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
var nodes []*models.Node
for _, id := range ids {
n, err := getNode(q, id)
if err != nil {
return nil, err
}
nodes = append(nodes, n)
}
return nodes, nil
}
func (s *SQLiteStore) FindNodes(filters []*models.Rel) ([]*models.Node, error) {
return findNodes(s.db, filters)
}
func (s *txStore) FindNodes(filters []*models.Rel) ([]*models.Node, error) {
return findNodes(s.tx, filters)
}

30
src/store/store.go Normal file
View File

@@ -0,0 +1,30 @@
package store
import "axolotl/models"
// Store 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 {
// Nodes
AddNode(id, title, content, dueDate, createdAt, updatedAt string) error
GetNode(id string) (*models.Node, error) // returns node with tags and rels populated
UpdateNode(id, title, content, dueDate, updatedAt string) error // empty dueDate stores NULL
DeleteNode(id string) error
NodeExists(id string) (bool, error)
GenerateID() (string, error) // returns a random 5-char ID guaranteed unique in the store
// Rels: relName is the relation name; toID is empty for "tag" rels (properties/labels).
AddRel(nodeID, relName, toID string) error
RemoveRel(nodeID, relName, toID string) error
// FindNodes returns fully-populated nodes matching all given filters.
// Filters with empty Target match nodes by rel_name prefix with empty toID (tag/property).
// Filters with non-empty Target match nodes by exact rel_name and toID (edge).
FindNodes(filters []*models.Rel) ([]*models.Node, error)
// 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
}