go-cli-data-dir-threading
Root cause (DF-003): configPathFor(dir) was unexported and dropped its dir argument — every config.Load()/Save() resolved to ~/.musterflow. --data-dir was parsed in main.go, stamped onto cfg.DataDir, and then silently ignored by the config package, so auth add --data-dir X wrote credentials into the real home config while X stayed empty.
Fix — four parts, all threaded through one shared dir:
1. internal/config/config.go — exported, parameterized API. configPathFor(dir) became the exported ConfigPathFor(dir); added LoadWithDataDir/SaveWithDataDir; Save() now honors cfg.DataDir; added (*Config).Dir() and DefaultDir():
// Dir resolves the effective data directory: cfg.DataDir wins, else default.
func (c *Config) Dir() string {
if c != nil && c.DataDir != "" {
return c.DataDir
}
return defaultDir()
}
// ConfigPathFor is the exported, parameterized form of configPathFor(dir).
func ConfigPathFor(dir string) string {
if dir == "" {
dir = defaultDir()
}
return filepath.Join(dir, "config.json")
}
func LoadWithDataDir(dir string) (*Config, error) { return loadPath(ConfigPathFor(dir)) }
func SaveWithDataDir(cfg *Config, dir string) error {
if cfg == nil { return errors.New("config: nil config") }
return savePath(cfg, ConfigPathFor(dir))
}
// Save persists cfg to cfg.Dir(), so a non-empty cfg.DataDir is honored
// instead of silently writing to the real home config.
func Save(cfg *Config) error { return SaveWithDataDir(cfg, cfg.Dir()) }
2. main.go — load dir-aware BEFORE setting cfg.DataDir (ordering is the contract that makes Save() write to the right place):
dataDir := flag.String("data-dir", "", "override the musterflow data directory (default: ~/.musterflow)")
flag.Parse()
// Load dir-aware first (reads the chosen dir), THEN stamp the override so
// every later Save() through the shared dir honors it.
cfg, err := config.LoadWithDataDir(*dataDir)
if err != nil { log.Fatalf("musterflow: load config: %v", err) }
if *dataDir != "" {
cfg.DataDir = *dataDir
}
3. root.go — handlers load via the shared dir. One chokepoint loadShared stamps genuine overrides back onto the loaded config (so legacy files that predate data_dir persistence still route correctly), and all handlers save through config.Save, which honors cfg.DataDir:
func loadShared(cfg *config.Config) (*config.Config, error) {
dir := cfg.Dir()
loaded, err := config.LoadWithDataDir(dir)
if err != nil { return nil, err }
if dir != config.DefaultDir() { // only stamp real overrides
loaded.DataDir = dir
}
return loaded, nil
}
func authAdd(cfg *config.Config, key, secret string) error {
loaded, err := loadShared(cfg) // loads from --data-dir, never home
if err != nil { return fmt.Errorf("auth add: load config: %w", err) }
if loaded.Auth == nil { loaded.Auth = map[string]string{} }
loaded.Auth[key] = secret
if err := config.Save(loaded); err != nil { // Save honors loaded.DataDir
return fmt.Errorf("auth add: save config: %w", err)
}
log.Printf("auth add: stored credential %q in %s", key, config.ConfigPathFor(loaded.Dir()))
return nil
}
**Foreman harness lesson applied:** repo-root `./musterflow` was never trusted — every verification ran against a **fresh tick-scoped build** (`/tmp/musterflow-df003-build/musterflow-final-<epoch>`), and all live runs used a **temp `$HOME`** so no real config was ever touched (the demo's "pollution" was itself demonstrated only inside a throwaway home).
**RED — bug reproduced first** (buggy build, temp HOME): `--data-dir $D auth add github tok_red_demo` left `$D` empty and wrote `{"auth":{"github":"tok_red_demo"}}` into `$HOME/.musterflow/config.json` — exactly the DF-003 failure.
**GREEN — after fix** (fresh build, temp HOME):
- `--data-dir A auth add` → creds in `A/config.json`; `$HOME/.musterflow` **never created**
- `--data-dir B` (second dir) → fully isolated, empty until its own `auth add`
- `auth-show` reads from the correct dir; log prints the resolved path
- No flag → writes to `~/.musterflow` under temp HOME and stays clean (no self-referential `data_dir` persisted)
**Edge cases tested:**
1. **Legacy config file without `data_dir` field** → `--data-dir` add merged into the legacy file, stamped `data_dir`, home untouched
2. **Two data dirs concurrently** → no cross-talk
3. **No flag, default path** → works; file has no `data_dir` key (refinement so the default dir never pins a stale HOME path)
4. **`HOME` unset** → `defaultDir()` falls back safely, `exit=0`, no panic
5. **`Save(nil)` / `SaveWithDataDir(nil, dir)`** → return error
6. **Round-trips** via `Save`/`SaveWithDataDir` + `LoadWithDataDir` → exact credential recovery
7. **Default-dir save does not persist self-referential `data_dir`** → asserted in test
**Test runs:** `go vet ./...` clean; `go test ./...` → **7/7 PASS** (config package: `TestConfigPathFor`, `TestLoadWithDataDirIsolation`, `TestSaveHonorsDataDir`, `TestSaveWithDataDirRoundTrip`, `TestSaveEmptyDirUsesDefault`, `TestDirResolution`, `TestNilConfigSave`), plus the live CLI e2e suite above — all green.
---{"model": "deepseek-v4-flash", "problem_class": "go-cli-data-dir-threading", "result": "passed", "tests": 7}