// Package config loads runtime configuration from a JSON file, with environment // variables taking precedence. Currently it carries the PostgreSQL connection. package config import ( "encoding/json" "errors" "fmt" "io/fs" "net/url" "os" "path/filepath" "strconv" "strings" ) // Database is the PostgreSQL connection config. Either set DSN directly, or set // the component fields and a DSN is assembled from them. type Database struct { DSN string `json:"dsn"` Host string `json:"host"` Port int `json:"port"` User string `json:"user"` Password string `json:"password"` DBName string `json:"dbname"` SSLMode string `json:"sslmode"` } // Config is the on-disk config file shape. type Config struct { Database Database `json:"database"` SkillDir string `json:"skill_dir"` } // BaseDir is the directory that anchors all runtime artifacts (config.json and // the data/ store). It is the directory the running binary lives in, so a // distributed executable keeps its files next to itself on any OS (Windows, // Linux, …) regardless of the working directory it is launched from. // // When launched via `go run`, the binary is throwaway: it sits either in a temp // build dir (cache miss → fresh link) OR straight inside the Go build cache // (cache hit → run from $GOCACHE/.../...-d). We detect both and fall back to the // current working directory so dev artifacts (data/, transcripts) and config // resolve against the project dir, not the throwaway binary's location. func BaseDir() string { exe, err := os.Executable() if err != nil { return "." } dir := filepath.Dir(exe) if isGoRunDir(dir) { return "." // throwaway `go run` binary → use CWD } return dir } // isGoRunDir reports whether dir is where `go run` parked its executable: under // the system temp dir (cache miss), or anywhere inside a Go build cache // (…/go-build/…, the cache-hit case — NOT under os.TempDir(), which is why the // old temp-only check failed intermittently). In both cases the binary is // throwaway, so config/data must resolve against the CWD. func isGoRunDir(dir string) bool { if tmp := os.TempDir(); tmp != "" { if rel, err := filepath.Rel(tmp, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return true } } for _, seg := range strings.Split(filepath.ToSlash(dir), "/") { if seg == "go-build" { return true } } return false } // Path returns the config file path. Resolution order: // 1. env ARTEX_CONFIG (explicit override) // 2. ./config.json in the current working directory (running from the project // dir — robust no matter where `go run` placed the temp/cached binary) // 3. config.json next to the executable (a distributed binary keeps it beside) // // The first existing file wins. If none exist, the CWD path is returned so the // "not found" message points at the project dir the user most likely expected. func Path() string { if v := strings.TrimSpace(os.Getenv("ARTEX_CONFIG")); v != "" { return v } var candidates []string if cwd, err := os.Getwd(); err == nil { candidates = append(candidates, filepath.Join(cwd, "config.json")) } candidates = append(candidates, filepath.Join(BaseDir(), "config.json")) for _, c := range candidates { if _, err := os.Stat(c); err == nil { return c } } return candidates[0] } // Load reads and parses the config file. A missing file yields a zero Config (so // callers fall back to defaults). A file that exists but cannot be read or parsed // is an error: ignoring it would run with defaults the operator did not choose. func Load() (Config, error) { var c Config path := Path() b, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return c, nil } if err != nil { return c, fmt.Errorf("read config file %s: %w", path, err) } if err := json.Unmarshal(b, &c); err != nil { return Config{}, fmt.Errorf("parse config file %s: %w", path, err) } return c, nil } // SkillDir returns the skill root directory with precedence: // // env ARTEX_SKILL_DIR > config file (skill_dir) > BaseDir()/skills // // The directory is created if it does not exist. func SkillDir() (string, error) { var d string if v := strings.TrimSpace(os.Getenv("ARTEX_SKILL_DIR")); v != "" { d = v } else { c, err := Load() if err != nil { return "", err } if v := strings.TrimSpace(c.SkillDir); v != "" { d = v } else { d = filepath.Join(BaseDir(), "skills") } } _ = os.MkdirAll(d, 0o755) return d, nil } // PostgresDSN resolves the connection string with precedence: // // env ARTEX_PG_DSN > config file (database.dsn, or assembled from fields) // // There is NO built-in fallback: when neither source supplies a database config, // it returns an error naming the config path it inspected, so startup fails loudly // instead of silently connecting to a wrong default. source describes where the // DSN came from (for startup logging). func PostgresDSN() (dsn, source string, err error) { if v := strings.TrimSpace(os.Getenv("ARTEX_PG_DSN")); v != "" { return v, "environment variable ARTEX_PG_DSN", nil } c, err := Load() if err != nil { return "", "", err } db := c.Database if d := strings.TrimSpace(db.DSN); d != "" { return d, "config file " + Path() + " (database.dsn)", nil } if db.Host != "" || db.DBName != "" || db.User != "" { return db.buildDSN(), "config file " + Path() + " (database fields)", nil } return "", "", fmt.Errorf("no database config found: the environment variable ARTEX_PG_DSN is not set, and config file %s provides no database (dsn or host/user/dbname). Create the config file or set the environment variable and retry", Path()) } func (d Database) buildDSN() string { host := d.Host if host == "" { host = "127.0.0.1" } port := d.Port if port == 0 { port = 5432 } ssl := d.SSLMode if ssl == "" { ssl = "disable" } u := url.URL{ Scheme: "postgres", Host: host + ":" + strconv.Itoa(port), Path: "/" + d.DBName, } if d.User != "" { if d.Password != "" { u.User = url.UserPassword(d.User, d.Password) } else { u.User = url.User(d.User) } } u.RawQuery = url.Values{"sslmode": {ssl}}.Encode() return u.String() } // String is a redacted view of the resolved DSN (password masked) for logging. func Redact(dsn string) string { u, err := url.Parse(dsn) if err != nil { return dsn } if u.User != nil { if _, hasPw := u.User.Password(); hasPw { u.User = url.UserPassword(u.User.Username(), "****") } } return fmt.Sprintf("%s", u.String()) }