diff --git a/cmd/lard/main.go b/cmd/lard/main.go index 11dc8c3..be47e3c 100644 --- a/cmd/lard/main.go +++ b/cmd/lard/main.go @@ -10,12 +10,12 @@ import ( "os" "os/signal" "path/filepath" - "strings" "syscall" "time" "github.com/taciturnaxolotl/lard/internal/auth" "github.com/taciturnaxolotl/lard/internal/collector" + "github.com/taciturnaxolotl/lard/internal/config" "github.com/taciturnaxolotl/lard/internal/dotenv" "github.com/taciturnaxolotl/lard/internal/httpapi" "github.com/taciturnaxolotl/lard/internal/llm" @@ -38,12 +38,30 @@ func run() error { // missed load would silently leave the service open. dotenv.LoadDefault() - dbPath := envOr("LARD_DB", defaultDBPath()) - if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil { + // Load configuration from TOML file (if present) with env var overrides. + configPath := os.Getenv("LARD_CONFIG") + if configPath == "" { + if d, err := os.UserConfigDir(); err == nil { + configPath = filepath.Join(d, "lard", "config.toml") + } + } + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + // Fill in defaults for paths if not set. + if cfg.DB == "" { + cfg.DB = defaultDBPath() + } + if cfg.MemoryDir == "" { + cfg.MemoryDir = defaultMemDir() + } + + if err := os.MkdirAll(filepath.Dir(cfg.DB), 0o700); err != nil { return err } - memDir := envOr("LARD_MEMORY_DIR", defaultMemDir()) - st, err := store.Open(dbPath, memDir) + st, err := store.Open(cfg.DB, cfg.MemoryDir) if err != nil { return fmt.Errorf("open store: %w", err) } @@ -61,35 +79,35 @@ func run() error { api := httpapi.New(st, llmClient) // Consolidate on its own once uploads go quiet, so a remote collector // feeding this server keeps memory current with nobody poking an endpoint. - if after := envDuration("LARD_CONSOLIDATE_AFTER", httpapi.DefaultConsolidateAfter); after > 0 { - api.EnableAutoConsolidate(after, envDuration("LARD_CONSOLIDATE_MAX_WAIT", httpapi.DefaultConsolidateMaxWait)) + if after := cfg.Consolidate.ConsolidateAfter(); after > 0 { + api.EnableAutoConsolidate(after, cfg.Consolidate.ConsolidateMaxWait()) defer api.StopAutoConsolidate() } mcpSrv := mcpserver.New(api) - cfg := auth.Config{ - Mode: auth.Mode(envOr("LARD_AUTH", string(auth.ModeNone))), - Token: os.Getenv("LARD_TOKEN"), - IndikoURL: envOr("LARD_INDIKO_URL", "https://indiko.dunkirk.sh"), - PublicURL: os.Getenv("LARD_PUBLIC_URL"), - AllowedClientIDs: envList("LARD_OAUTH_CLIENT_IDS"), - AllowedUsers: envList("LARD_OAUTH_USERS"), - RequiredScopes: envList("LARD_OAUTH_SCOPES"), - CollectorClientID: os.Getenv("LARD_COLLECTOR_CLIENT_ID"), + authCfg := auth.Config{ + Mode: auth.Mode(cfg.Auth.Mode), + Token: cfg.Auth.Token, + IndikoURL: cfg.Auth.IndikoURL, + PublicURL: cfg.Auth.PublicURL, + AllowedClientIDs: cfg.Auth.AllowedClientIDs, + AllowedUsers: cfg.Auth.AllowedUsers, + RequiredScopes: cfg.Auth.RequiredScopes, + CollectorClientID: cfg.Auth.CollectorClientID, } // The collector registration: which OAuth client edge collectors adopt. // Login itself is the device grant against the authorization server, so // this server only publishes the identity. collectorCfg := collector.Config{ - ClientID: os.Getenv("LARD_COLLECTOR_CLIENT_ID"), - Scopes: envList("LARD_COLLECTOR_SCOPES"), + ClientID: cfg.Collector.ClientID, + Scopes: cfg.Collector.Scopes, } collectorH := collector.New(collectorCfg) if collectorCfg.Configured() { slog.Info("collector registration published", "client_id", collectorCfg.ClientID) } - for _, warn := range cfg.Validate() { + for _, warn := range authCfg.Validate() { slog.Warn("auth: " + warn) } @@ -98,16 +116,15 @@ func run() error { // The trailing slash covers RFC 9728's path-suffixed documents (e.g. // /.well-known/oauth-protected-resource/mcp), which is what MCP clients // actually request. - mux.Handle(auth.PathProtectedResource, auth.ProtectedResourceMetadata(cfg)) - mux.Handle(auth.PathProtectedResource+"/", auth.ProtectedResourceMetadata(cfg)) - mux.Handle(auth.PathAuthServer, auth.AuthServerMetadata(cfg)) + mux.Handle(auth.PathProtectedResource, auth.ProtectedResourceMetadata(authCfg)) + mux.Handle(auth.PathProtectedResource+"/", auth.ProtectedResourceMetadata(authCfg)) + mux.Handle(auth.PathAuthServer, auth.AuthServerMetadata(authCfg)) mux.HandleFunc("GET "+auth.PathCollector, collectorH.Register) mux.Handle("/", api.Handler()) - addr := envOr("LARD_ADDR", ":7477") srv := &http.Server{ - Addr: addr, - Handler: auth.Middleware(cfg, mux), + Addr: cfg.Addr, + Handler: auth.Middleware(authCfg, mux), ReadHeaderTimeout: 10 * time.Second, } @@ -118,53 +135,13 @@ func run() error { _ = srv.Shutdown(shutdownCtx) }() - slog.Info("lard listening", "addr", addr, "db", dbPath, "memory", memDir, "auth", cfg.Mode, "mcp", addr+"/mcp") + slog.Info("lard listening", "addr", cfg.Addr, "db", cfg.DB, "memory", cfg.MemoryDir, "auth", authCfg.Mode, "mcp", cfg.Addr+"/mcp") if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } return nil } -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -// envList reads a comma-separated env var into a trimmed, non-empty slice. -func envList(key string) []string { - raw := os.Getenv(key) - if raw == "" { - return nil - } - var out []string - for _, part := range strings.Split(raw, ",") { - if p := strings.TrimSpace(part); p != "" { - out = append(out, p) - } - } - return out -} - -// envDuration reads a Go duration (e.g. "5m"). "off" or "0" disables the -// feature by returning zero; an unparseable value falls back to the default. -func envDuration(key string, fallback time.Duration) time.Duration { - raw := strings.TrimSpace(os.Getenv(key)) - if raw == "" { - return fallback - } - if raw == "off" || raw == "never" || raw == "0" { - return 0 - } - d, err := time.ParseDuration(raw) - if err != nil { - slog.Warn("ignoring unparseable duration", "key", key, "value", raw) - return fallback - } - return d -} - func defaultDBPath() string { if d, err := os.UserConfigDir(); err == nil { return filepath.Join(d, "lard", "lard.db") diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..ad5f536 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,35 @@ +# Lard server configuration +# Copy this to ~/.config/lard/config.toml and edit as needed + +# Server settings +addr = ":7477" +db = "" # defaults to ~/.config/lard/lard.db +memory_dir = "" # defaults to ~/.config/lard/memory + +# LLM client (for /consolidate endpoint) +[llm] +base_url = "https://api.openai.com/v1" +model = "gpt-4o-mini" +api_key = "" # or set OPENAI_API_KEY env var +api_version = "" + +# Authentication +[auth] +mode = "none" # none, token, or bearer +token = "" # static token for token mode +indiko_url = "https://indiko.dunkirk.sh" +public_url = "" # your public URL for OAuth redirects +allowed_client_ids = [] # OAuth client IDs allowed to access +allowed_users = [] # user emails/IDs allowed to access +required_scopes = [] # required OAuth scopes +collector_client_id = "" # collector's OAuth client ID + +# Collector registration (published to edge collectors) +[collector] +client_id = "" # the OAuth client ID collectors should use +scopes = ["read", "write"] # scopes granted to collectors + +# Auto-consolidation (runs when uploads go quiet) +[consolidate] +after = "5m" # wait this long after last upload before consolidating +max_wait = "30m" # but consolidate at least this often diff --git a/go.mod b/go.mod index 82ccc23..c64c9e9 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/muesli/roff v0.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/openai/openai-go/v3 v3.43.0 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.1.3 // indirect diff --git a/go.sum b/go.sum index df41f78..0377d15 100644 --- a/go.sum +++ b/go.sum @@ -105,6 +105,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/openai/openai-go/v3 v3.43.0 h1:C+MFVUMU3TJNgES+Ikt7HF8xcX7J0wynKeR9ST22hZM= github.com/openai/openai-go/v3 v3.43.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= diff --git a/internal/client/config.go b/internal/client/config.go index f1a1931..24f0309 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/pelletier/go-toml/v2" "github.com/taciturnaxolotl/lard/internal/xdg" ) @@ -53,9 +54,9 @@ func (t *OAuthToken) expired() bool { return time.Now().After(t.Expiry.Add(-time.Minute)) } -// DefaultConfigPath is ~/.config/lard/client.json. +// DefaultConfigPath is ~/.config/lard/client.toml. func DefaultConfigPath() string { - return xdg.ConfigPath("client.json") + return xdg.ConfigPath("client.toml") } // LoadConfig reads the config file, tolerating absence, then lets the @@ -68,7 +69,7 @@ func LoadConfig(path string) (*Config, error) { return nil, err } if err == nil { - if err := json.Unmarshal(b, cfg); err != nil { + if err := toml.Unmarshal(b, cfg); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } } @@ -91,7 +92,7 @@ func (c *Config) Save(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - b, err := json.MarshalIndent(c, "", " ") + b, err := toml.Marshal(c) if err != nil { return err } diff --git a/internal/client/config_test.go b/internal/client/config_test.go new file mode 100644 index 0000000..0228c46 --- /dev/null +++ b/internal/client/config_test.go @@ -0,0 +1,81 @@ +package client + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigTOML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "client.toml") + content := `url = "https://lard.example.com" +roots = ["/home/user/code", "/home/user/projects"] +token = "secret-token" + +[oauth] +accessToken = "test-access-token" +refreshToken = "test-refresh-token" +clientId = "test-client-id" +expiry = 2024-01-15T10:30:00Z +` + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if cfg.URL != "https://lard.example.com" { + t.Errorf("expected URL https://lard.example.com, got %s", cfg.URL) + } + if len(cfg.Roots) != 2 { + t.Errorf("expected 2 roots, got %d", len(cfg.Roots)) + } + if cfg.Token != "secret-token" { + t.Errorf("expected token secret-token, got %s", cfg.Token) + } + if cfg.OAuth == nil { + t.Fatal("expected OAuth config") + } + if cfg.OAuth.AccessToken != "test-access-token" { + t.Errorf("expected access token test-access-token, got %s", cfg.OAuth.AccessToken) + } +} + +func TestSaveConfigTOML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "client.toml") + + cfg := &Config{ + URL: "https://lard.example.com", + Roots: []string{"/home/user/code"}, + Token: "test-token", + OAuth: &OAuthToken{ + AccessToken: "access-token", + RefreshToken: "refresh-token", + ClientID: "client-id", + }, + } + + if err := cfg.Save(path); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loadedCfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("Reload failed: %v", err) + } + + if loadedCfg.URL != cfg.URL { + t.Errorf("expected URL %s, got %s", cfg.URL, loadedCfg.URL) + } + if len(loadedCfg.Roots) != len(cfg.Roots) { + t.Errorf("expected %d roots, got %d", len(cfg.Roots), len(loadedCfg.Roots)) + } + if loadedCfg.Token != cfg.Token { + t.Errorf("expected token %s, got %s", cfg.Token, loadedCfg.Token) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..be44680 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,200 @@ +// Package config holds the server's configuration, loaded from TOML and +// overridden by environment variables. +package config + +import ( + "os" + "reflect" + "strings" + "time" + + "github.com/pelletier/go-toml/v2" +) + +// Server holds all runtime configuration for the lard server. +type Server struct { + Addr string `toml:"addr" env:"LARD_ADDR" default:":7477"` + DB string `toml:"db" env:"LARD_DB"` + MemoryDir string `toml:"memory_dir" env:"LARD_MEMORY_DIR"` + LLM LLM `toml:"llm"` + Auth Auth `toml:"auth"` + Collector Collector `toml:"collector"` + Consolidate Consolidate `toml:"consolidate"` +} + +// LLM holds OpenAI-compatible client settings. +type LLM struct { + BaseURL string `toml:"base_url" env:"OPENAI_BASE_URL"` + Model string `toml:"model" env:"OPENAI_MODEL"` + APIKey string `toml:"api_key" env:"OPENAI_API_KEY"` + APIVersion string `toml:"api_version" env:"OPENAI_API_VERSION"` +} + +// Auth holds authentication configuration. +type Auth struct { + Mode string `toml:"mode" env:"LARD_AUTH" default:"none"` + Token string `toml:"token" env:"LARD_TOKEN"` + IndikoURL string `toml:"indiko_url" env:"LARD_INDIKO_URL" default:"https://indiko.dunkirk.sh"` + PublicURL string `toml:"public_url" env:"LARD_PUBLIC_URL"` + AllowedClientIDs []string `toml:"allowed_client_ids" env:"LARD_OAUTH_CLIENT_IDS"` + AllowedUsers []string `toml:"allowed_users" env:"LARD_OAUTH_USERS"` + RequiredScopes []string `toml:"required_scopes" env:"LARD_OAUTH_SCOPES"` + CollectorClientID string `toml:"collector_client_id" env:"LARD_COLLECTOR_CLIENT_ID"` +} + +// Collector holds the collector OAuth registration. +type Collector struct { + ClientID string `toml:"client_id" env:"LARD_COLLECTOR_CLIENT_ID"` + Scopes []string `toml:"scopes" env:"LARD_COLLECTOR_SCOPES"` +} + +// Consolidate holds auto-consolidation settings. +type Consolidate struct { + After string `toml:"after" env:"LARD_CONSOLIDATE_AFTER" default:"5m"` + MaxWait string `toml:"max_wait" env:"LARD_CONSOLIDATE_MAX_WAIT" default:"30m"` +} + +// Load reads configuration from a TOML file, then applies environment variable +// overrides. Env vars always win. +func Load(path string) (*Server, error) { + cfg := defaults() + + if path != "" { + b, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if err == nil { + if err := toml.Unmarshal(b, cfg); err != nil { + return nil, err + } + } + } + + applyEnv(cfg) + return cfg, nil +} + +func defaults() *Server { + cfg := &Server{} + applyDefaults(reflect.ValueOf(cfg).Elem()) + return cfg +} + +// applyDefaults recursively walks a struct and applies default values +// based on `default` tags. +func applyDefaults(v reflect.Value) { + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + fieldVal := v.Field(i) + + // Handle nested structs + if fieldVal.Kind() == reflect.Struct { + applyDefaults(fieldVal) + continue + } + + defaultTag := field.Tag.Get("default") + if defaultTag == "" { + continue + } + + // Only set if the field is empty (zero value) + isZero := false + switch fieldVal.Kind() { + case reflect.String: + isZero = fieldVal.String() == "" + case reflect.Slice: + isZero = fieldVal.Len() == 0 + } + + if !isZero { + continue + } + + // Set the default value based on type + switch fieldVal.Kind() { + case reflect.String: + fieldVal.SetString(defaultTag) + case reflect.Slice: + if fieldVal.Type().Elem().Kind() == reflect.String { + fieldVal.Set(reflect.ValueOf(splitList(defaultTag))) + } + } + } +} + +func applyEnv(cfg *Server) { + applyEnvToStruct(reflect.ValueOf(cfg).Elem()) +} + +// applyEnvToStruct recursively walks a struct and applies env var overrides +// based on `env` tags. +func applyEnvToStruct(v reflect.Value) { + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + fieldVal := v.Field(i) + + // Handle nested structs + if fieldVal.Kind() == reflect.Struct { + applyEnvToStruct(fieldVal) + continue + } + + envTag := field.Tag.Get("env") + if envTag == "" { + continue + } + + val := os.Getenv(envTag) + if val == "" { + continue + } + + // Set the field value based on its type + switch fieldVal.Kind() { + case reflect.String: + fieldVal.SetString(val) + case reflect.Slice: + if fieldVal.Type().Elem().Kind() == reflect.String { + fieldVal.Set(reflect.ValueOf(splitList(val))) + } + } + } +} + +func splitList(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} + +// ConsolidateAfter parses the after duration. +func (c *Consolidate) ConsolidateAfter() time.Duration { + if c.After == "" || c.After == "off" || c.After == "never" || c.After == "0" { + return 0 + } + d, err := time.ParseDuration(c.After) + if err != nil { + return 0 + } + return d +} + +// ConsolidateMaxWait parses the max wait duration. +func (c *Consolidate) ConsolidateMaxWait() time.Duration { + if c.MaxWait == "" { + return 30 * time.Minute + } + d, err := time.ParseDuration(c.MaxWait) + if err != nil { + return 30 * time.Minute + } + return d +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..cfd750c --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,146 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadTOML(t *testing.T) { + // Clear all LARD_ env vars to avoid interference + clearLARDEnv() + + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := `addr = ":8080" +db = "/tmp/lard.db" +memory_dir = "/tmp/memory" + +[llm] +base_url = "https://api.openai.com/v1" +model = "gpt-4" +api_key = "sk-test123" + +[auth] +mode = "bearer" +indiko_url = "https://indiko.example.com" +public_url = "https://lard.example.com" +allowed_client_ids = ["client1", "client2"] +allowed_users = ["user@example.com"] +required_scopes = ["profile", "email"] +collector_client_id = "collector-abc" + +[collector] +client_id = "collector-abc" +scopes = ["read", "write"] + +[consolidate] +after = "10m" +max_wait = "1h" +` + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Addr != ":8080" { + t.Errorf("expected addr :8080, got %s", cfg.Addr) + } + if cfg.DB != "/tmp/lard.db" { + t.Errorf("expected db /tmp/lard.db, got %s", cfg.DB) + } + if cfg.LLM.Model != "gpt-4" { + t.Errorf("expected model gpt-4, got %s", cfg.LLM.Model) + } + if cfg.Auth.Mode != "bearer" { + t.Errorf("expected mode bearer, got %s", cfg.Auth.Mode) + } + if len(cfg.Auth.AllowedClientIDs) != 2 { + t.Errorf("expected 2 allowed client IDs, got %d", len(cfg.Auth.AllowedClientIDs)) + } + if cfg.Consolidate.After != "10m" { + t.Errorf("expected consolidate after 10m, got %s", cfg.Consolidate.After) + } +} + +func TestEnvOverrides(t *testing.T) { + clearLARDEnv() + os.Setenv("LARD_ADDR", ":9090") + defer os.Unsetenv("LARD_ADDR") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Addr != ":9090" { + t.Errorf("expected addr :9090 from env, got %s", cfg.Addr) + } +} + +func TestDefaults(t *testing.T) { + clearLARDEnv() + cfg, err := Load("") + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Addr != ":7477" { + t.Errorf("expected default addr :7477, got %s", cfg.Addr) + } + if cfg.Auth.Mode != "none" { + t.Errorf("expected default auth mode none, got %s", cfg.Auth.Mode) + } + if cfg.Auth.IndikoURL != "https://indiko.dunkirk.sh" { + t.Errorf("expected default indiko URL, got %s", cfg.Auth.IndikoURL) + } +} + +func TestConsolidateDurationParsing(t *testing.T) { + c := &Consolidate{After: "5m", MaxWait: "30m"} + if d := c.ConsolidateAfter(); d != 5*60*1e9 { + t.Errorf("expected 5m duration, got %v", d) + } + if d := c.ConsolidateMaxWait(); d != 30*60*1e9 { + t.Errorf("expected 30m duration, got %v", d) + } + + // Test off/never/0 + c2 := &Consolidate{After: "off"} + if d := c2.ConsolidateAfter(); d != 0 { + t.Errorf("expected 0 for 'off', got %v", d) + } +} + +func TestSplitList(t *testing.T) { + result := splitList("a, b, c") + if len(result) != 3 { + t.Errorf("expected 3 items, got %d", len(result)) + } + if result[0] != "a" || result[1] != "b" || result[2] != "c" { + t.Errorf("unexpected items: %v", result) + } + + empty := splitList("") + if empty != nil { + t.Errorf("expected nil for empty string, got %v", empty) + } +} + +func clearLARDEnv() { + envVars := []string{ + "LARD_ADDR", "LARD_DB", "LARD_MEMORY_DIR", + "OPENAI_BASE_URL", "OPENAI_MODEL", "OPENAI_API_KEY", "OPENAI_API_VERSION", + "LARD_AUTH", "LARD_TOKEN", "LARD_INDIKO_URL", "LARD_PUBLIC_URL", + "LARD_OAUTH_CLIENT_IDS", "LARD_OAUTH_USERS", "LARD_OAUTH_SCOPES", + "LARD_COLLECTOR_CLIENT_ID", "LARD_COLLECTOR_SCOPES", + "LARD_CONSOLIDATE_AFTER", "LARD_CONSOLIDATE_MAX_WAIT", + } + for _, v := range envVars { + os.Unsetenv(v) + } +}