diff --git a/src/cmd/alias.go b/src/cmd/alias.go index 47287b4..47e32c2 100644 --- a/src/cmd/alias.go +++ b/src/cmd/alias.go @@ -4,6 +4,7 @@ import ( "axolotl/store" "fmt" "os" + "slices" "github.com/spf13/cobra" ) @@ -15,21 +16,32 @@ var aliasCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { w := cmd.OutOrStdout() if len(args) == 0 { - if aliases, err := cfg.ListAliases(); err == nil { - PrintAliases(w, aliases, jsonFlag) - } + PrintAliases(w, cfg.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) + for _, a := range cfg.Aliases { + if a.Name == args[0] { + fmt.Println(a.Command) + return + } } - fmt.Println(a.Command) - return + fmt.Fprintln(os.Stderr, "alias not found:", args[0]) + os.Exit(1) } - if err := cfg.SetAlias(&store.Alias{Name: args[0], Command: args[1], Description: aliasDesc}); err != nil { + alias := &store.Alias{Name: args[0], Command: args[1], Description: aliasDesc} + found := false + for i, a := range cfg.Aliases { + if a.Name == alias.Name { + cfg.Aliases[i] = alias + found = true + break + } + } + if !found { + cfg.Aliases = append(cfg.Aliases, alias) + } + if err := cfg.Save(); err != nil { fmt.Fprintln(os.Stderr, "failed to set alias:", err) } else { PrintAction(w, "Alias set", args[0], false) @@ -40,7 +52,19 @@ var aliasCmd = &cobra.Command{ var aliasDelCmd = &cobra.Command{ Use: "del ", Short: "Delete an alias", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - if err := cfg.DeleteAlias(args[0]); err != nil { + found := false + for i, a := range cfg.Aliases { + if a.Name == args[0] { + cfg.Aliases = slices.Delete(cfg.Aliases, i, i+1) + found = true + break + } + } + if !found { + fmt.Fprintln(os.Stderr, "alias not found") + os.Exit(1) + } + if err := cfg.Save(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/src/cmd/edit.go b/src/cmd/edit.go index 17783a7..635d24d 100644 --- a/src/cmd/edit.go +++ b/src/cmd/edit.go @@ -33,7 +33,7 @@ var editCmd = &cobra.Command{ tmp.Close() defer os.Remove(tmp.Name()) - c := exec.Command(cfg.GetEditor(), tmp.Name()) + c := exec.Command(cfg.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) diff --git a/src/cmd/login.go b/src/cmd/login.go index 69df479..aa464a3 100644 --- a/src/cmd/login.go +++ b/src/cmd/login.go @@ -15,12 +15,11 @@ 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 { + if cfg.Remote.Host == "" { 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) + base := fmt.Sprintf("http://%s:%d", cfg.Remote.Host, cfg.Remote.Port) sessionID := tryDeviceFlow(base) if sessionID == "" { diff --git a/src/cmd/root.go b/src/cmd/root.go index f0c19de..31a2bfd 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -12,12 +12,12 @@ import ( ) func getNodeService() (service.NodeService, error) { - user := cfg.GetUser() + user := cfg.User if user == "" { return nil, fmt.Errorf("no user configured: run 'ax user set ' first") } - if rc, ok := cfg.GetRemoteConfig(); ok { - base := fmt.Sprintf("http://%s:%d", rc.Host, rc.Port) + if cfg.Remote.Host != "" { + base := fmt.Sprintf("http://%s:%d", cfg.Remote.Host, cfg.Remote.Port) return service.NewRemoteNodeService(base, user), nil } st, err := store.FindAndOpenSQLiteStore() @@ -33,7 +33,7 @@ var rootCmd = &cobra.Command{Use: "ax", Short: "The axolotl issue tracker"} func Execute() { var err error - cfg, err = store.LoadConfigFile() + cfg, err = store.LoadConfig() if err != nil { fmt.Fprintln(os.Stderr, "failed to load config:", err) os.Exit(1) @@ -50,7 +50,7 @@ func init() { func RegisterAliasCommands() { rootCmd.AddGroup(&cobra.Group{ID: "aliases", Title: "Aliases:"}) - aliases, _ := cfg.ListAliases() + aliases := cfg.Aliases for _, a := range aliases { rootCmd.AddCommand(&cobra.Command{ Use: a.Name, @@ -59,7 +59,7 @@ func RegisterAliasCommands() { DisableFlagParsing: true, Run: func(ccmd *cobra.Command, args []string) { acmd := a.Command - acmd = strings.ReplaceAll(acmd, "$me", cfg.GetUser()) + acmd = strings.ReplaceAll(acmd, "$me", cfg.User) parts := strings.Fields(acmd) var expanded []string usedArgs := make([]bool, len(args)) diff --git a/src/cmd/serve.go b/src/cmd/serve.go index 1df02cc..88ec5b4 100644 --- a/src/cmd/serve.go +++ b/src/cmd/serve.go @@ -16,12 +16,11 @@ 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) + addr := fmt.Sprintf("%s:%d", cfg.Serve.Host, cfg.Serve.Port) var oidcCfg *store.OIDCConfig - if oc, ok := cfg.GetOIDCConfig(); ok { - oidcCfg = oc + if cfg.OIDC.Issuer != "" { + oidcCfg = &cfg.OIDC } handler, err := serve.New(func(user string) (service.NodeService, error) { diff --git a/src/store/config.go b/src/store/config.go index 47c3a67..ed1739d 100644 --- a/src/store/config.go +++ b/src/store/config.go @@ -2,40 +2,26 @@ package store import ( "encoding/json" - "errors" "fmt" "os" "os/user" "path/filepath" - "slices" ) -var builtinAliases = []*Alias{ - {Name: "mine", Command: "list --assignee $me", Description: "My assigned issues"}, - {Name: "due", Command: "list --status open", Description: "Open issues"}, - {Name: "inbox", Command: "list --mention $me", Description: "My mentions"}, -} - -func isBuiltinAlias(name string) bool { - for _, a := range builtinAliases { - if a.Name == name { - return true - } - } - return false -} - +// Alias defines a user-defined command shortcut. type Alias struct { Name string `json:"name"` Command string `json:"command"` Description string `json:"description,omitempty"` } +// ServerConfig holds a host:port pair used for both the local server and the remote connection. type ServerConfig struct { Host string `json:"host"` Port int `json:"port"` } +// OIDCConfig holds the settings needed to authenticate users via OpenID Connect. type OIDCConfig struct { Issuer string `json:"issuer"` ClientID string `json:"client_id"` @@ -44,16 +30,86 @@ type OIDCConfig struct { UserClaim string `json:"user_claim"` } +// Config is the central configuration object for ax, loaded from config.json. type Config struct { - path string - User string `json:"user"` - Editor string `json:"editor"` - UserAliases []*Alias `json:"aliases"` - Serve ServerConfig `json:"serve"` - Remote ServerConfig `json:"remote"` - OIDC OIDCConfig `json:"oidc"` + path string + User string `json:"user"` + Editor string `json:"editor"` + Aliases []*Alias `json:"aliases"` + Serve ServerConfig `json:"serve"` + Remote ServerConfig `json:"remote"` + OIDC OIDCConfig `json:"oidc"` } +// LoadConfig reads config.json from the data root and applies environment +// variable overrides (AX_USER, EDITOR) and sensible defaults for any +// unset fields. If no config file exists, a default config is returned. +func LoadConfig() (*Config, error) { + configRoot, err := FindDataRoot(".config") + if err != nil { + return nil, err + } + path := filepath.Join(configRoot, "config.json") + c := &Config{path: path, Aliases: []*Alias{}} + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return nil, err + } + } else { + if err := json.Unmarshal(data, c); err != nil { + return nil, err + } + } + + // Apply env overrides and defaults. + if c.User == "" { + c.User = os.Getenv("AX_USER") + } + if c.User == "" { + if u, err := user.Current(); err == nil { + c.User = u.Username + } else { + c.User = "unknown" + } + } + if c.Editor == "" { + c.Editor = os.Getenv("EDITOR") + } + if c.Editor == "" { + c.Editor = "vi" + } + if c.Serve.Host == "" { + c.Serve.Host = "localhost" + } + if c.Serve.Port == 0 { + c.Serve.Port = 7000 + } + if c.Remote.Host != "" && c.Remote.Port == 0 { + c.Remote.Port = 7000 + } + if c.OIDC.Issuer != "" && c.OIDC.UserClaim == "" { + c.OIDC.UserClaim = "preferred_username" + } + + return c, nil +} + +// Save writes the config back to disk as indented JSON. +func (c *Config) 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) +} + +// FindDataRoot locates the .ax directory by walking up from the current +// working directory. If none is found, it falls back to ~//ax +// (e.g. ~/.config/ax or ~/.local/share/ax). func FindDataRoot(std ...string) (string, error) { dir, err := filepath.Abs(".") if err != nil { @@ -74,145 +130,8 @@ func FindDataRoot(std ...string) (string, error) { } home, err := os.UserHomeDir() if err != nil { - return "", err + return "", fmt.Errorf("could not determine home directory: %w", err) } stdpath := filepath.Join(std...) return filepath.Join(home, stdpath, "ax"), nil } - -func LoadConfigFile() (*Config, error) { - configRoot, err := FindDataRoot(".config") - if err != nil { - return nil, err - } - path := filepath.Join(configRoot, "config.json") - fc := &Config{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 (c *Config) 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 *Config) GetEditor() string { - if c.Editor != "" { - return c.User - } - if u := os.Getenv("EDITOR"); u != "" { - return u - } - return "vi" -} - -func (c *Config) GetAlias(name string) (*Alias, error) { - for _, a := range c.UserAliases { - if a.Name == name { - return a, nil - } - } - return nil, errors.New("alias not found") -} - -func (c *Config) 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 *Config) DeleteAlias(name string) error { - if isBuiltinAlias(name) { - return fmt.Errorf("cannot delete built-in alias %q", name) - } - for i, a := range c.UserAliases { - if a.Name == name { - c.UserAliases = slices.Delete(c.UserAliases, i, i+1) - return c.Save() - } - } - return errors.New("alias not found") -} - -func (c *Config) ListAliases() ([]*Alias, error) { - seen := make(map[string]bool) - var result []*Alias - for _, a := range builtinAliases { - result = append(result, a) - seen[a.Name] = true - } - for _, a := range c.UserAliases { - if !seen[a.Name] { - result = append(result, a) - seen[a.Name] = true - } - } - return result, nil -} - -func (c *Config) GetOIDCConfig() (*OIDCConfig, bool) { - if c.OIDC.Issuer == "" { - return nil, false - } - cfg := c.OIDC - if cfg.UserClaim == "" { - cfg.UserClaim = "preferred_username" - } - return &cfg, true -} - -func (c *Config) GetRemoteConfig() (*ServerConfig, bool) { - if c.Remote.Host == "" { - return nil, false - } - port := c.Remote.Port - if port == 0 { - port = 7000 - } - return &ServerConfig{Host: c.Remote.Host, Port: port}, true -} - -func (c *Config) 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 *Config) 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) -} diff --git a/src/store/session.go b/src/store/session.go index 1c005ec..04c1199 100644 --- a/src/store/session.go +++ b/src/store/session.go @@ -13,6 +13,8 @@ type Session struct { Token string `json:"token"` } +// LoadSession reads the session token from disk. If no session file +// exists, an empty Session is returned (Token will be ""). func LoadSession() (*Session, error) { sessionRoot, err := FindDataRoot(".local", "share") if err != nil { @@ -34,6 +36,7 @@ func LoadSession() (*Session, error) { return &s, nil } +// Save writes the session token to disk with restrictive permissions (0600). func (s *Session) Save() error { if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { return err @@ -45,6 +48,7 @@ func (s *Session) Save() error { return os.WriteFile(s.path, data, 0600) } +// ClearSession deletes the session file from disk. func (s *Session) ClearSession() error { err := os.Remove(s.path) if os.IsNotExist(err) {