fix: make LoadConfig read-only to prevent panic on read-only filesystems

LoadConfig() tried to create directories and write opal.yml as a side
effect of loading config. On the server (where /etc/opal is in systemd
ReadOnlyPaths), this failed, returning nil. All internal GetConfig()
callers discarded the error, passing nil to BuildUrgencyCoefficients()
which panicked on nil dereference.

Redesign the config system with layered, read-only loading:
- Defaults (always present) → YAML file (if exists) → OPAL_ env vars
- LoadConfig never writes to the filesystem or returns nil
- File creation moved to explicit InitConfig() for CLI first-run/setup
- SaveConfig uses yaml.Marshal instead of manual field-by-field Viper
  calls, eliminating the three-place maintenance burden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 00:04:54 +01:00
parent b3c30738bd
commit c5a963bfd9
3 changed files with 161 additions and 184 deletions
+10 -6
View File
@@ -244,16 +244,20 @@ func initializeApp() {
os.Exit(1)
}
// Load config
// On first run, create the config file with defaults
if isFirstRun {
if err := engine.InitConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config: %v\n", err)
os.Exit(1)
}
showFirstRunMessage()
}
// Load config (reads file if present, otherwise uses defaults)
if _, err := engine.LoadConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
// Show first-run message after config is created
if isFirstRun {
showFirstRunMessage()
}
}
func showFirstRunMessage() {
+11 -57
View File
@@ -121,33 +121,6 @@ func runQuickSetup() {
os.Exit(1)
}
// Create config
cfg := engine.Config{
DefaultFilter: "status:pending",
DefaultSort: "due,priority",
DefaultReport: "list",
ColorOutput: true,
WeekStartDay: "monday",
DefaultDueTime: "",
NextLimit: 5,
SyncEnabled: false,
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
}
// Create directories
if err := os.MkdirAll(configDir, 0755); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to create config directory: %v", err))
@@ -158,8 +131,8 @@ func runQuickSetup() {
os.Exit(1)
}
// Save config
if err := engine.SaveConfig(&cfg); err != nil {
// Save default config
if err := engine.SaveConfig(engine.DefaultConfig()); err != nil {
wizard.PrintError(fmt.Sprintf("Failed to save config: %v", err))
os.Exit(1)
}
@@ -304,34 +277,15 @@ func runInteractiveSetup() {
return
}
// Create configuration
cfg := &engine.Config{
DefaultFilter: defaultFilter,
DefaultSort: "due,priority",
DefaultReport: reportNames[defaultReport],
ColorOutput: colorOutput,
WeekStartDay: weekStartDay,
DefaultDueTime: "",
NextLimit: 5,
SyncEnabled: syncEnabled,
SyncURL: syncURL,
SyncAPIKey: syncAPIKey,
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
}
// Create configuration from defaults, then apply user choices
cfg := engine.DefaultConfig()
cfg.DefaultFilter = defaultFilter
cfg.DefaultReport = reportNames[defaultReport]
cfg.ColorOutput = colorOutput
cfg.WeekStartDay = weekStartDay
cfg.SyncEnabled = syncEnabled
cfg.SyncURL = syncURL
cfg.SyncAPIKey = syncAPIKey
// Set directory overrides
engine.SetConfigDirOverride(configDir)