diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5d5981b..e462df1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,6 +15,7 @@ graph TD main --> loader main --> logx main --> version + main --> configdir model[internal/model] --> loader[internal/loader] model --> version[internal/version] model --> updater[internal/updater] @@ -25,27 +26,32 @@ graph TD version --> loader version --> logx[internal/logx] version --> proc + version --> configdir updater --> loader updater --> proc ui --> loader loader --> logx + loader --> configdir + logx --> configdir[internal/configdir] ``` | Package | Responsibility | |---|---| +| `internal/configdir` | Resolve the base user-config dir: `~/.config/keeptui` on macOS/Linux (`$XDG_CONFIG_HOME` else `~/.config`), `%AppData%\keeptui` on Windows. Pure `baseFor(goos, …)` core + `Base()` wrapper; stdlib-only bottom leaf shared by `loader`/`version`/`logx`/`main` | | `internal/launcher` | Decide how to run a tracked tool in a new terminal tab: pure `planFor(env, command, toolName)` → `Plan{Argv, Fallback, Terminal}`, detection chain tmux → iTerm2 → Terminal.app → kitty → WezTerm → fallback; env-only, no subprocesses | | `internal/loader` | Tracker persistence (`meta.yaml`), status lifecycle (`active → trying → inactive`, legacy values migrated on read), the one-tag-per-tool invariant (a legacy multi-tag list is truncated to its first entry on read), GitHub ref parsing (`NormalizeRepo`, `ParseToolRef`) | -| `internal/logx` | Session error journal: dependency-free, errors only, one lazily created file per session. Package-level state — any package can log without threading a logger through | +| `internal/logx` | Session error journal: errors only, one lazily created file per session, imports only the stdlib-only `configdir` leaf. Package-level state — any package can log without threading a logger through | | `internal/model` | The entire Bubble Tea model: TUI state, key handling, rendering | | `internal/proc` | `DetachTTY` — run probes without a controlling terminal; `KillGroup` — process-group SIGKILL (plain `Kill` on Windows) | | `internal/ui` | Lip Gloss styles, `PlaceOverlay`, `StripANSI` | | `internal/updater` | Detect the package manager that owns an installed binary and produce an update `Plan{Manager, Argv, Display}` | | `internal/version` | Detect the installed version locally; GitHub API with a 24-hour cache; semver comparison (`IsNewer`) | -`launcher`, `logx`, `proc`, `ui`, `updater` and `version` sit at the bottom of the import graph: +`configdir`, `launcher`, `logx`, `proc`, `ui`, `updater` and `version` sit at the bottom of the import graph: they know nothing about the TUI (`ui`, `updater` and `version` reach only into -`loader`/`proc`/`logx`). GitHub ref parsing is owned by `loader` (otherwise a -`version ↔ loader` cycle would appear). +`loader`/`proc`/`logx`). `configdir` is the lowest leaf (stdlib only), shared by +`loader`/`version`/`logx`/`main` for one config-dir resolution. GitHub ref parsing +is owned by `loader` (otherwise a `version ↔ loader` cycle would appear). The `model` package is split across files within a single package: @@ -233,6 +239,8 @@ caller left it empty). Token: `GITHUB_TOKEN` from the environment always wins ov ## Storage +The base dir comes from `configdir.Base()`: `~/.config/keeptui` on macOS and Linux (`$XDG_CONFIG_HOME` else `~/.config`), `%AppData%\keeptui` on Windows. Paths below use the macOS/Linux form. + | Data | Path | |---|---| | Tracker metadata | `~/.config/keeptui/meta.yaml` | diff --git a/CLAUDE.md b/CLAUDE.md index 37c688e..a73cc67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,9 +27,10 @@ CI (`.github/workflows/ci.yml`) runs build / vet / `test -race` / golangci-lint | Package | Purpose | |---|---| +| `internal/configdir` | Resolve keeptui's base user-config dir (parent of the `keeptui/` subdir holding `meta.yaml`, `cache.json`, `token`, `logs/`). Pure `baseFor(goos, getenv, userConfigDir, userHomeDir)` core + thin `Base()` wrapper (the `shellCommand`/`planFor` idiom). **Windows** → `os.UserConfigDir()` (`%AppData%`); **macOS and Linux** → `$XDG_CONFIG_HOME` else `~/.config` — deliberately *not* `os.UserConfigDir()`, which is `~/Library/Application Support` on macOS. The **bottom leaf of the import graph** (stdlib only), so `loader`/`version`/`logx`/`main` all share one resolution without a cycle | | `internal/launcher` | Decide how to run a tracked tool in a new terminal tab: pure `planFor(env, command, toolName)` over an injected env lookup → `Plan{Argv, Fallback, Terminal}`, thin `Detect` wrapper over `os.Getenv`. Detection chain (first hit wins): `$TMUX` → iTerm2 → Terminal.app → kitty → WezTerm → `Fallback: true` (no scripting API — run in the current window). `$TMUX` is deliberately first: inside tmux `TERM_PROGRAM` names the *outer* terminal and a tmux window is the correct "tab" there. Terminal.app opens a **window**, not a tab (tabs aren't scriptable without System Events — honest degradation). tmux/kitty/wezterm plans carry command and tool name as argv elements (no escaping; the tmux plan puts `--` before the command so a `-`-leading edit isn't eaten as a flag); the two osascript plans interpolate into script source through `appleScriptQuote` (backslashes, then double quotes, then `\n`/`\r`/`\t` — the control characters an AppleScript literal cannot carry raw), the single escaping point. The kitty plan needs a remote-control socket (`listen_on` → `KITTY_LISTEN_ON`): the adapter runs detached, so tty-transport remote control cannot work — without the socket it degrades to the auto-fallback. Env-only — no subprocesses — so `Detect` is safe inside `Update()`. Bottom of the import graph like `updater`: no TUI knowledge | | `internal/loader` | Persist tracker metadata (`meta.yaml`: name, status, tag, note, github ref, optional `update_cmd` override); own the tool-status lifecycle (`active → trying → inactive` via `NextStatus`; legacy `forgotten`/`archived` values are migrated to `inactive` in `LoadMeta` — in-memory, the file keeps the old value until the next `SaveMeta`); hold the **one tag per tool** invariant (`Tags` stays a `[]string` so the yaml schema is unchanged, but `LoadMeta` truncates a legacy multi-tag list to `Tags[:1]` in the same migration loop — first tag wins, matching what the editor's `parseTag` does to comma-separated input. Unlike the status migration, which swaps a retired value for its successor, this one **discards user-authored data** and the next `SaveMeta` — any note edit or status cycle — makes it permanent, so a load that actually dropped tags stashes the pre-migration file as `meta.yaml.bak` first: best-effort, logged and swallowed on failure, and not rewritten by later already-migrated loads); parse GitHub refs (`NormalizeRepo`, `ParseToolRef` in `github.go`) | -| `internal/logx` | Dependency-free, errors-only session logger; one lazily-created plain-text file per session under `~/.config/keeptui/logs`. Package-level state (`mu`/`file`/`path`/`header`), so any package can log without threading a logger through constructors | +| `internal/logx` | Errors-only session logger (imports only the stdlib-only `configdir` leaf); one lazily-created plain-text file per session under `/keeptui/logs`. Package-level state (`mu`/`file`/`path`/`header`), so any package can log without threading a logger through constructors | | `internal/model` | Entire Bubble Tea model — all TUI state, key handling, and rendering | | `internal/proc` | `DetachTTY` — runs tool probe subprocesses without a controlling terminal (`Setsid` on unix, `DETACHED_PROCESS` on Windows); `KillGroup` — process-group SIGKILL (negative pid; plain `Process.Kill` on Windows) for the update streamer's timeout path | | `internal/ui` | Lip Gloss styles and `PlaceOverlay` helper | @@ -105,6 +106,8 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu ### File storage +The base dir is resolved by `configdir.Base()`: `~/.config/keeptui` on **macOS and Linux** (`$XDG_CONFIG_HOME` else `~/.config` — same path on both, deliberately not macOS's `~/Library/Application Support`), `%AppData%\keeptui` on **Windows**. Paths below use the macOS/Linux form. + | Data | Location | |---|---| | Tracker metadata | `~/.config/keeptui/meta.yaml` | @@ -127,7 +130,7 @@ The internal `testConfigDir`/`testCacheDir`/`testTokenDir`/`testAPIBase`/`testBr ### Session error log (`internal/logx`) -An errors-only journal so bugs can be researched after the fact instead of reconstructed from memory. One plain-text file per session, created **lazily on the first write** — a session with no errors leaves no file at all, so the presence of a file is itself the signal. The timestamp is colon-free (Windows filenames) and zero-padded, so lexicographic order equals chronological order, which is what `Cleanup()` (keep newest 20) relies on. The header (`keeptui / tools= token=`) is assembled in `main.go` via `logx.SetHeader` and written as the file's first line — it stays in `main` because pulling version/tool-count/token-source into `logx` would invert the import graph (`logx` imports nothing from the project, keeping it at the bottom of the graph). +An errors-only journal so bugs can be researched after the fact instead of reconstructed from memory. One plain-text file per session, created **lazily on the first write** — a session with no errors leaves no file at all, so the presence of a file is itself the signal. The timestamp is colon-free (Windows filenames) and zero-padded, so lexicographic order equals chronological order, which is what `Cleanup()` (keep newest 20) relies on. The header (`keeptui / tools= token=`) is assembled in `main.go` via `logx.SetHeader` and written as the file's first line — it stays in `main` because pulling version/tool-count/token-source into `logx` would invert the import graph (`logx`'s only project import is the stdlib-only `configdir` leaf, keeping it near the bottom of the graph). **Why our `recover` sits deeper than Bubble Tea's:** `recover()` consumes a panic wherever it fires first, and Bubble Tea's own recover in each `tea.Cmd` goroutine prints the trace *after* `p.cancel()` (async) but *before* the terminal is restored — so the trace lands in the alt-screen buffer and vanishes on exit. `logx.Recover(context)` is therefore deferred *inside* `Update`, `View` and every command (via the `safeCmd` wrapper; `execToolCmd` is the one unwrapped cmd — `tea.ExecProcess` only constructs the exec message, nothing there can panic), records the value plus `debug.Stack()` (which still contains the real panic site from inside the defer), then **re-panics** so Bubble Tea still catches it, restores the terminal and returns `ErrProgramPanic`. `tea.WithoutCatchPanics` is deliberately **not** used — terminal restoration is Bubble Tea's job and it does it correctly. Errors-only by design: there is no debug level, no `KEYS_DEBUG`, no env reading, no JSON, no size-based rotation — without a debug level the file appears only when something went wrong. The logger never breaks the app: its own failures (file won't open, disk full) are swallowed silently. diff --git a/README.md b/README.md index 3335d51..99cdc99 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,9 @@ atomic. | Session error log | `~/.config/keeptui/logs/keeptui-.log` | | Copy of the tracker before the one-tag migration | `~/.config/keeptui/meta.yaml.bak` | +The paths above are the macOS and Linux locations (`$XDG_CONFIG_HOME` if set, otherwise +`~/.config`). On Windows the base directory is `%AppData%\keeptui\` instead. + The log is created lazily — only on the first error. A session with no errors leaves no file at all, so the presence of a file is itself the signal. The 20 most recent logs are kept. diff --git a/internal/configdir/configdir.go b/internal/configdir/configdir.go new file mode 100644 index 0000000..645e7e9 --- /dev/null +++ b/internal/configdir/configdir.go @@ -0,0 +1,41 @@ +// Package configdir resolves keeptui's base user-config directory — the parent +// of the keeptui/ subdir that holds meta.yaml, cache.json, the token and logs. +// It is the bottom leaf of the import graph (stdlib only), so every package that +// owns one of those files can share the resolution without an import cycle. +// +// The resolution deliberately differs from os.UserConfigDir(): on macOS that +// returns ~/Library/Application Support, but keeptui keeps macOS and Linux on +// the same ~/.config/keeptui path (honoring $XDG_CONFIG_HOME). Windows still +// follows os.UserConfigDir() (%AppData%), where there is no ~/.config convention. +package configdir + +import ( + "os" + "path/filepath" + "runtime" +) + +// Base returns keeptui's base user-config directory. Callers append the +// "keeptui" subdir (and the file) themselves, matching what os.UserConfigDir() +// callers did before. See baseFor for the per-GOOS rules. +func Base() (string, error) { + return baseFor(runtime.GOOS, os.Getenv, os.UserConfigDir, os.UserHomeDir) +} + +// baseFor is the pure core behind Base, parameterized on GOOS and the env/dir +// lookups so both branches are table-testable (the shellCommand/planFor idiom). +// Windows → os.UserConfigDir() (%AppData%); macOS and Linux → $XDG_CONFIG_HOME +// if set, else ~/.config. +func baseFor(goos string, getenv func(string) string, userConfigDir, userHomeDir func() (string, error)) (string, error) { + if goos == "windows" { + return userConfigDir() + } + if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" { + return xdg, nil + } + home, err := userHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config"), nil +} diff --git a/internal/configdir/configdir_test.go b/internal/configdir/configdir_test.go new file mode 100644 index 0000000..69759ee --- /dev/null +++ b/internal/configdir/configdir_test.go @@ -0,0 +1,75 @@ +package configdir + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestBaseFor(t *testing.T) { + const ( + home = "/home/user" + appData = `C:\Users\user\AppData\Roaming` + xdgDir = "/custom/xdg" + ) + userConfig := func() (string, error) { return appData, nil } + homeDir := func() (string, error) { return home, nil } + noEnv := func(string) string { return "" } + xdgEnv := func(k string) string { + if k == "XDG_CONFIG_HOME" { + return xdgDir + } + return "" + } + + tests := []struct { + name string + goos string + getenv func(string) string + want string + }{ + {"windows uses UserConfigDir (%AppData%)", "windows", noEnv, appData}, + {"windows ignores XDG", "windows", xdgEnv, appData}, + {"linux without XDG uses ~/.config", "linux", noEnv, filepath.Join(home, ".config")}, + {"linux honors XDG_CONFIG_HOME", "linux", xdgEnv, xdgDir}, + {"darwin without XDG uses ~/.config (not Application Support)", "darwin", noEnv, filepath.Join(home, ".config")}, + {"darwin honors XDG_CONFIG_HOME", "darwin", xdgEnv, xdgDir}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := baseFor(tt.goos, tt.getenv, userConfig, homeDir) + if err != nil { + t.Fatalf("baseFor returned error: %v", err) + } + if got != tt.want { + t.Errorf("baseFor(%q) = %q, want %q", tt.goos, got, tt.want) + } + }) + } +} + +// TestBaseForHomeError: on the unix path with no XDG, a UserHomeDir failure +// propagates instead of returning a bogus ".config" relative path. +func TestBaseForHomeError(t *testing.T) { + boom := errors.New("no home") + failHome := func() (string, error) { return "", boom } + okConfig := func() (string, error) { return "/whatever", nil } + noEnv := func(string) string { return "" } + + if _, err := baseFor("linux", noEnv, okConfig, failHome); !errors.Is(err, boom) { + t.Errorf("err = %v, want the home-dir error propagated", err) + } +} + +// TestBaseForWindowsConfigError: the Windows branch surfaces a UserConfigDir +// error rather than falling back to ~/.config. +func TestBaseForWindowsConfigError(t *testing.T) { + boom := errors.New("no appdata") + failConfig := func() (string, error) { return "", boom } + okHome := func() (string, error) { return `C:\Users\user`, nil } + noEnv := func(string) string { return "" } + + if _, err := baseFor("windows", noEnv, failConfig, okHome); !errors.Is(err, boom) { + t.Errorf("err = %v, want the UserConfigDir error propagated", err) + } +} diff --git a/internal/loader/meta.go b/internal/loader/meta.go index 4a24c8a..d97c942 100644 --- a/internal/loader/meta.go +++ b/internal/loader/meta.go @@ -7,6 +7,7 @@ import ( "gopkg.in/yaml.v3" + "github.com/stanlyzoolo/keeptui/internal/configdir" "github.com/stanlyzoolo/keeptui/internal/logx" ) @@ -76,7 +77,7 @@ func MetaPath() string { if testConfigDir != "" { return filepath.Join(testConfigDir, "keeptui", "meta.yaml") } - configDir, err := os.UserConfigDir() + configDir, err := configdir.Base() if err != nil { return "" } diff --git a/internal/logx/logx.go b/internal/logx/logx.go index 2aa5ad3..587ae9b 100644 --- a/internal/logx/logx.go +++ b/internal/logx/logx.go @@ -16,6 +16,8 @@ import ( "strings" "sync" "time" + + "github.com/stanlyzoolo/keeptui/internal/configdir" ) var ( @@ -27,13 +29,13 @@ var ( dirOverride string // test seam; empty in production ) -// logDir resolves ~/.config/keeptui/logs via os.UserConfigDir, honoring the test -// override. Mirrors loader.MetaPath's resolution. Caller holds mu. +// logDir resolves /keeptui/logs, honoring the test override. +// Mirrors loader.MetaPath's resolution. Caller holds mu. func logDir() string { if dirOverride != "" { return dirOverride } - base, err := os.UserConfigDir() + base, err := configdir.Base() if err != nil { return "" } diff --git a/internal/version/github.go b/internal/version/github.go index a80ce15..374a3df 100644 --- a/internal/version/github.go +++ b/internal/version/github.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/stanlyzoolo/keeptui/internal/configdir" "github.com/stanlyzoolo/keeptui/internal/loader" "github.com/stanlyzoolo/keeptui/internal/logx" ) @@ -737,7 +738,7 @@ func cacheFilePath() (string, error) { if testCacheDir != "" { return filepath.Join(testCacheDir, "cache.json"), nil } - base, err := os.UserConfigDir() + base, err := configdir.Base() if err != nil { return "", err } diff --git a/internal/version/token.go b/internal/version/token.go index ea6bddd..479a48c 100644 --- a/internal/version/token.go +++ b/internal/version/token.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "sync" + + "github.com/stanlyzoolo/keeptui/internal/configdir" ) // testTokenDir overrides the token file directory in tests. @@ -22,7 +24,7 @@ func tokenFilePath() (string, error) { if testTokenDir != "" { return filepath.Join(testTokenDir, "token"), nil } - base, err := os.UserConfigDir() + base, err := configdir.Base() if err != nil { return "", err } diff --git a/main.go b/main.go index 018633e..a97e910 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "runtime/debug" tea "github.com/charmbracelet/bubbletea" + "github.com/stanlyzoolo/keeptui/internal/configdir" "github.com/stanlyzoolo/keeptui/internal/loader" "github.com/stanlyzoolo/keeptui/internal/logx" "github.com/stanlyzoolo/keeptui/internal/model" @@ -83,16 +84,23 @@ func resolveVersion(ldflag, modVersion string) string { } // migrateConfigDir renames the pre-rename config directory (/keys, -// from when the app was called "keys") to /keeptui. One-shot and -// conservative: if the new directory already exists — even empty — nothing is -// touched, so a half-adopted new install is never overwritten by old data. +// from when the app was called "keys") to the current keeptui config dir. The +// old "keys" data was written under os.UserConfigDir(); the new dir resolves via +// configdir.Base() (~/.config/keeptui on macOS/Linux, %AppData%\keeptui on +// Windows), i.e. wherever the app now reads. One-shot and conservative: if the +// new directory already exists — even empty — nothing is touched, so a +// half-adopted new install is never overwritten by old data. func migrateConfigDir() { base, err := os.UserConfigDir() if err != nil { return } + newBase, err := configdir.Base() + if err != nil { + return + } oldDir := filepath.Join(base, "keys") - newDir := filepath.Join(base, "keeptui") + newDir := filepath.Join(newBase, "keeptui") if _, err := os.Stat(newDir); err == nil { return }