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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
193 lines
6.0 KiB
Go
193 lines
6.0 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())
|
|
}
|