diff --git a/.gitignore b/.gitignore index 6267ff7..1e07077 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,22 @@ +# Compiled binary (built from source; never commit build artifacts) +/tailtui + +# Test/coverage output +*.test +*.out +coverage.* + +# VHS demo renders are committed under assets/ deliberately; ignore stray ones +*.gif.tmp + +# Editor / OS noise +.DS_Store +*.swp +*~ +.idea/ +.vscode/ + +# Design sources: keep only the two specs the repo documents _designs/* !_designs/00_STYLE_GUIDE.md !_designs/00_WIREFRAME_SPEC.md diff --git a/CLAUDE.md b/CLAUDE.md index 8483349..0ee5b71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,6 +166,8 @@ Before changing any interaction, consult the keybinding matrix and overlay specs - **Phase 26.1 — VHS tape syntax correction & README hero-image swap** — docs-only follow-up to Phase 26. **(1) Tape correction**: the `Set Env TAILTUI_MOCK 1` directive added to `demo.tape` in Phase 26 is **not valid VHS syntax** and was removed; the env var must travel via the invoking shell instead (**`TAILTUI_MOCK=1 vhs demo.tape`**). The tape's header comment was updated to document the correct invocation and explicitly note that `Set Env` is intentionally **not** used. Going forward, do **not** reintroduce `Set Env TAILTUI_MOCK 1` to the tape. **(2) README hero image**: the static ASCII terminal mockup block that lived directly under the introductory paragraph was replaced with the live demo image tag `![tailTUI Live Demo](assets/demo.gif)`, pointing at the GIF produced by `vhs demo.tape`. Generating `assets/demo.gif` is a separate manual step (run VHS locally with the mock env var); the README now expects that file to exist when the repository is published. No Go / test changes — markdown + tape comment only. - **Phase 26.2 — Privacy clean-up: pseudonym-only attribution** — anonymized every personal-name reference across the repo ahead of publication. Three text touches: **LICENSE** copyright holder line now reads `Copyright (c) 2026 Phundahl`; **README.md**'s Acknowledgments paragraph and License footer both attribute the project to `Phundahl`; **CLAUDE.md**'s Phase 16 log entry parenthetical was updated to match. After the sweep, a case-insensitive repo-wide grep for the prior real-name fragments returns only the GitHub-username import path `github.com/Phundahl/tailtui` (the public handle, which is supposed to stay). **Standing rule going forward — strict pseudonym enforcement**: the only attribution form anywhere in this codebase is `Phundahl`. No first / full / real names in source-file headers, code comments, docstrings, markdown docs, the phase log, or commit messages — including when *describing* a fix that anonymized one (paraphrase the change instead of quoting the old value). No Go / test / functional changes this phase — text-only. - **Phase 27 — Omarchy 4 ("Quattro") theme support: dual location, dual schema & light mode** — the theme loader had gone silently dead on upgraded machines. Omarchy 4 changed **both** halves of the contract: the current-theme symlink moved `~/.config/omarchy/current/theme/` → `~/.local/state/omarchy/current/theme/`, and `colors.toml` swapped its flat terminal palette (`color0`–`color15`) for **semantic slots** (`mode`, `selection`, `muted`, `lighter_background`, `dark_background`, `red`/`yellow`/`orange`/`green`, …). `LoadTheme` found nothing, fell back to Matrix Core without a word, and the UI stopped tracking the desktop. **(1) Path probing**: `themeCandidates()` returns the v4 location then the legacy one (or a lone `TAILTUI_THEME` override); `LoadTheme` walks them and takes the first that yields a usable palette, and `ThemePath()` now reports the file actually read rather than a hardcoded guess. **(2) Schema detection**: `omarchyV4` and `omarchyLegacy` wire structs each expose `hasMarkers()`, checking only the keys **exclusive** to that generation — the three shared keys (`accent`/`foreground`/`background`) are deliberately excluded so they can't misclassify a file; detection is by content, not filename, so an override of either vintage maps correctly. A file with only the shared three still applies them and defaults the rest. **(3) Light mode**: new `Theme.Mode` field (`ModeDark`/`ModeLight`) read from the v4 `mode` key, used to flip which slot becomes `Surface` — `lighter_background` when dark, `dark_background` when light — because the elevated surface has to move away from the canvas in whichever direction the canvas sits. Light themes (`catppuccin-latte`, `flexoki-light`, `white`) previously rendered as an inverted mess. **(4) Warning slot**: `yellow`, falling back to `orange` for the themes that omit it. **Tests** (`internal/styles/theme_test.go`, the package's first): verbatim fixtures from real shipped themes cover v4 dark, v4 light (asserting `Surface` takes `dark_background` and *not* `lighter_background`), the legacy schema, orange fallback, partial/ambiguous files, malformed/empty/missing files, and the path-priority + override rules; `TestInstalledOmarchyThemesMapCompletely` sweeps every `colors.toml` installed on the machine (22 themes) and fails if **any** palette slot is empty or silently equal to its Matrix Core default — the tripwire for the next upstream schema change. It skips cleanly where Omarchy isn't installed. No TUI / layout / keybinding changes; `styles.Apply` and every consumer are untouched. +- **Phase 28 — CI workflow & release-config repair** — `.github/workflows/release.yml` was the **only** workflow and it fires solely on `v*` tags, so nothing verified a commit until a release was already being cut, and a broken `.goreleaser.yaml` could only surface as a failed release job *after* the tag existed. Added `.github/workflows/ci.yml`: runs on pushes to `main`, on every pull request, and via `workflow_dispatch`; `concurrency` cancels superseded in-flight runs; `contents` permission is read-only. Two jobs — **`test`** (a `gofmt -l` gate, a `go mod tidy` drift gate, `go build ./...`, `go vet ./...`, and **`go test -race ./...`**; the View layer is TTY-free so the suite runs headless, and `-race` covers the async `tea.Cmd` status/ping polling paths) and **`goreleaser-config`** (`goreleaser check`, so config errors fail on a PR instead of on a tag). Both resolve Go via `go-version-file: go.mod` rather than `stable`, so CI tracks the pinned toolchain instead of drifting on release day. **The new check immediately caught three real defects** in `.goreleaser.yaml`, every one of which would have failed the release job only after a tag was pushed: `nfpms.files` → **`nfpms.contents`** (`files` is a v1 key and is rejected outright by the v2 schema — the hard failure); `archives.format` → **`archives.formats`** (deprecated singular); and `snapshot.name_template` → **`snapshot.version_template`** (deprecated — note `goreleaser check` exits non-zero on deprecations, not just schema errors). Also dropped `-extldflags "{{.Env.LDFLAGS}}"` from the build ldflags — it required an `LDFLAGS` env var to be set at release time and is a no-op under `CGO_ENABLED=0` (no external linker to pass it to) — and translated the one non-English comment. **Test hermeticity is a requirement for CI to stay green**: every styles test uses `t.Setenv` for `HOME`/`TAILTUI_THEME`, and `TestInstalledOmarchyThemesMapCompletely` (which globs the machine's real Omarchy theme dir) **skips** when none is installed. Don't add a test that assumes Omarchy, a TTY, or a live `tailscale` daemon without an equivalent skip guard. +- **Phase 29 — Release v1.2.0 & version plumbing** — cut the release covering everything since the (never-published) `v1.1.0` tag: the 24.x sudo account flows, Phase 26 mock mode + VHS demo, the 26.2 privacy pass, Phase 27's Omarchy 4 theme support, and Phase 28's CI. **Version plumbing fix**: `.goreleaser.yaml` stamps `-X main.version={{.Version}}`, but `main.go` had **no `version` var** and `appVersion` was a `const` in `internal/tui/view.go` — so the ldflag silently did nothing (Go ignores `-X` on a missing symbol) and a tagged build would have shipped whatever was hardcoded. Now `main.go` declares `var version string` (empty for `go build`/`go run`) and calls **`tui.SetVersion(version)`** first thing in `main`; `appVersion` became a **var** (dev-build literal `v1.2.0`) and `SetVersion` ignores an empty value and normalizes a bare `1.2.0` → `v1.2.0`, since goreleaser's `{{.Version}}` drops the prefix. `appVersion` remains the single definition — set it there, never duplicate it. Added `internal/tui/version_test.go` (normalization table incl. prerelease + empty, plus a render test asserting the stamped version actually reaches the footer while staying flush). `.gitignore` was rewritten to cover the compiled `/tailtui` binary (previously **committed**, ~6.5 MB, removed from history in the same cycle), test/coverage output, and editor/OS noise, while preserving the existing `_designs/` allow-list. README gained a `ci` status badge under the tagline and a "What's New in v1.2.0" section above the v1.1.0 one. No layout / state / keybinding changes. - **Upcoming (next major cycle — see the README Roadmap)** — - **Tailscale Serve & Funnel management**: visual port forwarding to securely expose local services to the tailnet (`tailscale serve`) or the public internet (`tailscale funnel`), driven from keyboard overlays in the existing modal style. - **Connection diagnostics**: deep-dive into peer connection health — DERP-relay vs. direct routing and the signals to debug a flaky link (likely from `tailscale status --json` endpoints + `tailscale ping`/`netcheck`). @@ -213,6 +215,18 @@ Inside an overlay, `esc`/`q` close it (except the Routing CIDR editor — see be Keys from the design spec not yet wired: `p` (ping-as-action) and `s` (SSH-as-action — lowercase `s` is reserved and currently a no-op; Advanced Settings moved to uppercase `S`). +## Git workflow (required) + +Adopted in the v1.2.0 cycle; `main` is a published, public branch. + +- **Never commit directly to `main`.** Branch first: `feat/…`, `fix/…`, `ci/…`, `chore/…`, `docs/…`. Keep branches short-lived and single-purpose. +- **Every change lands through a pull request**, squash-merged, branch deleted. CI (`test` + `goreleaser-config`) gates the merge — see Phase 28. +- **Rebase onto the latest `main` before opening a PR**, so the PR is tested against current `main` rather than a stale base. +- **Order dependent work so the safety net lands first.** CI went in ahead of the feature PRs precisely so they were gated by it. +- **Scope a PR to files it truly needs.** When several changes touch the same doc (the phase log especially), split by replaying the edits in phase order across sequential branches rather than hunk-splitting one big diff. +- **Releases are tags on `main`** (`git tag -a vX.Y.Z && git push origin vX.Y.Z`), which is the only trigger for `release.yml`. Bump `appVersion` in `view.go` in the release PR, before tagging. +- **Commit identity is the pseudonym only** — `Phundahl <89451493+Phundahl@users.noreply.github.com>`. Never let a real-name or employer-domain address into commit metadata; this is the attribution rule above, applied to Git itself. + ## Documentation workflow (required) At the end of **every** successful phase, automatically update both `CLAUDE.md` (technical decisions, architecture state, constraints) and `README.md` (tech stack, working features, run instructions). This is a standing user requirement, not a per-request ask. diff --git a/README.md b/README.md index 23b9f58..a7c5a1b 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ # tailTUI *A brutalist, keyboard-centric terminal user interface for Tailscale.* +[![ci](https://github.com/Phundahl/tailtui/actions/workflows/ci.yml/badge.svg)](https://github.com/Phundahl/tailtui/actions/workflows/ci.yml) + `tailTUI` is a fast, dense, single-screen control panel for your tailnet. It wraps the `tailscale` CLI in a sharp, no-nonsense TUI built on the [Charmbracelet](https://charm.sh) stack — so you can see your whole network, @@ -27,6 +29,28 @@ for the opposite workflow: drop you to the shell only when *they* need to (to paste an auth URL), then restore the UI automatically. +## What's New in v1.2.0 + +- **Omarchy 4 ("Quattro") theme support.** Omarchy 4 moved the current-theme + store to `~/.local/state/omarchy/` *and* replaced the flat `color0`–`color15` + palette with semantic slots. tailTUI now probes both locations and reads both + schemas — detected by the keys present, not the filename — so upgraded and + older installs alike keep tracking the desktop theme instead of silently + falling back to the built-in palette. +- **Light theme support.** The theme's `mode` key is honored, shading panels and + modals in the right direction, so light palettes like `catppuccin-latte`, + `flexoki-light`, and `white` render as a light UI rather than an inverted one. +- **Demo / screenshot mode.** `TAILTUI_MOCK=1` runs the whole UI against an + in-memory fictional tailnet — every pane, modal, and animation, with no + daemon, no network, and no risk of touching a real configuration. +- **Account management is unprivileged-aware.** The four profile actions + (`a`/`Enter`/`d`/`l`) elevate properly for the root-owned Linux profile store, + and a session without access shows a clear "profile store locked" hint inside + the modal instead of quietly repeating errors into the log. +- **Prebuilt binaries.** Releases are built and published with goreleaser as + `.tar.gz`, `.deb`, and `.rpm` for amd64 and arm64, and the version shown in + the footer is stamped from the release tag. + ## What's New in v1.1.0 tailTUI grew from a read-only dashboard into a full configuration tool: diff --git a/internal/tui/version_test.go b/internal/tui/version_test.go new file mode 100644 index 0000000..50619cd --- /dev/null +++ b/internal/tui/version_test.go @@ -0,0 +1,52 @@ +package tui + +import ( + "strings" + "testing" +) + +// restoreVersion snapshots appVersion and puts it back after the test, so the +// package-level override can't leak into the other suites. +func restoreVersion(t *testing.T) { + t.Helper() + orig := appVersion + t.Cleanup(func() { appVersion = orig }) +} + +// goreleaser's {{.Version}} expands to a bare "1.2.0" (no v prefix), so +// SetVersion normalizes it; an unstamped build passes "" and must keep the +// dev-build literal rather than blanking the footer. +func TestSetVersion(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"bare goreleaser version", "1.2.0", "v1.2.0"}, + {"already prefixed", "v1.2.0", "v1.2.0"}, + {"prerelease", "1.3.0-rc1", "v1.3.0-rc1"}, + {"unstamped build keeps the literal", "", "v1.2.0"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + restoreVersion(t) + appVersion = "v1.2.0" // the dev-build literal + SetVersion(c.in) + if appVersion != c.want { + t.Errorf("SetVersion(%q) → %q, want %q", c.in, appVersion, c.want) + } + }) + } +} + +// The stamped version has to actually reach the footer — the whole point of +// the ldflag, which previously targeted a main.version var that didn't exist. +func TestVersionRendersInFooter(t *testing.T) { + restoreVersion(t) + SetVersion("9.9.9") + + m := newReadyModel(t, 120, 40) + view := m.View() + if !strings.Contains(view, "v9.9.9") { + t.Error("stamped version does not appear in the rendered footer") + } + assertFlush(t, view, 120, 40) +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 5588613..8d9a66d 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -11,10 +11,26 @@ import ( ) // Branding shown in the UI chrome. -const ( - appName = "tailTUI" - appVersion = "v1.1.0" -) +const appName = "tailTUI" + +// appVersion is the version string shown in the footer. It is a var, not a +// const, so a release build can override it from the git tag via SetVersion +// (see main.go / .goreleaser.yaml); the literal here is the dev-build value. +var appVersion = "v1.2.0" + +// SetVersion overrides the footer version string, for builds that stamp the +// real tag in at link time. An empty version is ignored (keeping the dev-build +// literal), and a bare "1.2.0" is normalized to "v1.2.0" since goreleaser's +// {{.Version}} drops the prefix. +func SetVersion(v string) { + if v == "" { + return + } + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + appVersion = v +} // Fixed layout constants. const ( diff --git a/main.go b/main.go index 40d254d..8b13eb9 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,14 @@ import ( "github.com/Phundahl/tailtui/internal/tui" ) +// version is stamped in at link time by goreleaser +// (-X main.version={{.Version}}). It stays empty for a plain `go build`/`go +// run`, in which case the TUI keeps its own dev-build version literal. +var version string + func main() { + tui.SetVersion(version) + // Load the theme (native Omarchy palette if present, else the built-in // "Matrix Core" default) and apply it before building any styles. styles.Apply(styles.LoadTheme())