refactor: implement configurable directory structure with XDG support

Separate configuration from data storage and make paths configurable
via environment variables and command-line flags. This improves
Unix/Linux compliance and supports both development and production
deployments.

Key changes:
- Separate config dir (opal.yml) from data dir (database, logs)
- Support XDG Base Directory specification
- Add --config-dir and --data-dir flags
- Environment variables: OPAL_CONFIG_DIR, OPAL_DATA_DIR, OPAL_DB_PATH
- Smart fallback: /etc/opal, /var/lib/opal -> ~/.config/opal, ~/.local/share/opal
- Server mode validates required OAuth/JWT environment variables
- Update naming from 'jade' to 'opal' throughout
- Update systemd service name to 'opal.service'
- Add migration guide in README

Default paths:
- Config: /etc/opal (fallback: ~/.config/opal)
- Data: /var/lib/opal (fallback: ~/.local/share/opal)

Files modified:
- internal/engine/config.go: New directory resolution logic
- internal/engine/database.go: Auto-create data directory
- cmd/root.go: Add global flags for directory overrides
- cmd/server.go: Add configuration validation
- cmd/sync.go, internal/sync/*: Use new path helper functions
- tests: Update to use directory overrides
- docs: Update deployment guide and README
This commit is contained in:
2026-01-06 20:46:29 +01:00
parent 7ea78d3b54
commit 5d01c9f564
12 changed files with 333 additions and 54 deletions
+150 -3
View File
@@ -45,22 +45,151 @@ type Config struct {
var globalConfig *Config
// Global variables for flag/programmatic overrides
var (
configDirOverride string
dataDirOverride string
)
// SetConfigDirOverride sets the config directory override (typically from --config-dir flag)
func SetConfigDirOverride(dir string) {
configDirOverride = dir
}
// SetDataDirOverride sets the data directory override (typically from --data-dir flag)
func SetDataDirOverride(dir string) {
dataDirOverride = dir
}
// GetConfigDir returns the configuration directory path
// Resolution priority:
// 1. Flag override (via SetConfigDirOverride)
// 2. OPAL_CONFIG_DIR environment variable
// 3. XDG_CONFIG_HOME/opal
// 4. /etc/opal (with fallback to ~/.config/opal if not writable)
func GetConfigDir() (string, error) {
// Priority 1: Flag override
if configDirOverride != "" {
return configDirOverride, nil
}
// Priority 2: Environment variable
if dir := os.Getenv("OPAL_CONFIG_DIR"); dir != "" {
return dir, nil
}
// Priority 3: XDG_CONFIG_HOME
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
return filepath.Join(xdgConfig, "opal"), nil
}
// Priority 4: Try /etc/opal, fallback to ~/.config/opal if not writable
etcOpal := "/etc/opal"
if isWritable(etcOpal) {
return etcOpal, nil
}
// Fallback to user config directory
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(home, ".config", "jade"), nil
return filepath.Join(home, ".config", "opal"), nil
}
// GetDataDir returns the data directory path
// Resolution priority:
// 1. Flag override (via SetDataDirOverride)
// 2. OPAL_DATA_DIR environment variable
// 3. XDG_DATA_HOME/opal
// 4. /var/lib/opal (with fallback to ~/.local/share/opal if not writable)
func GetDataDir() (string, error) {
// Priority 1: Flag override
if dataDirOverride != "" {
return dataDirOverride, nil
}
// Priority 2: Environment variable
if dir := os.Getenv("OPAL_DATA_DIR"); dir != "" {
return dir, nil
}
// Priority 3: XDG_DATA_HOME
if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
return filepath.Join(xdgData, "opal"), nil
}
// Priority 4: Try /var/lib/opal, fallback to ~/.local/share/opal if not writable
varLibOpal := "/var/lib/opal"
if isWritable(varLibOpal) {
return varLibOpal, nil
}
// Fallback to user data directory
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(home, ".local", "share", "opal"), nil
}
// isWritable checks if a directory exists and is writable, or if parent exists and is writable
func isWritable(path string) bool {
// Check if path exists
info, err := os.Stat(path)
if err == nil {
// Path exists, check if it's a directory and writable
if !info.IsDir() {
return false
}
// Test write permission by trying to create a temp file
testFile := filepath.Join(path, ".write-test")
f, err := os.Create(testFile)
if err != nil {
return false
}
f.Close()
os.Remove(testFile)
return true
}
// Path doesn't exist, check if parent is writable
parent := filepath.Dir(path)
parentInfo, err := os.Stat(parent)
if err != nil {
return false
}
if !parentInfo.IsDir() {
return false
}
// Test if we can create in parent
testFile := filepath.Join(parent, ".write-test")
f, err := os.Create(testFile)
if err != nil {
return false
}
f.Close()
os.Remove(testFile)
return true
}
// GetDBPath returns the path to the SQLite database
// Resolution priority:
// 1. OPAL_DB_PATH environment variable
// 2. {data-dir}/opal.db
func GetDBPath() (string, error) {
configDir, err := GetConfigDir()
// Priority 1: OPAL_DB_PATH override
if dbPath := os.Getenv("OPAL_DB_PATH"); dbPath != "" {
return dbPath, nil
}
// Priority 2: Data directory + opal.db
dataDir, err := GetDataDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "opal.db"), nil
return filepath.Join(dataDir, "opal.db"), nil
}
// GetConfigPath returns the path to the config file
@@ -72,6 +201,24 @@ func GetConfigPath() (string, error) {
return filepath.Join(configDir, "opal.yml"), nil
}
// GetSyncQueuePath returns the path to the sync queue file
func GetSyncQueuePath() (string, error) {
dataDir, err := GetDataDir()
if err != nil {
return "", err
}
return filepath.Join(dataDir, "sync_queue.json"), nil
}
// GetSyncConflictLogPath returns the path to the sync conflict log
func GetSyncConflictLogPath() (string, error) {
dataDir, err := GetDataDir()
if err != nil {
return "", err
}
return filepath.Join(dataDir, "sync_conflicts.log"), nil
}
// LoadConfig loads the configuration from file or creates default
func LoadConfig() (*Config, error) {
if globalConfig != nil {