First Commit
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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
// 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())
}
+105
View File
@@ -0,0 +1,105 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// hasHan reports whether s contains a CJK Han ideograph (the Chinese source text
// we are replacing). Hangul and ASCII identifiers must survive; Han must not.
func hasHan(s string) bool {
for _, r := range s {
if r >= 0x4e00 && r <= 0x9fff {
return true
}
}
return false
}
func hasHangul(s string) bool {
for _, r := range s {
if r >= 0xac00 && r <= 0xd7a3 {
return true
}
}
return false
}
func assertKorean(t *testing.T, label, s string) {
t.Helper()
if hasHan(s) {
t.Errorf("%s: Chinese Han ideograph remains: %q", label, s)
}
if !hasHangul(s) {
t.Errorf("%s: no Hangul found (expected Korean): %q", label, s)
}
}
// TestPostgresDSNErrorLocalized pins the install-path startup message to Korean.
// When neither ARTEX_PG_DSN nor a config file supplies a database, PostgresDSN
// returns an error that propagates verbatim (db.DSN → server.NewManager → the
// `log.Fatalf("open stores: %v", err)` in cmd/artex/main.go). An operator who
// boots with missing config sees it first, so it must read as Korean.
func TestPostgresDSNErrorLocalized(t *testing.T) {
dir := t.TempDir()
t.Setenv("ARTEX_PG_DSN", "")
t.Setenv("ARTEX_CONFIG", filepath.Join(dir, "nope.json"))
_, _, err := PostgresDSN()
if err == nil {
t.Fatal("missing config should error")
}
assertKorean(t, "startup error", err.Error())
// The identifiers an operator must act on stay verbatim (not translated).
for _, want := range []string{"ARTEX_PG_DSN", "database", "dsn", "host/user/dbname"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("startup error should keep identifier %q verbatim: %q", want, err.Error())
}
}
// What the operator actually sees after propagation (no %w wrapping anywhere
// on the path), recorded so the message can be eyeballed in context.
t.Logf("open stores: %v", err)
}
// TestPostgresDSNSourceLocalized pins the three source labels (logged at
// server/manager.go:361) to Korean. They live in the same function as the
// startup error, so they are localized together to avoid a mixed-language file.
func TestPostgresDSNSourceLocalized(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
// env source
t.Setenv("ARTEX_CONFIG", filepath.Join(dir, "nope.json"))
t.Setenv("ARTEX_PG_DSN", "postgres://envwins/x")
if _, source, err := PostgresDSN(); err != nil {
t.Fatalf("env source: %v", err)
} else {
assertKorean(t, "env source", source)
}
// config file dsn source
t.Setenv("ARTEX_PG_DSN", "")
if err := os.WriteFile(cfgPath, []byte(`{"database":{"dsn":"postgres://full/dsn"}}`), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ARTEX_CONFIG", cfgPath)
if _, source, err := PostgresDSN(); err != nil {
t.Fatalf("dsn source: %v", err)
} else {
assertKorean(t, "dsn source", source)
}
// config file fields source
if err := os.WriteFile(cfgPath, []byte(`{"database":{"host":"10.1.2.3","dbname":"d","user":"u"}}`), 0o644); err != nil {
t.Fatal(err)
}
if _, source, err := PostgresDSN(); err != nil {
t.Fatalf("fields source: %v", err)
} else {
assertKorean(t, "fields source", source)
}
}
+42
View File
@@ -0,0 +1,42 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestPostgresDSNPrecedence(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
os.WriteFile(cfgPath, []byte(`{"database":{"host":"10.1.2.3","port":6000,"user":"u","password":"p","dbname":"d","sslmode":"require"}}`), 0o644)
t.Setenv("ARTEX_CONFIG", cfgPath)
// no env DSN → built from config file fields
t.Setenv("ARTEX_PG_DSN", "")
got, _, err := PostgresDSN()
want := "postgres://u:p@10.1.2.3:6000/d?sslmode=require"
if err != nil || got != want {
t.Fatalf("from file: got %q err %v want %q", got, err, want)
}
// env wins over config file
t.Setenv("ARTEX_PG_DSN", "postgres://envwins/x")
if got, _, err := PostgresDSN(); err != nil || got != "postgres://envwins/x" {
t.Fatalf("env should win, got %q err %v", got, err)
}
// no env, no file → error (no built-in default)
t.Setenv("ARTEX_PG_DSN", "")
t.Setenv("ARTEX_CONFIG", filepath.Join(dir, "nope.json"))
if got, _, err := PostgresDSN(); err == nil {
t.Fatalf("missing config should error, got %q", got)
}
// full dsn in config file is used verbatim
os.WriteFile(cfgPath, []byte(`{"database":{"dsn":"postgres://full/dsn"}}`), 0o644)
t.Setenv("ARTEX_CONFIG", cfgPath)
if got, _, err := PostgresDSN(); err != nil || got != "postgres://full/dsn" {
t.Fatalf("file dsn verbatim, got %q err %v", got, err)
}
}