Files
artex/config/config.go
T
dela 97668dab7e
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
Localize user-facing UI, agent output, and backend messages to Simplified Chinese
2026-10-09 14:08:31 +08:00

193 lines
5.9 KiB
Go

// 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"
"fmt"
"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/unreadable file yields a zero
// Config (so callers fall back to defaults) rather than an error.
func Load() Config {
var c Config
b, err := os.ReadFile(Path())
if err != nil {
return c
}
_ = json.Unmarshal(b, &c)
return c
}
// 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 {
var d string
if v := strings.TrimSpace(os.Getenv("ARTEX_SKILL_DIR")); v != "" {
d = v
} else if v := strings.TrimSpace(Load().SkillDir); v != "" {
d = v
} else {
d = filepath.Join(BaseDir(), "skills")
}
_ = os.MkdirAll(d, 0o755)
return d
}
// 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, "环境变量 ARTEX_PG_DSN", nil
}
db := Load().Database
if d := strings.TrimSpace(db.DSN); d != "" {
return d, "配置文件 " + Path() + " (database.dsn)", nil
}
if db.Host != "" || db.DBName != "" || db.User != "" {
return db.buildDSN(), "配置文件 " + Path() + " (database 字段)", nil
}
return "", "", fmt.Errorf("找不到数据库配置: 环境变量 ARTEX_PG_DSN 未设置,配置文件 %s 中也没有 database (dsn 或 host/user/dbname) 配置。请创建配置文件或设置环境变量后重试", 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())
}