From dfa36311af46e70b55901a40670935ec180adbb4 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 18 Jul 2026 19:35:13 +0200 Subject: [PATCH 1/3] feat(storage): default database and cache to OS per-user directories When no path is configured and no legacy ./data (or ./.cache) directory exists, the SQLite database and local model cache now land in the platform-conventional locations (XDG dirs on Linux, ~/Library on macOS, %LocalAppData% on Windows) via the new internal/platformdir package. Existing deployments and the Docker image keep their current paths: the presence of ./data or ./.cache pins the historical defaults, so upgrades never move anyone's state. Startup now logs the resolved database path. This makes binary installs (install.sh, Homebrew) work from any working directory without scattering state. Co-Authored-By: Claude Fable 5 --- .env.template | 7 ++- CLAUDE.md | 2 +- config/config.go | 2 +- config/config_test.go | 6 ++- config/storage.go | 6 ++- internal/app/app.go | 6 ++- internal/platformdir/platformdir.go | 65 +++++++++++++++++++++++ internal/platformdir/platformdir_test.go | 66 ++++++++++++++++++++++++ internal/providers/init.go | 18 ++++++- internal/storage/default_path_test.go | 34 ++++++++++++ internal/storage/sqlite.go | 2 +- internal/storage/storage.go | 27 ++++++++-- 12 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 internal/platformdir/platformdir.go create mode 100644 internal/platformdir/platformdir_test.go create mode 100644 internal/storage/default_path_test.go diff --git a/.env.template b/.env.template index f00784cd..91a3daa3 100644 --- a/.env.template +++ b/.env.template @@ -148,6 +148,8 @@ # SEMANTIC_CACHE_WEAVIATE_API_KEY= # Optional: Custom cache directory for local file cache +# Default: ./.cache when it already exists, otherwise the OS per-user cache +# directory (~/.cache/gomodel, ~/Library/Caches/gomodel, %LocalAppData%\gomodel\cache) # GOMODEL_CACHE_DIR=.cache # External model metadata registry (provides pricing, capabilities, context window, etc.) @@ -251,7 +253,10 @@ # STORAGE_TYPE=sqlite # SQLite Configuration (default, good for single instance) -# SQLITE_PATH=data/gomodel.db +# Default: ./data/gomodel.db when a ./data directory exists, otherwise the +# OS per-user data directory (~/.local/share/gomodel, ~/Library/Application +# Support/gomodel, %LocalAppData%\gomodel) +# GOMODEL_SQLITE_PATH=data/gomodel.db # PostgreSQL Configuration (for multi-instance deployments) # POSTGRES_URL=postgres://user:password@localhost:5432/gomodel diff --git a/CLAUDE.md b/CLAUDE.md index 4e35afdd..98530eb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ Full reference: `.env.template` and `config/config.yaml` - `ALLOW_PASSTHROUGH_V1_ALIAS` (true: Allow /p/{provider}/v1/... aliases while keeping /p/{provider}/... canonical) - `ENABLED_PASSTHROUGH_PROVIDERS` (openai,anthropic,openrouter,zai,vllm: Comma-separated list of enabled passthrough providers) - `REALTIME_ENABLED` (true: Expose the realtime speech-to-speech websocket at `/v1/realtime` and the `/p/{provider}/v1/realtime` upgrade. The canonical `/v1/realtime` route needs only `REALTIME_ENABLED`; the `/p/{provider}/v1/realtime` upgrade additionally requires passthrough routes enabled (`ENABLE_PASSTHROUGH_ROUTES`) with the provider listed in `ENABLED_PASSTHROUGH_PROVIDERS`. The gateway is a transparent websocket reverse proxy — it injects provider credentials and relays the provider's realtime event schema verbatim (no translation), so clients connect without provider API keys. Only providers implementing realtime accept sessions. Currently: OpenAI and xAI/Grok Voice Agent (both `wss://…/v1/realtime`); Z.ai/Zhipu GLM-Realtime (`wss://…/api/paas/v4/realtime`); Bailian/Qwen-Omni (`wss://dashscope…/api-ws/v1/realtime`); and Azure OpenAI (`wss:///openai/realtime?api-version=…&deployment=…`, `api-key` header). All use OpenAI's realtime event schema (Z.ai adds extensions that relay transparently). Provider-specific notes: xAI voice models (e.g. `grok-voice-latest`) aren't in upstream `/models` discovery, so configure them via `XAI_MODELS`, and xAI bills realtime per-minute (no token usage reported); Azure realtime requires a realtime-capable `AZURE_API_VERSION` (the default may be too old) and the model selects the Azure deployment. (MiniMax was evaluated but skipped — its conversational realtime schema is not OpenAI-compatible.) Sessions are gated by the same model-access and budget rules as other model endpoints; usage is tracked per `response.done` event, accepting both the OpenAI singular and Alibaba plural token-detail spellings. The same flag also exposes the OpenAI-compatible WebRTC surface (via the optional `core.RealtimeCallProvider` interface — OpenAI and xAI at the shared `…/v1/realtime/{calls,client_secrets}` shape, and Azure OpenAI at its GA `/openai/v1/realtime/{calls,client_secrets}` surface with `api-key` auth and no api-version; xAI gates WebRTC calls per team, so unauthorized accounts get the upstream 403 relayed while client_secrets works. Bailian is deliberately not wired: its WebRTC is allowlist-only with a per-customer endpoint provided by sales, plus no call id in the answer; Z.ai has no WebRTC realtime): `POST /v1/realtime/calls` exchanges SDP (raw `application/sdp` offer with `?model=`, or multipart `sdp` + `session` JSON fields; the session/query model is rewritten to the resolved provider model so aliases and virtual models work) and relays the answer with a gateway-relative `Location: /v1/realtime/calls/{call_id}` header; `POST /v1/realtime/client_secrets` mints ephemeral browser credentials routed by `session.model` (falling back to the nested transcription model); and `GET /v1/realtime?call_id=…` attaches to an existing call as a sideband websocket (an in-memory per-instance call registry recalls the route for calls created through the same instance — 6h TTL, capped; otherwise pass explicit `model`+`provider` params). WebRTC media and events flow directly between client and provider, so after creating a call the gateway attaches its own best-effort sideband observer websocket to record usage per `response.done` (entries carry endpoint `/v1/realtime/calls`; skipped when usage tracking is off, and gateway-relayed sideband attaches for registry-known calls don't tap usage to avoid double counting). WebRTC signaling counts toward request-scoped rate limits, but concurrent-scope rules can't span a WebRTC call's lifetime since only signaling transits the gateway; ephemeral client secrets authenticate clients directly against the provider, so those sessions bypass the gateway entirely and are untracked.) -- **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (data/gomodel.db), `POSTGRES_URL`, `MONGODB_URL`. `/v1/responses` snapshots and `/v1/conversations` history persist to the configured backend (30-day TTL, hourly sweep); the in-memory fallback stores are byte-capped and used only by embedded setups that skip app wiring. +- **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (default: `data/gomodel.db` when a `./data` directory exists — existing deployments, Docker; otherwise the OS per-user data dir, e.g. `~/.local/share/gomodel/gomodel.db` — see `internal/platformdir`; the local model cache resolves `.cache` vs the OS cache dir the same way), `POSTGRES_URL`, `MONGODB_URL`. `/v1/responses` snapshots and `/v1/conversations` history persist to the configured backend (30-day TTL, hourly sweep); the in-memory fallback stores are byte-capped and used only by embedded setups that skip app wiring. - **Models:** `MODELS_ENABLED_BY_DEFAULT` (true), `KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT` (false), `CONFIGURED_PROVIDER_MODELS_MODE` (`fallback` or `allowlist`, default `fallback`; `allowlist` skips upstream `/models` for providers with configured lists); persisted overrides restrict/allow selectors with `user_paths`. When alias-only models listing is enabled, `GET /v1/models` returns only model aliases, not full concrete model specs, to operators. - **Virtual models:** Redirects (aliases / load balancers) and access policies are managed in the admin dashboard and persisted to the `virtual_models` store. A redirect with one target is a plain alias; a redirect with several targets is load balanced by `strategy`: `round_robin` (default; rotates across targets, honoring per-target `weight`) or `cost` (always routes to the cheapest catalog-priced available target, falling back to the first target when none are priced). Unavailable targets are skipped, so a redirect works while any target is live. Virtual models can also be declared as infrastructure-as-code under `virtual_models:` in `config.yaml` or via the `VIRTUAL_MODELS` env var (a JSON array; env merges over YAML, winning per `source`). Declarative entries are validated at startup, override admin-store rows with the same `source`, and are read-only in the dashboard. Startup validation is catalog-independent: structure plus explicit target `provider` names — a name matching no configured provider (a typo) aborts startup listing the registered providers; a name declared under `providers:` but unregistered (e.g. credentials unset in this environment) only warns and the target stays unavailable; target *model* availability is never a startup gate (checked at resolve time, since the catalog loads asynchronously). - **MCP gateway:** `MCP_ENABLED` (true: expose the MCP-protocol endpoints; a no-op until servers are declared). GoModel aggregates upstream MCP (Model Context Protocol) servers behind the authenticated streamable-HTTP endpoint `/mcp` (POST JSON-RPC, GET notification stream, DELETE session end) and per-server endpoints `/mcp/{server}`. On `/mcp`, tools and prompts are namespaced `{server}_{name}` with deterministic ordering; `tools/call` accepts the namespaced name (longest server-prefix match) or a unique bare name; `/mcp/{server}` exposes original names. Tools, prompts, resources, and resource templates relay with raw schemas/results verbatim; upstream `instructions` are merged into the gateway's `initialize` result. Servers come from three sources with the usual precedence: `mcp.servers:` map in `config.yaml`, the `MCP_SERVERS` env var (JSON object merged over YAML per name), and the `mcp_servers` admin store (dashboard MCP Servers page / `/admin/mcp-servers` GET/PUT/DELETE + `POST .../{name}/reconnect` + `GET .../{name}/catalog` for the per-server tools/prompts/resources inspector); declarative entries are validated at startup, shadow same-name store rows, and are read-only in the dashboard (secret header values are redacted as `***` in admin reads, and a `***` value on PUT preserves the stored secret). Per-server fields: `url` + `transport` (`http` streamable default, `sse` legacy), or declarative-only `stdio` (`command`/`args`/`env` — rejected via admin API/dashboard because runtime-registered subprocesses would be an RCE vector), `headers` (upstream credentials, `${ENV}` supported; the gateway is a credential boundary — client bearer tokens are never forwarded upstream), `allowed_tools`/`disallowed_tools`, `user_paths` (visibility subtree scoping like virtual models — filtered out of `tools/list`, not just blocked at call time), `tool_timeout` (30s default). The `X-MCP-Servers` request header narrows a session to a comma-separated server subset. One upstream session is shared per server (lazy dial, redial-once on death); a failed listing marks the server `degraded` keeping its last catalog (stale carry-forward, 60s re-probe, 5m re-list, `list_changed` notifications trigger resync). Downstream sessions are SDK-managed (`Mcp-Session-Id`, 30m idle timeout), bound to the initializing user path (a different principal presenting the session ID gets 404), and each session sees a visibility-filtered tool snapshot taken at initialize. Every MCP POST is gated by user-path rate limits and budgets; every `tools/call` writes a usage entry (`provider="mcp"`, `provider_name`=server, `model`=namespaced tool, duration/sizes/error in raw data, labels/user_path as usual) and MCP paths are audit-logged model interactions whose entries are labelled with the JSON-RPC method (tool/prompt name for calls) and `provider="mcp"`, so request-log and live-log rows are self-describing; with `LOGGING_LOG_BODIES` the JSON-RPC request and response frames (SSE replies decoded) are captured on POST entries too. Server→client MCP features (sampling, elicitation, roots) and resource subscriptions are not negotiated in v1. Spec: `docs/dev/2026-07-07_mcp-gateway-spec.md`. diff --git a/config/config.go b/config/config.go index 9e6b0c2f..8819cb90 100644 --- a/config/config.go +++ b/config/config.go @@ -93,7 +93,7 @@ func buildDefaultConfig() *Config { Storage: StorageConfig{ Type: "sqlite", SQLite: SQLiteStorageConfig{ - Path: storage.DefaultSQLitePath, + Path: storage.DefaultSQLitePath(), }, PostgreSQL: PostgreSQLStorageConfig{ MaxConns: 10, diff --git a/config/config_test.go b/config/config_test.go index 51d2102a..49ea3528 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -9,6 +9,8 @@ import ( "gopkg.in/yaml.v3" "time" + + "github.com/enterpilot/gomodel/internal/storage" ) // clearProviderEnvVars unsets all known provider-related environment variables. @@ -136,8 +138,8 @@ func TestBuildDefaultConfig(t *testing.T) { if cfg.Storage.Type != "sqlite" { t.Errorf("expected Storage.Type=sqlite, got %s", cfg.Storage.Type) } - if cfg.Storage.SQLite.Path != "data/gomodel.db" { - t.Errorf("expected Storage.SQLite.Path=data/gomodel.db, got %s", cfg.Storage.SQLite.Path) + if cfg.Storage.SQLite.Path != storage.DefaultSQLitePath() { + t.Errorf("expected Storage.SQLite.Path=%s, got %s", storage.DefaultSQLitePath(), cfg.Storage.SQLite.Path) } if cfg.Storage.PostgreSQL.MaxConns != 10 { t.Errorf("expected Storage.PostgreSQL.MaxConns=10, got %d", cfg.Storage.PostgreSQL.MaxConns) diff --git a/config/storage.go b/config/storage.go index 67e62a61..21759dad 100644 --- a/config/storage.go +++ b/config/storage.go @@ -19,7 +19,9 @@ type StorageConfig struct { // SQLiteStorageConfig holds SQLite-specific storage configuration type SQLiteStorageConfig struct { - // Path is the database file path (default: data/gomodel.db) + // Path is the database file path. Default: ./data/gomodel.db when a + // ./data directory exists, otherwise the OS per-user data directory + // (e.g. ~/.local/share/gomodel/gomodel.db). Path string `yaml:"path" env:"SQLITE_PATH"` } @@ -60,7 +62,7 @@ func (c StorageConfig) BackendConfig() storage.Config { cfg.Type = storage.TypeSQLite } if cfg.SQLite.Path == "" { - cfg.SQLite.Path = storage.DefaultSQLitePath + cfg.SQLite.Path = storage.DefaultSQLitePath() } return cfg } diff --git a/internal/app/app.go b/internal/app/app.go index f622f680..b35df687 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1048,7 +1048,11 @@ func (a *App) logStartupInfo() { } // Storage configuration (shared by audit logging and usage tracking) - slog.Info("storage configured", "type", cfg.Storage.Type) + if backend := cfg.Storage.BackendConfig(); backend.Type == storage.TypeSQLite { + slog.Info("storage configured", "type", backend.Type, "path", backend.SQLite.Path) + } else { + slog.Info("storage configured", "type", backend.Type) + } // Audit logging configuration if cfg.Logging.Enabled { diff --git a/internal/platformdir/platformdir.go b/internal/platformdir/platformdir.go new file mode 100644 index 00000000..a2a6c390 --- /dev/null +++ b/internal/platformdir/platformdir.go @@ -0,0 +1,65 @@ +// Package platformdir resolves the OS-conventional per-user directories for +// GoModel's durable data and caches, used when no explicit path is +// configured. Binary installs (install.sh / Homebrew / install.ps1) run from +// arbitrary working directories, so CWD-relative defaults would scatter +// state; these follow each platform's convention instead. +package platformdir + +import ( + "os" + "path/filepath" + "runtime" +) + +const app = "gomodel" + +// DataDir returns the directory for durable application data such as the +// SQLite database: +// +// Linux $XDG_DATA_HOME/gomodel (default ~/.local/share/gomodel) +// macOS ~/Library/Application Support/gomodel +// Windows %LocalAppData%\gomodel +func DataDir() (string, error) { + switch runtime.GOOS { + case "windows": + base, err := os.UserCacheDir() // %LocalAppData% + if err != nil { + return "", err + } + return filepath.Join(base, app), nil + case "darwin": + base, err := os.UserConfigDir() // ~/Library/Application Support + if err != nil { + return "", err + } + return filepath.Join(base, app), nil + default: + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, app), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share", app), nil + } +} + +// CacheDir returns the directory for re-creatable caches such as the model +// catalog: +// +// Linux $XDG_CACHE_HOME/gomodel (default ~/.cache/gomodel) +// macOS ~/Library/Caches/gomodel +// Windows %LocalAppData%\gomodel\cache +func CacheDir() (string, error) { + base, err := os.UserCacheDir() + if err != nil { + return "", err + } + if runtime.GOOS == "windows" { + // UserCacheDir is %LocalAppData%, shared with DataDir: keep the + // cache in a subdirectory so the two stay distinguishable. + return filepath.Join(base, app, "cache"), nil + } + return filepath.Join(base, app), nil +} diff --git a/internal/platformdir/platformdir_test.go b/internal/platformdir/platformdir_test.go new file mode 100644 index 00000000..bd4ab471 --- /dev/null +++ b/internal/platformdir/platformdir_test.go @@ -0,0 +1,66 @@ +package platformdir + +import ( + "path/filepath" + "runtime" + "testing" +) + +func TestDataDir(t *testing.T) { + dir, err := DataDir() + if err != nil { + t.Fatalf("DataDir() error: %v", err) + } + if !filepath.IsAbs(dir) { + t.Errorf("DataDir() = %q, want an absolute path", dir) + } + if filepath.Base(dir) != app { + t.Errorf("DataDir() = %q, want a %q leaf directory", dir, app) + } +} + +func TestDataDirHonorsXDGDataHome(t *testing.T) { + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + t.Skip("XDG_DATA_HOME only applies to the default (Linux/Unix) branch") + } + t.Setenv("XDG_DATA_HOME", "/custom/data") + + dir, err := DataDir() + if err != nil { + t.Fatalf("DataDir() error: %v", err) + } + if want := filepath.Join("/custom/data", app); dir != want { + t.Errorf("DataDir() = %q, want %q", dir, want) + } +} + +func TestCacheDir(t *testing.T) { + dir, err := CacheDir() + if err != nil { + t.Fatalf("CacheDir() error: %v", err) + } + if !filepath.IsAbs(dir) { + t.Errorf("CacheDir() = %q, want an absolute path", dir) + } + want := app + if runtime.GOOS == "windows" { + want = "cache" + } + if filepath.Base(dir) != want { + t.Errorf("CacheDir() = %q, want a %q leaf directory", dir, want) + } +} + +func TestDataAndCacheDirsDiffer(t *testing.T) { + dataDir, err := DataDir() + if err != nil { + t.Fatalf("DataDir() error: %v", err) + } + cacheDir, err := CacheDir() + if err != nil { + t.Fatalf("CacheDir() error: %v", err) + } + if dataDir == cacheDir { + t.Errorf("DataDir() and CacheDir() are both %q; they must differ", dataDir) + } +} diff --git a/internal/providers/init.go b/internal/providers/init.go index 0e80d0ad..b44f17f9 100644 --- a/internal/providers/init.go +++ b/internal/providers/init.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os" "path/filepath" "sort" "sync" @@ -14,6 +15,7 @@ import ( "github.com/enterpilot/gomodel/internal/cache/modelcache" "github.com/enterpilot/gomodel/internal/core" "github.com/enterpilot/gomodel/internal/modeldata" + "github.com/enterpilot/gomodel/internal/platformdir" ) // InitResult holds the initialized provider infrastructure and cleanup functions. @@ -204,7 +206,7 @@ func initCache(cfg *config.Config) (modelcache.Cache, error) { if m.Local != nil { cacheDir := m.Local.CacheDir if cacheDir == "" { - cacheDir = ".cache" + cacheDir = defaultModelCacheDir() } cacheFile := filepath.Join(cacheDir, "models.json") slog.Info("using local file cache", "path", cacheFile) @@ -213,6 +215,20 @@ func initCache(cfg *config.Config) (modelcache.Cache, error) { return nil, fmt.Errorf("cache.model: must have either local or redis configured") } +// defaultModelCacheDir keeps the historical ./.cache location whenever it +// already exists (existing deployments, the Docker image); fresh binary +// installs get the OS-conventional per-user cache directory instead. +func defaultModelCacheDir() string { + if _, err := os.Stat(".cache"); err == nil { + return ".cache" + } + dir, err := platformdir.CacheDir() + if err != nil { + return ".cache" + } + return dir +} + // initializeProviders instantiates and registers all resolved providers. // Returns the count of successfully registered providers. func initializeProviders(ctx context.Context, providerMap map[string]ProviderConfig, factory *ProviderFactory, registry *ModelRegistry) (int, error) { diff --git a/internal/storage/default_path_test.go b/internal/storage/default_path_test.go new file mode 100644 index 00000000..18da85de --- /dev/null +++ b/internal/storage/default_path_test.go @@ -0,0 +1,34 @@ +package storage + +import ( + "os" + "path/filepath" + "testing" + + "github.com/enterpilot/gomodel/internal/platformdir" +) + +func TestDefaultSQLitePathUsesLegacyWhenDataDirExists(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "data"), 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + + if got := DefaultSQLitePath(); got != LegacySQLitePath { + t.Errorf("DefaultSQLitePath() = %q, want legacy %q when ./data exists", got, LegacySQLitePath) + } +} + +func TestDefaultSQLitePathUsesPlatformDirOtherwise(t *testing.T) { + t.Chdir(t.TempDir()) + + dataDir, err := platformdir.DataDir() + if err != nil { + t.Fatalf("platformdir.DataDir() error: %v", err) + } + want := filepath.Join(dataDir, "gomodel.db") + if got := DefaultSQLitePath(); got != want { + t.Errorf("DefaultSQLitePath() = %q, want %q without ./data", got, want) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index ac44c587..4df33254 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -19,7 +19,7 @@ type sqliteStorage struct { // It enables WAL mode for better concurrent read/write performance. func NewSQLite(cfg SQLiteConfig) (SQLiteStorage, error) { if cfg.Path == "" { - cfg.Path = DefaultSQLitePath + cfg.Path = DefaultSQLitePath() } // Ensure directory exists diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 43ab1931..783864d3 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -7,9 +7,13 @@ import ( "context" "database/sql" "fmt" + "os" + "path/filepath" "github.com/jackc/pgx/v5/pgxpool" "go.mongodb.org/mongo-driver/v2/mongo" + + "github.com/enterpilot/gomodel/internal/platformdir" ) // Type constants for storage backends @@ -19,8 +23,25 @@ const ( TypeMongoDB = "mongodb" ) -// DefaultSQLitePath is the default file path for the SQLite database. -const DefaultSQLitePath = "data/gomodel.db" +// LegacySQLitePath is the historical default database location, relative to +// the working directory. It stays the default whenever a ./data directory +// exists (existing deployments, the Docker image), so upgrades never move +// anyone's database. +const LegacySQLitePath = "data/gomodel.db" + +// DefaultSQLitePath returns the database path used when none is configured: +// LegacySQLitePath when a ./data directory already exists, otherwise the +// OS-conventional per-user data directory (see platformdir.DataDir). +func DefaultSQLitePath() string { + if _, err := os.Stat("data"); err == nil { + return LegacySQLitePath + } + dir, err := platformdir.DataDir() + if err != nil { + return LegacySQLitePath + } + return filepath.Join(dir, "gomodel.db") +} // Config holds storage configuration type Config struct { @@ -39,7 +60,7 @@ type Config struct { // SQLiteConfig holds SQLite-specific configuration type SQLiteConfig struct { - // Path is the database file path (default: data/gomodel.db) + // Path is the database file path (default: DefaultSQLitePath()) Path string } From 94e6afa11e48bd14c055014f72c1bc018ef64726 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sun, 19 Jul 2026 16:32:45 +0200 Subject: [PATCH 2/3] fix(storage): address review findings Legacy-path detection now requires ./data and ./.cache to be directories, not just to exist. .env.template documents SQLITE_PATH under its currently-honored spelling (the GOMODEL_ prefix arrives with the env-prefix migration). Co-Authored-By: Claude Fable 5 --- .env.template | 2 +- internal/providers/init.go | 2 +- internal/storage/default_path_test.go | 12 ++++++++++++ internal/storage/storage.go | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.env.template b/.env.template index 91a3daa3..95e898ad 100644 --- a/.env.template +++ b/.env.template @@ -256,7 +256,7 @@ # Default: ./data/gomodel.db when a ./data directory exists, otherwise the # OS per-user data directory (~/.local/share/gomodel, ~/Library/Application # Support/gomodel, %LocalAppData%\gomodel) -# GOMODEL_SQLITE_PATH=data/gomodel.db +# SQLITE_PATH=data/gomodel.db # PostgreSQL Configuration (for multi-instance deployments) # POSTGRES_URL=postgres://user:password@localhost:5432/gomodel diff --git a/internal/providers/init.go b/internal/providers/init.go index b44f17f9..8d31a9c1 100644 --- a/internal/providers/init.go +++ b/internal/providers/init.go @@ -219,7 +219,7 @@ func initCache(cfg *config.Config) (modelcache.Cache, error) { // already exists (existing deployments, the Docker image); fresh binary // installs get the OS-conventional per-user cache directory instead. func defaultModelCacheDir() string { - if _, err := os.Stat(".cache"); err == nil { + if info, err := os.Stat(".cache"); err == nil && info.IsDir() { return ".cache" } dir, err := platformdir.CacheDir() diff --git a/internal/storage/default_path_test.go b/internal/storage/default_path_test.go index 18da85de..5a924e4e 100644 --- a/internal/storage/default_path_test.go +++ b/internal/storage/default_path_test.go @@ -20,6 +20,18 @@ func TestDefaultSQLitePathUsesLegacyWhenDataDirExists(t *testing.T) { } } +func TestDefaultSQLitePathIgnoresDataFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "data"), []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + + if got := DefaultSQLitePath(); got == LegacySQLitePath { + t.Errorf("DefaultSQLitePath() returned the legacy path although ./data is a regular file") + } +} + func TestDefaultSQLitePathUsesPlatformDirOtherwise(t *testing.T) { t.Chdir(t.TempDir()) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 783864d3..b23c9378 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -33,7 +33,7 @@ const LegacySQLitePath = "data/gomodel.db" // LegacySQLitePath when a ./data directory already exists, otherwise the // OS-conventional per-user data directory (see platformdir.DataDir). func DefaultSQLitePath() string { - if _, err := os.Stat("data"); err == nil { + if info, err := os.Stat("data"); err == nil && info.IsDir() { return LegacySQLitePath } dir, err := platformdir.DataDir() From 5462e920534a38d1a38ac1ed8bba80c47eec963c Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sun, 19 Jul 2026 16:44:54 +0200 Subject: [PATCH 3/3] test(storage): make DefaultSQLitePath tests table-driven Co-Authored-By: Claude Fable 5 --- internal/storage/default_path_test.go | 73 ++++++++++++++++----------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/internal/storage/default_path_test.go b/internal/storage/default_path_test.go index 5a924e4e..f0e501ac 100644 --- a/internal/storage/default_path_test.go +++ b/internal/storage/default_path_test.go @@ -8,39 +8,52 @@ import ( "github.com/enterpilot/gomodel/internal/platformdir" ) -func TestDefaultSQLitePathUsesLegacyWhenDataDirExists(t *testing.T) { - dir := t.TempDir() - if err := os.Mkdir(filepath.Join(dir, "data"), 0o755); err != nil { - t.Fatal(err) - } - t.Chdir(dir) - - if got := DefaultSQLitePath(); got != LegacySQLitePath { - t.Errorf("DefaultSQLitePath() = %q, want legacy %q when ./data exists", got, LegacySQLitePath) - } -} - -func TestDefaultSQLitePathIgnoresDataFile(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "data"), []byte("not a directory"), 0o644); err != nil { - t.Fatal(err) +func TestDefaultSQLitePath(t *testing.T) { + platformDataDir, err := platformdir.DataDir() + if err != nil { + t.Fatalf("platformdir.DataDir() error: %v", err) } - t.Chdir(dir) - - if got := DefaultSQLitePath(); got == LegacySQLitePath { - t.Errorf("DefaultSQLitePath() returned the legacy path although ./data is a regular file") + platformPath := filepath.Join(platformDataDir, "gomodel.db") + + tests := []struct { + name string + setup func(t *testing.T, dir string) + want string + }{ + { + name: "data directory exists keeps legacy path", + setup: func(t *testing.T, dir string) { + if err := os.Mkdir(filepath.Join(dir, "data"), 0o755); err != nil { + t.Fatal(err) + } + }, + want: LegacySQLitePath, + }, + { + name: "regular file named data falls through to platform path", + setup: func(t *testing.T, dir string) { + if err := os.WriteFile(filepath.Join(dir, "data"), []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + }, + want: platformPath, + }, + { + name: "no data entry uses platform path", + setup: func(t *testing.T, dir string) {}, + want: platformPath, + }, } -} -func TestDefaultSQLitePathUsesPlatformDirOtherwise(t *testing.T) { - t.Chdir(t.TempDir()) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + t.Chdir(dir) - dataDir, err := platformdir.DataDir() - if err != nil { - t.Fatalf("platformdir.DataDir() error: %v", err) - } - want := filepath.Join(dataDir, "gomodel.db") - if got := DefaultSQLitePath(); got != want { - t.Errorf("DefaultSQLitePath() = %q, want %q without ./data", got, want) + if got := DefaultSQLitePath(); got != tt.want { + t.Errorf("DefaultSQLitePath() = %q, want %q", got, tt.want) + } + }) } }