From da0917a0698cef07d435b538bc5ab02ec4e72fdf Mon Sep 17 00:00:00 2001 From: root Date: Fri, 31 Jul 2026 21:39:54 -0400 Subject: [PATCH] Add stew package manager detection and docs updates --- ARCHITECTURE.md | 21 ++- CLAUDE.md | 4 +- docs/design/updating.md | 4 +- internal/updater/stew.go | 177 ++++++++++++++++++++++ internal/updater/stew_test.go | 243 +++++++++++++++++++++++++++++++ internal/updater/updater.go | 51 ++++--- internal/updater/updater_test.go | 45 +++++- 7 files changed, 517 insertions(+), 28 deletions(-) create mode 100644 internal/updater/stew.go create mode 100644 internal/updater/stew_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e4ad174..6af6720 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -282,14 +282,23 @@ Key invariants: ## Updating a tool (`enter` in `[2]`) `updater.Detect` identifies the manager from the installed binary — the chain is -brew → go → cargo → pipx → uv → pnpm → bun → npm. Order matters twice: brew before -go, so a brew-installed Go binary is not misrouted to `go install`, and pnpm/bun -before npm, because both layouts contain `node_modules` segments the npm step would -otherwise claim (a bun global really did resolve to `npm install -g `, which -installs a duplicate under npm's prefix). `update_cmd` from `meta.yaml` always wins -and runs via `sh -c`. Detection spawns subprocesses, so it runs as a `tea.Cmd`, +brew → stew → go → cargo → pipx → uv → pnpm → bun → npm. Order matters twice: brew +and stew before go, so a brew/stew-installed Go binary is not misrouted to `go install`, +and pnpm/bun before npm, because both layouts contain `node_modules` segments the npm +step would otherwise claim (a bun global really did resolve to `npm install -g `, +which installs a duplicate under npm's prefix). `update_cmd` from `meta.yaml` always +wins and runs via `sh -c`. Detection spawns subprocesses, so it runs as a `tea.Cmd`, never inside `Update()`. +stew is the one manager path cannot prove alone: its binaries land in a shared bin +dir (`~/.local/bin`) that anyone installs into. `Detect` therefore reads stew's own +two files — `stew.config.json` (resolving `stewPath`/`stewBinPath` with stew's +defaults) and `Stewfile.lock.json` — and hands the pure core a `stewManaged bool`. +Both halves are load-bearing: the lock alone would claim a same-named binary +shadowing stew's on `PATH`, and the path alone would offer `stew upgrade ` for +a hand-installed file stew refuses. Only `source: "github"` entries are upgradable +(URL installs are not), and the command is `stew upgrade `. + Five steps are path-convention based (cargo, pipx, uv, pnpm, bun) and take their roots from `managerDirsFrom(getenv, home, goos)` (pure core, `resolveManagerDirs()` wrapper — the `launcher.planFor` idiom): `$UV_TOOL_DIR`, `$PNPM_HOME` and diff --git a/CLAUDE.md b/CLAUDE.md index 983372e..301a18a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ Everything else stays here. **Never re-inline these three sections.** When a cha | `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` | `Theme` (theme.go) — the app's ten semantic color roles plus the gauge's dim track — and `Styles` (styles.go), every lipgloss style keepkit renders with, built from one theme by the single `NewStyles(Theme)`. `DefaultStyles()` is the fallback for a `Model{}` literal and nothing else. `LanguageColor` (lang.go) is the one thing here that is **not** a theme role and must not follow a theme switch: linguist's per-language brand colors, keyed case-folded, `(color, false)` for a language it does not know. Plus `PlaceOverlay` (which takes the dim style rather than reading a package var) and `StripANSI` | -| `internal/updater` | Detect the package manager that owns an installed binary and produce an update `Plan{Manager, Argv, Display}` (brew → go → cargo → pipx → uv → pnpm → bun → npm chain; `update_cmd` override always wins; on a `LookPath` miss **or an exhausted chain**, a brew-by-name fallback before giving up — see [`docs/design/updating.md`](docs/design/updating.md)). Bottom of the import graph like `version`: no TUI knowledge, depends only on `loader` for `Tool`. Pure `detectFromPath`/`brewNamePlanAt`/`managerDirsFrom`/`pnpmShimTarget` cores + OS-facing `Detect`/`brewNamePlan`/`resolveManagerDirs`/`readPnpmShim` wrappers; `testHomeDir` and `testBrewPrefix` seams (the latter a deliberate duplicate of `version`'s — two bottom leaves that may not import each other) | +| `internal/updater` | Detect the package manager that owns an installed binary and produce an update `Plan{Manager, Argv, Display}` (brew → stew → go → cargo → pipx → uv → pnpm → bun → npm chain; `update_cmd` override always wins; on a `LookPath` miss **or an exhausted chain**, a brew-by-name fallback before giving up — see [`docs/design/updating.md`](docs/design/updating.md)). Bottom of the import graph like `version`: no TUI knowledge, depends only on `loader` for `Tool`. Pure `detectFromPath`/`brewNamePlanAt`/`managerDirsFrom`/`pnpmShimTarget`/`stewOwned`/`stewLockHas` cores + OS-facing `Detect`/`brewNamePlan`/`resolveManagerDirs`/`readPnpmShim`/`stewManaged` wrappers; `testHomeDir` and `testBrewPrefix` seams (the latter a deliberate duplicate of `version`'s — two bottom leaves that may not import each other) | | `internal/version` | Detect installed version locally — `InstalledVersion(t) (ver string, present bool)`, the two results independent so the card can tell "installed but won't say its version" from "not installed". Sources in order: `--version`/`-V`, then `brewDirVersion` in `brew.go` (reads the version from the `Caskroom/`/`Cellar/` directory names — no brew subprocess — so casks with no version CLI still resolve), then `cargoListVersion`/`cargoVersionFromList` (same idea one ecosystem over: `cargo install --list` names every cargo-installed crate's version without running its binary; gated on the binary existing, and `LookPath("cargo")` short-circuits before any subprocess). A fallback hit suppresses the anomaly log; `testBrewPrefix` seam. Also: fetch latest release, repo card, changelog and README from the GitHub API with a 24h cache; semver comparison (`IsNewer`) and the card's version spelling (`DisplayVersion`); keepkit's own self-check (`selfcheck.go`: `SelfRepo`, `SelfLatest`) | The `model` package is split by responsibility (one package, several files): @@ -108,7 +108,7 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu - **Panel footers**: each panel reserves the last `panelFooterRows` (2) of its content height for a **blank** spacer plus a footer line. `[1]` carried a border-colored rule there for a while and it only made the footer read as a fourth section of the list rather than as the frame's own caption. Cells are joined by a **dim** ` · ` — the same painted middot the card's language list uses, since an unpainted one renders at the terminal's default brightness, louder than the hint labels it is separating (`TestPanelFooterSeparatorIsDim`). `[1]` carries `/ filter · enter run · space group` — `enter` is a `[1]` action, not a global one (see **Status bar**), and the three are ordered most-important-first because cells drop from the right and on a narrow list "run" outranks "group"; a right cell that is absent now reserves **nothing**, gap included, which is what had been dropping `[1]`'s last cell one step earlier than the width required. `[2]` carries its own actions led by the contextual `enter update to ` when a release is pending (and deliberately **not** `e note` / `# tags`: both are already offered in the meta line beside the values they edit, and a footer repeating them spends the row on saying it twice), `[3]` the source name, the `page/pages` position, the entry-cursor hints while there is an index to walk, and `ctrl+d/u page` pinned right. Cells are dropped from the right until they fit, exactly like the status bar and for the same reason: a footer that wrapped would push the panel one row past its height, and lipgloss answers that by scrolling the top border off the alt screen. **`calcListHeight()` is the single definition** of the viewport height inside a panel (`calcVpHeight() - footerRows()`), used both by the `WindowSizeMsg` handler that sizes the viewports and by the renderers that stack viewport + footer back to the full height — two copies of that arithmetic would drift and cost a row. On a terminal too short to spare them (`calcVpHeight() < 6`) `footerRows()` is 0 and the panels are content only: two of six rows is a third of the panel, and a footer is a reminder while the content is the point. - **`panelGutter`**: the blank column a panel keeps between its frame and everything it draws — the `[1]` group headers, every footer, and in `[2]` and `[3]` **all** content, at **both** ends. Content that touches the border it lives in reads as having overflowed it, and on the right the gutter is also what keeps text off the scrollbar thumb. The two wide panels reach it from opposite directions and each through a **single definition**: `[2]` sizes itself to **`cardWidth()`** (`briefW - 1 - 2*panelGutter` — the viewport is one column narrower than the panel, `withScrollbar` keeps that one) and `buildCard` steps the finished card in with **`indentLines`** at the very end; `[3]` wraps to **`helpWrapWidth()`** (the same arithmetic) and `renderHelpContent` is a one-line wrapper applying `indentLines` over `helpContent()`, so the spotlight, the search highlight, the placeholders and the update log all land on the same point and no branch can render flush against the frame. The indent is applied **last, to whole finished lines**: it is plain spaces outside the styling, so it can never split an escape sequence, it lands in front of the metrics plate's own background segments rather than inside them, and it cannot shift a line index — which is why `buildCard`'s clickable-link map, built while writing, stays correct for free. `TestPanelsKeepTheirGutter` pins both panels on rendered output. `[1]` and `[2]` additionally open with a blank **row** for the same reason — their content started against the title spliced into the top border. In `[1]` that row is a real screen line, so it goes into the line maps as a non-selectable one and every tool below it shifts by one (which is what the maps exist for, and `syncToolsViewport` walks *up* over every non-selectable row above the selection so a header and the blank above it are revealed together). Tool rows keep the wider `toolRowIndent` (gutter + marker + blank), which puts the `⏺` in the same column as a section header's label and the names one step in from it. - **Refresh (`r` in `focusBrief`)**: `refreshSelectedCmd(t)` force-refreshes the selected tool bypassing the 24h cache TTL — the repo pass (`refreshRemoteCmd` → `version.RefreshRepoData`) + changelog (`refreshChangelogCmd` → `version.RefreshChangelog`) + README (`refreshReadmeCmd` → `version.RefreshReadme`, preceded by a `delete(m.readmeData, name)` so a session-cached 404/rate-limit negative can recover, then a `markReadmeLoading(name)` — the deletion makes `needsReadme` true again for the whole in-flight window, so without the marker leaving and re-entering the tool would spend a second request; `refreshingFor` does *not* cover it, since `remoteMsg` clears that flag as soon as the repo pass lands, which can be well before the README does) + a local installed re-detect (`fetchInstalledCmd`). It emits the same `remoteMsg`/`changelogMsg` as the startup path, so the merge/re-render logic is reused. While the repo pass is in flight `m.refreshingFor` (the tool name) turns the card title into a status line — `refreshing data ` (`bubbles/spinner`, `MiniDot`; the about is hidden) — with no status-bar takeover; the `remoteMsg` handler clears `refreshingFor` on completion, which reverts the title to name+about and halts the `spinner.TickMsg` loop. `refreshingFor` doubles as the double-press guard; a tool with no `GitHub` only re-detects the installed version (`m.statusMsg = "no repo to refresh"`, no spinner). Note `case "r"` no longer branches at all: it is refresh in `focusBrief` and unbound everywhere else. Rename went global as `m` and the README source moved to `R`, so the key that once meant three things by focus now means one. -- **Update (`enter` in `focusBrief`)** — full rationale in **[`docs/design/updating.md`](docs/design/updating.md)**. Installs a newer release from inside the TUI: `enter` is the card's primary action (in `focusTools` the same key runs the tool), requires `hasUpdate(name)`, and reports the shared `updateBusyStatus` while `updatingFor != ""` — one update at a time, no queue. The guard sequence lives in `startToolUpdate()` (**pointer receiver**, it sets a status message). Detection runs off `Update()` in `detectUpdateCmd(t, false)` because it spawns subprocesses. The invariants most often broken (the full list is in the design file): **`updater.Detect`'s chain order is load-bearing twice** — brew before go, pnpm/bun before npm (both layouts carry `node_modules` segments npm claims on sight, and the misdetection installs a duplicate the shadowed copy hides); **an empty manager root disables its own step, enforced inside `underDir`/`segmentUnder`**, never by a repeated `!= ""` guard (`filepath.Rel("", …)` makes a relative path read as living under every disabled root); all five roots come from `managerDirsFrom` and are **symlink-expanded** by the wrapper, because `Detect` compares them against an `EvalSymlinks`-resolved binary path; pnpm needs the cmd-shim's `# cmd-shim-target=` line and an over-cap file is **rejected whole**, never parsed truncated. `update_cmd` always wins; a `LookPath` miss **and** an exhausted chain both fall back to brew-by-name. `acceptsUpdateDetect(msg)` drops a stale result, and `m.updateTarget` is resolved from the *message*, not from the selection at keypress time. Streaming order is fixed by os/exec: scan the pipe to EOF → `cmd.Wait()` → final `updateLine{done:true, err, elapsed}` → `close(ch)`; a `\r` segment sets `replace`; the log caps at ~500 lines; the deadline path uses `proc.KillGroup` (negative pid); `elapsed` is stamped in `startUpdateCmd`, never in `Update()`, where `time.Now()` would make completion non-deterministic in tests. **`showsUpdateLog()` is the single predicate for who owns `[3]`**. A finished session leaves a **terminal block** under the log — `✓ finished · go · 12s` / `✕ failed · brew · 4s`, then the *verified* version once the post-update `installedMsg` lands (`⚠ fd still v10.2.0` is what catches a manager that exited zero having done nothing), then the way out — and the frame follows it (`[3] update` → `[3] update finished` / `[3] update failed`, words rather than glyphs because `insetPanelTitle` measures runes). It is **model state, not log lines**: the buffer is wrapped at render time and would shred styling. `recordUpdateOutcome` is the single writer, shared with the self path and called on **both** results, so neither the block nor the log format can drift between them. **The phase-2 write in the `installedMsg` handler sits after that handler's cursor remap**, never before: its repaint is gated on `showsUpdateLog()` → `selectedMeta()`, and the version merge above is what re-partitions the list the index reads against, so the pre-remap order both skips the repaint for the tool that just updated and paints its log over the tool sitting at its old row. The **buffer itself renders `Dim`** (`dimUpdateLog`) so the block is the only thing on the panel carrying a verdict; the style lands after the wrap, per whole line, and strips nothing — segments are sanitized at the `updateChunkMsg` boundary. +- **Update (`enter` in `focusBrief`)** — full rationale in **[`docs/design/updating.md`](docs/design/updating.md)**. Installs a newer release from inside the TUI: `enter` is the card's primary action (in `focusTools` the same key runs the tool), requires `hasUpdate(name)`, and reports the shared `updateBusyStatus` while `updatingFor != ""` — one update at a time, no queue. The guard sequence lives in `startToolUpdate()` (**pointer receiver**, it sets a status message). Detection runs off `Update()` in `detectUpdateCmd(t, false)` because it spawns subprocesses. The invariants most often broken (the full list is in the design file): **`updater.Detect`'s chain order is load-bearing twice** — brew **and stew** before go (both install Go binaries whose buildinfo would misroute them to `go install`), pnpm/bun before npm (both layouts carry `node_modules` segments npm claims on sight, and the misdetection installs a duplicate the shadowed copy hides); **an empty manager root disables its own step, enforced inside `underDir`/`segmentUnder`**, never by a repeated `!= ""` guard (`filepath.Rel("", …)` makes a relative path read as living under every disabled root); all five roots come from `managerDirsFrom` and are **symlink-expanded** by the wrapper, because `Detect` compares them against an `EvalSymlinks`-resolved binary path; pnpm needs the cmd-shim's `# cmd-shim-target=` line and an over-cap file is **rejected whole**, never parsed truncated. `update_cmd` always wins; a `LookPath` miss **and** an exhausted chain both fall back to brew-by-name. `acceptsUpdateDetect(msg)` drops a stale result, and `m.updateTarget` is resolved from the *message*, not from the selection at keypress time. Streaming order is fixed by os/exec: scan the pipe to EOF → `cmd.Wait()` → final `updateLine{done:true, err, elapsed}` → `close(ch)`; a `\r` segment sets `replace`; the log caps at ~500 lines; the deadline path uses `proc.KillGroup` (negative pid); `elapsed` is stamped in `startUpdateCmd`, never in `Update()`, where `time.Now()` would make completion non-deterministic in tests. **`showsUpdateLog()` is the single predicate for who owns `[3]`**. A finished session leaves a **terminal block** under the log — `✓ finished · go · 12s` / `✕ failed · brew · 4s`, then the *verified* version once the post-update `installedMsg` lands (`⚠ fd still v10.2.0` is what catches a manager that exited zero having done nothing), then the way out — and the frame follows it (`[3] update` → `[3] update finished` / `[3] update failed`, words rather than glyphs because `insetPanelTitle` measures runes). It is **model state, not log lines**: the buffer is wrapped at render time and would shred styling. `recordUpdateOutcome` is the single writer, shared with the self path and called on **both** results, so neither the block nor the log format can drift between them. **The phase-2 write in the `installedMsg` handler sits after that handler's cursor remap**, never before: its repaint is gated on `showsUpdateLog()` → `selectedMeta()`, and the version merge above is what re-partitions the list the index reads against, so the pre-remap order both skips the repaint for the tool that just updated and paints its log over the tool sitting at its old row. The **buffer itself renders `Dim`** (`dimUpdateLog`) so the block is the only thing on the panel carrying a verdict; the style lands after the wrap, per whole line, and strips nothing — segments are sanitized at the `updateChunkMsg` boundary. - **Self-update (`U`/`X`)** — full rationale in **[`docs/design/self-update.md`](docs/design/self-update.md)**. keepkit watches its own releases and installs one through the very same pipeline as `enter`. **The feature's main case is a keepkit that is not tracked**, so nothing in this path may read `meta.yaml`, the selection or a card — every guard that normally leans on `selectedMeta()` has a self counterpart that does not. The invariants most often broken (the full list is in the design file): **`selfCheckEnabled()` rejects three shapes**, not one — `""` (no `WithAppVersion`), `"dev"`, and anything `isDevVersion` sees as a working copy (a Go pseudo-version tail or any `+` build metadata), which is what keeps `go build .` from offering to `go install …@latest` over itself; **`isSelfUpdate(name)` = the name *and* that gate**, both clauses load-bearing; the `selfCheckMsg` handler writes **only from `selfNone`**, in either direction, so a late message cannot walk back a state the user acted on; `selfState` has **no "updating" member** — that is derived by `selfUpdating()`; **six sites switch on `selfState`** and each enumerates every member and ends in a `default:`, with the `selfStateCount` sentinel driving `TestSelfStateSitesAreExhaustive`; the failure branch of `updateDoneMsg` writes **no `selfState` at all** (the banner returns by itself once `updatingFor` clears, and any write there could only walk a state back); `[U]` checks `selfNone` **before** the busy guard, or a dev build answers `another update is running` for a surface documented as absent. Restart happens strictly **after `p.Run()` returns** (Bubble Tea has restored the terminal by then), and `resolveSelfPath`'s order matters: an argv0 carrying a separator wins when it exists, a bare argv0 goes through `lookPath` **first** because Linux's `/proc/self/exe` can still name the old binary after an upgrade, and `sameProgram` rejects a `PATH` hit whose base differs. - **Panel `[3]` modes (`helpMode`)** — full rationale in **[`docs/design/readme-pipeline.md`](docs/design/readme-pipeline.md)**. Three sources: `helpModeReadme = 2` (the **default** set in `New()`), `helpModeHelp = 0`, `helpModeMan = 1`. `helpMode` is a sticky global field, not per tool; `[R]`/`[H]`/`[M]` switch it from `focusBrief || focusHelp` through the shared **`switchHelpMode(mode)`** (sets the mode, dismisses a *completed* update log, `setHelpContent()` + `GotoTop()`, returns the fetch command for the mode's missing source). The trio is **capitals as a set** so none collides with a lowercase verb (`r` is `[2]`'s refresh, `m` the global rename). The invariants most often broken (the full list is in the design file): **`m.helpCache` is a `map[string][2]string` indexed by `helpMode`, so mode 2 panics on every index site** — README content lives in `m.readmeData` and each index site (`rawHelpText`, `renderHelpContent`, `autoFetchCmdsForSelected`) carries a readme early-return *before* the array read; a **live** update log keeps `[3]` in every path (its branch sits ahead of both the readme branch and the `No tool selected` guard). Rendering is `cleanTerminalOutput` → **`cleanReadmeMarkdown`** (readme_clean.go) → glamour with `keepkitStyle`, and a glamour failure falls back to the **preprocessed** text, never to the raw one. In the preprocessor: **code is never rewritten** (fenced blocks segmented, inline spans NUL-masked), CRLF is normalized on entry (a `\r`-suffixed closing fence protects the rest of the file), images run before links, autolinks and bare URLs are left alone, both reference forms are gated on labels collected **document-wide after** the HTML rules, HTML tag names come from the fixed `rcHTMLNames` allowlist whose trailing `\b` is what makes the ~80-branch alternation order-independent, and **`rcLineContent` slices rather than `ReplaceAllString`** — the replace form froze the whole TUI for 8.4 s on a 512 KiB adversarial README. `keepkitStyle` **clones** the glamour globals and assigns a **fresh pointer** per override, because `styles.DefaultStyles` aliases the same structs and writing through a cloned one restyles glamour process-wide. Dark/light is resolved **once at construction** into `m.darkBG` — `glamour.WithAutoStyle()` probes the terminal with an OSC query that races Bubble Tea's input reader. - **Help navigation (`j`/`k` in `focusHelp`)**: `[3]` is navigable per *entry* — a flag or subcommand line plus its indented description block. `parseHelpEntries(raw, width)` (textutil.go) detects entries heuristically on the **pre-wrap source lines** (flag start = the `helpTokenRe` flag core at the trimmed line start; subcommand start = `helpEntrySubcmdRe`, an indented non-dash word + 2+ spaces + text — the word class excludes `.` so justified man prose like `tree. See also…` doesn't match; continuation = `continuesEntry`: any deeper-indented non-header line — including deeper lines that *begin* with a flag token (`…overridden with\n --no-ignore.`) — plus blank lines whose next non-blank line still continues, so multi-paragraph descriptions stay one entry; the entry ends at a section header or the next line at the entry's own indent or shallower) and maps the ranges to wrapped display-line indices via `wrapLine` — the same code `wrapText` uses, which is the point: `wrapText` rebuilds wrapped lines from `strings.Fields` (indentation is lost), so parsing wrapped output would break the indent heuristic, and sharing the wrap algorithm plus the single `helpWrapWidth()` (`max(helpW-1-2*panelGutter, 20)` — the viewport is a column narrower than the panel and a gutter is held at each end) keeps entry indices in lockstep with what the viewport shows. `isHelpSectionHeader` is the one definition of a header, used by both `colorizeHelp` and the parser. State is `m.helpEntries []entryRange` + `m.helpNavIdx` (−1 = off) + `m.helpBase` — the wrapped+colorized full-color content, cached because cursor moves repaint per keystroke and must not re-run the colorize regex over a whole man page (`helpContent`'s normal path serves `applySpotlight(helpBase)`, and `renderHelpContent` is the one-line wrapper that steps the result in by `panelGutter`; the base is built directly in `setHelpContent`, not via the renderer, which can be in the search-highlight branch). **`setHelpContent()` is the single recompute point** — every site where the *visible* text changes (selection via `autoFetchCmdsForSelected`, `[R]`/`[H]`/`[M]`, `helpOutputMsg` — gated on `msg.mode == m.helpMode`, a late fetch for the hidden mode must not reset the cursor, `readmeMsg` for the selected tool while in readme mode, resize — only when `helpWrapWidth()` actually changed, so a height-only resize keeps the cursor and a width change re-renders the README, update-log start) goes through it: recompute entries (empty for the update log, readme mode, `helpLoadingFor` and placeholders — `j`/`k` stay plain scroll there), reset the cursor, repaint, never scroll. Style-only repaints (help-search keystrokes, per-chunk log appends, cursor moves) call `SetContent(renderHelpContent())` directly and must not reset the cursor. Interaction: **only the letter keys navigate — `↑`/`↓` keep their 3-line scroll** so prose between/after entries stays keyboard-reachable; the first `j`/`k` lands via `helpNavStart(delta)` on the first entry intersecting the window, or (none visible) the nearest entry in the movement direction; later presses step clamped without wrap; `applySpotlight` (render.go) dims every line outside the current entry (`Styles.Dim.Render(stripANSI(line))` — the `[a]` overlay's strip-then-repaint trick per whole line) while the entry keeps full `colorizeHelp` color; `scrollToNavEntry` keeps it in view with mutually exclusive branches and a `min(end-Height, start)` clamp so a taller-than-window entry pins its start to the top. `esc` is two-stage: cursor off first (scroll kept), focus walk second. `PgUp`/`PgDn`/`g`/`G`/wheel stay pure scroll and never touch the cursor. Every path that deactivates navigation (esc, `/` help-search entry, any `setFocus` move) goes through `clearHelpNav()`, which pairs the reset with the repaint — clearing the index without repainting leaves stale dimming. The `focusHelp` bar shows `[j/k] navigate` alongside `[↑↓] scroll` when entries exist and prepends `[esc] exit nav` while the cursor is on. diff --git a/docs/design/updating.md b/docs/design/updating.md index e9e919c..7c604fb 100644 --- a/docs/design/updating.md +++ b/docs/design/updating.md @@ -8,7 +8,9 @@ Read this before touching `internal/updater`, update rides this same pipeline; its banner, version gate and restart are described in [`self-update.md`](self-update.md). -- **Update (`enter` in `focusBrief`)**: installs a newer release from inside the TUI. `enter` fires the card's primary action in `focusBrief` (in `focusTools` the same key runs the selected tool); it requires `hasUpdate(name)` (else `statusMsg`) and reports the shared `updateBusyStatus` (`another update is running`) while `updatingFor != ""` instead of starting a second one (one update at a time, no queue; the same wording `[U]` uses, since the running update's only other sign is a card spinner invisible unless that tool is selected). The whole guard sequence lives in `startToolUpdate()` — a **pointer receiver**, because it sets a status message, and its caller assigns the command before returning `m`, since Go copies the model into the return before evaluating the second operand. It fires `detectUpdateCmd(t, false)` — detection spawns subprocesses (`go version -m`, `cargo install --list`) so it must never run inside `Update()`, same as every other probe. `updater.Detect` runs the brew → go → cargo → pipx → uv → pnpm → bun → npm chain. **Order is load-bearing twice**: brew before go, so a brew-installed Go binary with buildinfo isn't misrouted, and pnpm/bun before npm, because both layouts carry `node_modules` segments the npm step claims on sight — a bun global (`$BUN_INSTALL/bin/` → `install/global/node_modules//…`) really did resolve to `npm install -g `, the wrong manager, installing a duplicate under npm's prefix that the bun copy keeps shadowing on `PATH`. Five steps are **path-convention** based (cargo, pipx, uv, pnpm, bun), and an empty root switches its own step off — enforced **inside `underDir`/`segmentUnder`**, which answer "no match" for an empty dir, deliberately *not* by a `!= ""` guard repeated at each step. The hazard is real: `filepath.Rel("", "bin/exa")` succeeds and yields `bin/exa`, so a **relative** path reads as living under every disabled root, and one forgotten copy of the guard silently claims it. It lived as six hand-copied copies of that one convention until the review that turned it into one definition — the same lesson as `version.applyReleaseOutcome` (`TestPathHelpersRejectEmptyDir`, plus a per-step `empty leaves a relative path undetected` row): `uv` = `segmentUnder(realPath, uvTools)` → `uv tool upgrade `; `pnpm` = `underDir(realPath, pnpmHome)` plus a name from the shim target or the resolved path → `pnpm add -g `; `bun` = `underDir(realPath, bunInstall)` + `npmPackage(realPath)` → `bun add -g `. **`add -g`, not `update -g`**: `update` honours the semver range saved at install time and can silently refuse a major bump, while keepkit promises the version the card shows as `latest:`. A manager's own binary carries no `node_modules` segment and no shim target, so it falls through — `bun upgrade`/`pnpm self-update` are deliberately out of scope. **All five roots** come from **`managerDirsFrom(getenv, home, goos)`** (pure core, `resolveManagerDirs()` wrapper — the `launcher.planFor` idiom), and carrying cargo/pipx there too is what lets `detectFromPath` stop calling `homeDir()`, making its "no I/O, no environment" contract literally true instead of nearly true. cargo and pipx stay home-derived with **no env var of their own** — `$CARGO_HOME`/`$PIPX_HOME` are not consulted, exactly as before the struct existed, since honouring them changes behaviour for anyone who sets them and belongs in its own commit. `/.cargo/bin` and `/.local/pipx/venvs`; `$UV_TOOL_DIR` else `$XDG_DATA_HOME/uv/tools` else `~/.local/share/uv/tools` (macOS included — uv is XDG there too; **Windows env-only**, its layout is unverified and a wrong guess beats no guess only in the wrong direction), `$PNPM_HOME` else `~/Library/pnpm` (darwin) / `$XDG_DATA_HOME/pnpm` else `~/.local/share/pnpm` (linux) / `%LOCALAPPDATA%\pnpm` (windows), `$BUN_INSTALL` else `~/.bun`. An empty field is a *disabled* check, which is what keeps the zero-value `managerDirs{}` backwards compatible. The wrapper then **expands symlinks in every one of the five roots** (`resolveDir` → `filepath.EvalSymlinks`, falling back to the raw path when it does not resolve — a root that does not exist yet is the normal state for an uninstalled manager and must stay a harmless *non-match* rather than becoming an empty, i.e. disabled, field). That expansion is load-bearing, not hygiene: `Detect` compares these roots against an `EvalSymlinks`-**resolved** binary path, so a root carrying any symlink component — a relocated `~/.bun` → `/mnt/big/bun`, a home on a secondary volume, `/home` under autofs, and on macOS `/var` → `/private/var`, which is what made every fixture test here need `EvalSymlinks(t.TempDir())` — silently fails to match. For uv and pnpm's shim/store layouts that only costs the update offer, but a bun global and a legacy pnpm one still carry a plain `node_modules/` segment, so the npm step claims them and offers `npm install -g ` — the exact duplicate-install misdetection this chain exists to prevent, measured on all five layouts and pinned by `TestDetectSymlinkedManagerRoot`. The expansion is a **loop over pointers to all five fields**, so a sixth field added to `managerDirs` but forgotten there would resolve to a root nothing can ever match; `TestResolveManagerDirsExpandsSymlinks` asserts all five at once as the guard. **pnpm needs a second signal** because its globals are not symlinks at all: `$PNPM_HOME/bin/` is a cmd-shim `/bin/sh` script `EvalSymlinks` resolves to itself, whose last line — `# cmd-shim-target=` — is the only machine-readable link to the owning package. `Detect` reads it (best-effort, capped at `pnpmShimMaxBytes` = 8 KiB against real shims of ~1.5 KiB, and **only** when the found path sits under a non-empty `pnpmHome` — reading every binary on `PATH` to look for a comment would be an open per detection) through `readPnpmShim` → the pure `pnpmShimTarget` (last marker wins: cmd-shim writes it as the final line). A file **over** the cap is rejected whole (`LimitReader(cap+1)` + a length check, `getReadme`'s idiom) rather than parsed truncated, and that is a correctness guard, not tidiness: a cut landing inside the marker line hands the parser a *shortened* path, and `…/node_modules/typescript/…` shortened to `…/node_modules/types` yields `types` — a real package on npm, so the chain would offer a confidently wrong `pnpm add -g types` where everything else here degrades honestly (`TestReadPnpmShim`'s oversized row, whose fixture asserts the truncated parse *would* have said `types`), and passes it into the core as `shimTarget` exactly the way `goBuildinfo` rides in; an unreadable file yields `""`, i.e. the check simply has no signal. Two npm-side consequences of the pnpm layout: `npmPackage` **refuses a bare scope** (`…/node_modules/@angular` with nothing after it is a directory holding packages, not one — returning it offered `pnpm add -g @angular`, and the pnpm step feeds this function a *shim target*, i.e. file content nothing validated) and **skips a post-`node_modules` segment starting with `.`** (`.pnpm` is the virtual store, `.bin` the shim dir — never package names) and keeps scanning, and the **npm step refuses any path through `/node_modules/.pnpm/`** — such a path reaching npm means a pnpm layout the pnpm step failed to attribute, and `npm install -g ` there is the duplicate-install failure again, so the chain falls through to `ErrUnknownManager` + the `update_cmd` hint (honest degradation). Detection is convention-based end to end: a future layout change makes its check miss *silently* and degrade to the hint, never to a wrong command. A `update_cmd` in `meta.yaml` always wins and runs via `sh -c` (skips detection entirely). The chain starts from `exec.LookPath(t.Name)`, and a **miss there is not the end**: the tracked name can still be a brew formula whose binaries are named differently — `rust` ships `rustc`/`cargo`, so `LookPath("rust")` misses while `brew upgrade rust` is exactly right — so `brewNamePlan` checks for a `Cellar/`/`Caskroom/` directory before `ErrUnknownManager` is returned. **An exhausted chain takes the same fallback**: a binary can be found and still belong to no known manager — a Homebrew cask app keeps its launcher on `PATH` while the executable lives inside the `.app` bundle (`agterm`), matching neither the Cellar regex nor any later step — so `Detect` retries `brewNamePlan` on `ErrUnknownManager` from `detectFromPath` too (`TestDetectBrewByNameChainExhausted`). Both sites are self-validating (they fire only when such a keg actually exists) and share `version.brewDirVersion`'s traversal guard: a name carrying `/` or `\` can't be a formula name and must not turn the `Join` into a traversal. The `updateDetectedMsg` handler drops a stale result through the shared predicate **`acceptsUpdateDetect(msg)`**: both paths refuse while `updatingFor != ""` and while `m.mode != modeNormal` (detection spawns subprocesses and can answer seconds later — a confirm dialog opening under an editor, a search or an overlay steals the keystroke aimed at it, the mirror of `launchDoneMsg`'s mode gate), and beyond that a *tool* result must still match the selection while keepkit's own has no selection to match. It maps `ErrUnknownManager` to a `statusMsg` hint (`no known updater for — set update_cmd or [o] releases` — no dead-end dialog; the wording branches on **`isSelfUpdate(msg.tool)`**, not on `msg.self`, so the identical failure of the identical binary reads the same from `enter` on a tracked `keepkit` row as from `[U]` — `msg.self` keeps the one meaning only it has, "no selection to match", inside `acceptsUpdateDetect`), and on success stores `m.updatePlan` plus **`m.updateTarget = msg.tool`** and enters `modeConfirmUpdate`. The target is resolved *there*, not when enter is pressed: a selection that moved while detection ran can no longer retarget the dialog, and keepkit's own update — which has no row, and no selection at all with an empty tracker — needs no second identity. The confirm status bar shows `update : enter run esc cancel`; `enter` sets `m.updatingFor`/`m.updateLogFor` to that target, resets the log, and fires `startUpdateCmd` (any other key cancels, not just `esc`, and clears `updateTarget` with the plan it named). The `enter update to ` cell leads the `[2]` panel footer only when `hasUpdate(selected)`. +- **Update (`enter` in `focusBrief`)**: installs a newer release from inside the TUI. `enter` fires the card's primary action in `focusBrief` (in `focusTools` the same key runs the selected tool); it requires `hasUpdate(name)` (else `statusMsg`) and reports the shared `updateBusyStatus` (`another update is running`) while `updatingFor != ""` instead of starting a second one (one update at a time, no queue; the same wording `[U]` uses, since the running update's only other sign is a card spinner invisible unless that tool is selected). The whole guard sequence lives in `startToolUpdate()` — a **pointer receiver**, because it sets a status message, and its caller assigns the command before returning `m`, since Go copies the model into the return before evaluating the second operand. It fires `detectUpdateCmd(t, false)` — detection spawns subprocesses (`go version -m`, `cargo install --list`) so it must never run inside `Update()`, same as every other probe. `updater.Detect` runs the brew → stew → go → cargo → pipx → uv → pnpm → bun → npm chain. **Order is load-bearing twice**: brew **and stew** before go, so a brew/stew-installed Go binary with buildinfo isn't misrouted, and pnpm/bun before npm, because both layouts carry `node_modules` segments the npm step claims on sight — a bun global (`$BUN_INSTALL/bin/` → `install/global/node_modules//…`) really did resolve to `npm install -g `, the wrong manager, installing a duplicate under npm's prefix that the bun copy keeps shadowing on `PATH`. + +**stew is the one manager in the chain that cannot be proven by path alone**: its binaries land in a plain, shared bin dir (default `~/.local/bin`) that anyone installs into, so `detectFromPath` sees a `stewManaged bool` computed by `Detect` rather than a root it can check itself. The verdict pairs path with stew's own two files: `stew.config.json` (at `$XDG_CONFIG_HOME/stew` else `~/.config/stew`, Windows `%LOCALAPPDATA%\stew\Config`) resolves both configurable directories — `stewPath`, the data root that holds `pkg/` and `Stewfile.lock.json`, and `stewBinPath`, the install dir — with stew's own defaults for whatever the file (or the whole file, when absent or malformed) omits (data `$XDG_DATA_HOME/stew` else `~/.local/share/stew`, bin `~/.local/bin`, Windows `%LOCALAPPDATA%\stew` / `…\stew\bin`); and the lock file's `packages[].binary` must name `filepath.Base(realPath)`. Both halves are load-bearing: the lock alone would claim a same-named binary shadowing stew's on `PATH` (a cargo `rg` ahead of `~/.local/bin/rg`), and the path alone would offer `stew upgrade ` for every hand-installed file in the shared bin dir — which stew itself refuses ("binary not installed"). The lock entry's `source` must be `"github"`: a URL install (`source: "other"`) is not upgradable by `stew upgrade` by design, so offering it would be a known-failing command and keepkit stays quiet (honest degradation). The command is **`stew upgrade `** — `rg`, not the repo name — and the bin dir gets the same `resolveDir` symlink expansion as the `managerDirs` roots before the comparison, since `Detect` compares it against an `EvalSymlinks`-resolved binary path. The verdict is gathered where buildinfo is — once, in `Detect`, only for a binary that exists — and rides into the pure core as a bool, so the core's "no I/O, no environment" contract holds. Five steps are **path-convention** based (cargo, pipx, uv, pnpm, bun), and an empty root switches its own step off — enforced **inside `underDir`/`segmentUnder`**, which answer "no match" for an empty dir, deliberately *not* by a `!= ""` guard repeated at each step. The hazard is real: `filepath.Rel("", "bin/exa")` succeeds and yields `bin/exa`, so a **relative** path reads as living under every disabled root, and one forgotten copy of the guard silently claims it. It lived as six hand-copied copies of that one convention until the review that turned it into one definition — the same lesson as `version.applyReleaseOutcome` (`TestPathHelpersRejectEmptyDir`, plus a per-step `empty leaves a relative path undetected` row): `uv` = `segmentUnder(realPath, uvTools)` → `uv tool upgrade `; `pnpm` = `underDir(realPath, pnpmHome)` plus a name from the shim target or the resolved path → `pnpm add -g `; `bun` = `underDir(realPath, bunInstall)` + `npmPackage(realPath)` → `bun add -g `. **`add -g`, not `update -g`**: `update` honours the semver range saved at install time and can silently refuse a major bump, while keepkit promises the version the card shows as `latest:`. A manager's own binary carries no `node_modules` segment and no shim target, so it falls through — `bun upgrade`/`pnpm self-update` are deliberately out of scope. **All five roots** come from **`managerDirsFrom(getenv, home, goos)`** (pure core, `resolveManagerDirs()` wrapper — the `launcher.planFor` idiom), and carrying cargo/pipx there too is what lets `detectFromPath` stop calling `homeDir()`, making its "no I/O, no environment" contract literally true instead of nearly true. cargo and pipx stay home-derived with **no env var of their own** — `$CARGO_HOME`/`$PIPX_HOME` are not consulted, exactly as before the struct existed, since honouring them changes behaviour for anyone who sets them and belongs in its own commit. `/.cargo/bin` and `/.local/pipx/venvs`; `$UV_TOOL_DIR` else `$XDG_DATA_HOME/uv/tools` else `~/.local/share/uv/tools` (macOS included — uv is XDG there too; **Windows env-only**, its layout is unverified and a wrong guess beats no guess only in the wrong direction), `$PNPM_HOME` else `~/Library/pnpm` (darwin) / `$XDG_DATA_HOME/pnpm` else `~/.local/share/pnpm` (linux) / `%LOCALAPPDATA%\pnpm` (windows), `$BUN_INSTALL` else `~/.bun`. An empty field is a *disabled* check, which is what keeps the zero-value `managerDirs{}` backwards compatible. The wrapper then **expands symlinks in every one of the five roots** (`resolveDir` → `filepath.EvalSymlinks`, falling back to the raw path when it does not resolve — a root that does not exist yet is the normal state for an uninstalled manager and must stay a harmless *non-match* rather than becoming an empty, i.e. disabled, field). That expansion is load-bearing, not hygiene: `Detect` compares these roots against an `EvalSymlinks`-**resolved** binary path, so a root carrying any symlink component — a relocated `~/.bun` → `/mnt/big/bun`, a home on a secondary volume, `/home` under autofs, and on macOS `/var` → `/private/var`, which is what made every fixture test here need `EvalSymlinks(t.TempDir())` — silently fails to match. For uv and pnpm's shim/store layouts that only costs the update offer, but a bun global and a legacy pnpm one still carry a plain `node_modules/` segment, so the npm step claims them and offers `npm install -g ` — the exact duplicate-install misdetection this chain exists to prevent, measured on all five layouts and pinned by `TestDetectSymlinkedManagerRoot`. The expansion is a **loop over pointers to all five fields**, so a sixth field added to `managerDirs` but forgotten there would resolve to a root nothing can ever match; `TestResolveManagerDirsExpandsSymlinks` asserts all five at once as the guard. **pnpm needs a second signal** because its globals are not symlinks at all: `$PNPM_HOME/bin/` is a cmd-shim `/bin/sh` script `EvalSymlinks` resolves to itself, whose last line — `# cmd-shim-target=` — is the only machine-readable link to the owning package. `Detect` reads it (best-effort, capped at `pnpmShimMaxBytes` = 8 KiB against real shims of ~1.5 KiB, and **only** when the found path sits under a non-empty `pnpmHome` — reading every binary on `PATH` to look for a comment would be an open per detection) through `readPnpmShim` → the pure `pnpmShimTarget` (last marker wins: cmd-shim writes it as the final line). A file **over** the cap is rejected whole (`LimitReader(cap+1)` + a length check, `getReadme`'s idiom) rather than parsed truncated, and that is a correctness guard, not tidiness: a cut landing inside the marker line hands the parser a *shortened* path, and `…/node_modules/typescript/…` shortened to `…/node_modules/types` yields `types` — a real package on npm, so the chain would offer a confidently wrong `pnpm add -g types` where everything else here degrades honestly (`TestReadPnpmShim`'s oversized row, whose fixture asserts the truncated parse *would* have said `types`), and passes it into the core as `shimTarget` exactly the way `goBuildinfo` rides in; an unreadable file yields `""`, i.e. the check simply has no signal. Two npm-side consequences of the pnpm layout: `npmPackage` **refuses a bare scope** (`…/node_modules/@angular` with nothing after it is a directory holding packages, not one — returning it offered `pnpm add -g @angular`, and the pnpm step feeds this function a *shim target*, i.e. file content nothing validated) and **skips a post-`node_modules` segment starting with `.`** (`.pnpm` is the virtual store, `.bin` the shim dir — never package names) and keeps scanning, and the **npm step refuses any path through `/node_modules/.pnpm/`** — such a path reaching npm means a pnpm layout the pnpm step failed to attribute, and `npm install -g ` there is the duplicate-install failure again, so the chain falls through to `ErrUnknownManager` + the `update_cmd` hint (honest degradation). Detection is convention-based end to end: a future layout change makes its check miss *silently* and degrade to the hint, never to a wrong command. A `update_cmd` in `meta.yaml` always wins and runs via `sh -c` (skips detection entirely). The chain starts from `exec.LookPath(t.Name)`, and a **miss there is not the end**: the tracked name can still be a brew formula whose binaries are named differently — `rust` ships `rustc`/`cargo`, so `LookPath("rust")` misses while `brew upgrade rust` is exactly right — so `brewNamePlan` checks for a `Cellar/`/`Caskroom/` directory before `ErrUnknownManager` is returned. **An exhausted chain takes the same fallback**: a binary can be found and still belong to no known manager — a Homebrew cask app keeps its launcher on `PATH` while the executable lives inside the `.app` bundle (`agterm`), matching neither the Cellar regex nor any later step — so `Detect` retries `brewNamePlan` on `ErrUnknownManager` from `detectFromPath` too (`TestDetectBrewByNameChainExhausted`). Both sites are self-validating (they fire only when such a keg actually exists) and share `version.brewDirVersion`'s traversal guard: a name carrying `/` or `\` can't be a formula name and must not turn the `Join` into a traversal. The `updateDetectedMsg` handler drops a stale result through the shared predicate **`acceptsUpdateDetect(msg)`**: both paths refuse while `updatingFor != ""` and while `m.mode != modeNormal` (detection spawns subprocesses and can answer seconds later — a confirm dialog opening under an editor, a search or an overlay steals the keystroke aimed at it, the mirror of `launchDoneMsg`'s mode gate), and beyond that a *tool* result must still match the selection while keepkit's own has no selection to match. It maps `ErrUnknownManager` to a `statusMsg` hint (`no known updater for — set update_cmd or [o] releases` — no dead-end dialog; the wording branches on **`isSelfUpdate(msg.tool)`**, not on `msg.self`, so the identical failure of the identical binary reads the same from `enter` on a tracked `keepkit` row as from `[U]` — `msg.self` keeps the one meaning only it has, "no selection to match", inside `acceptsUpdateDetect`), and on success stores `m.updatePlan` plus **`m.updateTarget = msg.tool`** and enters `modeConfirmUpdate`. The target is resolved *there*, not when enter is pressed: a selection that moved while detection ran can no longer retarget the dialog, and keepkit's own update — which has no row, and no selection at all with an empty tracker — needs no second identity. The confirm status bar shows `update : enter run esc cancel`; `enter` sets `m.updatingFor`/`m.updateLogFor` to that target, resets the log, and fires `startUpdateCmd` (any other key cancels, not just `esc`, and clears `updateTarget` with the plan it named). The `enter update to ` cell leads the `[2]` panel footer only when `hasUpdate(selected)`. - **Streaming** (channel + re-subscribe idiom, no `*tea.Program`): `startUpdateCmd` runs the plan via `exec.Command` + `proc.DetachTTY` (10-min deadline; a sudo prompt fails fast instead of hanging — deliberate), with stdout+stderr merged into one pipe. **Reader ordering is load-bearing** (os/exec forbids `Wait` before pipe reads finish): the goroutine scans the pipe to EOF via `streamLines` → then `cmd.Wait()` → sends the exit error as a final `updateLine{done:true, err}` → then `close(ch)`. `waitForChunkCmd` does one receive → `updateChunkMsg`; a done item or closed channel → `updateDoneMsg`. The channel carries a typed `updateLine{text, replace, done, err, elapsed}` (not `chan string`) so the `replace` flag, the completion error and the duration all ride the same channel — no second error channel threaded through every re-subscribe. **`elapsed` is stamped here, after `Start` returns**, and not in the `updateDoneMsg` handler: `time.Now()` inside `Update()` would make completion non-deterministic in every test that drives it, while this is where the process actually lives. The two early returns (empty argv, a `StdoutPipe`/`Start` error) never reach the stamp and report zero, which the outcome block reads as "it never ran" and prints no duration cell for. Each segment is sanitized through `cleanTerminalOutput` (which already strips ANSI) at the boundary; `streamLines` splits on `\n` **and** `\r`, and a `\r` segment sets `replace` so brew/npm progress bars collapse to one updating line. `m.updateLog` is capped at ~500 lines (tail matters). On deadline, `proc.KillGroup` SIGKILLs the process group (negative pid — `DetachTTY`'s `Setsid` makes the child a session leader, so a plain kill would orphan `sh -c` grandchildren). - **Live log in `[3]`**: `m.updateLog []string` is a single active-session buffer (not a map); `m.updateLogFor` names its tool. Every site that asks "does the log own panel `[3]`?" asks the **single predicate `showsUpdateLog()`** (defined in [`self-update.md`](self-update.md)), never `updateLogFor` directly: `renderHelpContent()` returns the log **ahead of** the `helpLoadingFor`/cache branches, and `autoFetchCmdsForSelected` skips the help fetch (and `helpLoadingFor` set) — otherwise re-selecting the tool paints `Loading...` or a late `helpOutputMsg` clobbers the live log. The panel title comes from **`updateLogTitle()`** (same `insetPanelTitle` path as `[3] help`/`[3] man`, and the source hints are dropped with it): `[3] update` while one runs, then `[3] update finished` / `[3] update failed` once the session has ended. One string feeds both the title and the footer's source cell, so the two can never disagree about whether an update is still running, and a reader who has scrolled away from the end of a long log still learns it ended. **Words, not the block's own `✓`/`✕`**: `insetPanelTitle` measures the title in **runes**, not cells, so an East-Asian-Ambiguous glyph there renders two cells wide under `RUNEWIDTH_EASTASIAN=1` and pushes the top border out by one — inside the viewport the same glyphs are harmless, which is why the block keeps them. The panel autoscrolls to bottom on each chunk, and the buffer persists after completion until the next update. For a *tool* update the claim is per tool: navigating away shows the other tool's normal help; back shows the live log. - **Spinner + completion**: `m.updatingFor` twins `refreshingFor` — card title `updating `; the `spinner.TickMsg` gate is `refreshingFor != "" || updatingFor != ""` (or the spinner freezes after one frame). The `updateDoneMsg` handler clears `updatingFor`; success → `statusMsg "updated "` + `fetchInstalledCmd(t)` (the version merge extinguishes `↑` and the existing by-name cursor remap moves the tool out of the update group); failure → `statusMsg "update failed — see [3]"`. A tool untracked mid-update just clears `updatingFor` (no re-fetch, no crash). diff --git a/internal/updater/stew.go b/internal/updater/stew.go new file mode 100644 index 0000000..1ccc214 --- /dev/null +++ b/internal/updater/stew.go @@ -0,0 +1,177 @@ +// stew detection lives here: stew (marwanhawari/stew) is a source-compiler +// package manager whose binaries land in a plain, shared bin directory — the +// default is ~/.local/bin on unix — so no path convention alone can prove +// ownership. The two things that can are stew's own files: stew.config.json +// (which resolves where stew installs to and where its data lives) and +// Stewfile.lock.json (which lists exactly which binaries stew manages). Both +// are read by the OS-facing wrapper; the pure core only parses. + +package updater + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" +) + +// stewConfigFilePath resolves stew.config.json's location, mirroring stew's +// own rules (GetStewConfigFilePath in lib/config.go): $XDG_CONFIG_HOME/stew or +// ~/.config/stew on unix, %LOCALAPPDATA%.../stew/Config on Windows. +func stewConfigFilePath(getenv func(string) string, home, goos string) string { + switch goos { + case "windows": + if home == "" { + return "" + } + return filepath.Join(home, "AppData", "Local", "stew", "Config", "stew.config.json") + default: + if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "stew", "stew.config.json") + } + if home == "" { + return "" + } + return filepath.Join(home, ".config", "stew", "stew.config.json") + } +} + +// readStewConfig is the OS-facing half of the stew signal: the config file's +// contents, or "" when absent or unreadable — a missing file just means stew +// is not configured (or not installed), which is the normal non-match state. +func readStewConfig() string { + p := stewConfigFilePath(os.Getenv, homeDir(), runtime.GOOS) + if p == "" { + return "" + } + b, err := os.ReadFile(p) + if err != nil { + return "" + } + return string(b) +} + +// stewDirsFrom is the pure resolution core: stew's two configurable +// directories from its config file, with stew's platform defaults for the +// fields the file (or one of its two values) omits. A malformed config is +// treated as an empty one — defaults apply. Either result being "" means the +// stew check is off (no home to default against), exactly like an empty +// managerDirs field. +func stewDirsFrom(configJSON string, getenv func(string) string, home, goos string) (bin, data string) { + if configJSON != "" { + var cfg struct { + StewPath string `json:"stewPath"` + StewBinPath string `json:"stewBinPath"` + } + if err := json.Unmarshal([]byte(configJSON), &cfg); err == nil { + bin, data = cfg.StewBinPath, cfg.StewPath + } + } + if bin == "" { + bin = stewDefaultBin(home, goos) + } + if data == "" { + data = stewDefaultData(getenv, home, goos) + } + return bin, data +} + +// stewDefaultBin is stew's default install location (GetDefaultStewBinPath). +func stewDefaultBin(home, goos string) string { + if home == "" { + return "" + } + switch goos { + case "windows": + return filepath.Join(home, "AppData", "Local", "stew", "bin") + default: + return filepath.Join(home, ".local", "bin") + } +} + +// stewDefaultData is stew's default data root, the parent of pkg/ and +// Stewfile.lock.json (GetDefaultStewPath). +func stewDefaultData(getenv func(string) string, home, goos string) string { + if home == "" { + return "" + } + switch goos { + case "windows": + return filepath.Join(home, "AppData", "Local", "stew") + default: + if xdg := getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "stew") + } + return filepath.Join(home, ".local", "share", "stew") + } +} + +// readStewLock reads stew's lock file at /Stewfile.lock.json, or "" +// when absent/unreadable. Stew rewrites it on every install/upgrade, so a +// stale-but-present file is the normal state; content arriving here is trusted +// to the same degree pnpm shim content is — see the bare-scope hole in +// npmPackage for why that still gets validated rather than interpolated. +func readStewLock(dataDir string) string { + if dataDir == "" { + return "" + } + b, err := os.ReadFile(filepath.Join(dataDir, "Stewfile.lock.json")) + if err != nil { + return "" + } + return string(b) +} + +// stewManaged reports whether stew owns the binary on PATH at realPath. Thin +// OS-facing wrapper over stewOwned: it reads stew.config.json and +// Stewfile.lock.json and feeds their contents to the pure core, exactly the +// way readPnpmShim hands shimTarget to detectFromPath. +func stewManaged(realPath string) bool { + bin, data := stewDirsFrom(readStewConfig(), os.Getenv, homeDir(), runtime.GOOS) + if data == "" { + return false + } + // stewBin is a root like the managerDirs ones, so it gets the same + // symlink expansion before it is compared against the resolved binary path. + return stewOwned(realPath, resolveDir(bin), readStewLock(data)) +} + +// stewOwned is the pure decision, and both halves are load-bearing. The lock +// file alone would claim any binary that shares a stew-managed name — the +// classic shadowing case where a cargo `rg` on PATH ahead of stew's `~/.local/ +// bin/rg` must not be offered `stew upgrade rg`. The path alone would claim +// every plain binary the user dropped into ~/.local/bin by hand, offering +// `stew upgrade ` for something stew does not manage (stew refuses with +// "binary not installed"). Together they say "this exact binary is stew's". +func stewOwned(realPath, stewBin, lockJSON string) bool { + if stewBin == "" || !underDir(realPath, stewBin) { + return false + } + return stewLockHas(lockJSON, binaryName(realPath)) +} + +// stewLockHas reports whether the lock file names a stew-managed package that +// installs the given binary. Only github-sourced packages are upgradable: +// source == "other" means the binary was installed from a URL, which `stew +// upgrade` refuses by design, so offering it here would be a command we know +// will fail — the honest degradation is to stay quiet. +func stewLockHas(lockJSON, binName string) bool { + if lockJSON == "" || binName == "" { + return false + } + var lock struct { + Packages []struct { + Source string `json:"source"` + Binary string `json:"binary"` + } `json:"packages"` + } + if err := json.Unmarshal([]byte(lockJSON), &lock); err != nil { + return false + } + for _, pkg := range lock.Packages { + if pkg.Binary == binName && pkg.Source == "github" { + return true + } + } + return false +} diff --git a/internal/updater/stew_test.go b/internal/updater/stew_test.go new file mode 100644 index 0000000..4c1778a --- /dev/null +++ b/internal/updater/stew_test.go @@ -0,0 +1,243 @@ +package updater + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// env returns a getenv stub that answers from vars (a nil map answers nothing), +// the shape the pure stew cores expect. +func env(vars map[string]string) func(string) string { + return func(k string) string { return vars[k] } +} + +var noEnv = env(nil) + +// TestStewDirsFrom pins the pure resolution of stew's two directories: config +// values win, stew's own defaults fill the gaps, and a malformed config is +// treated as no config at all. +func TestStewDirsFrom(t *testing.T) { + const home = "/home/tester" + xdgData := env(map[string]string{"XDG_DATA_HOME": "/xdg/data"}) + + tests := []struct { + name string + config string + home string + goos string + getenv func(string) string + wantBin string + wantData string + }{ + {name: "linux bare home", home: home, goos: "linux", getenv: noEnv, + wantBin: filepath.Join(home, ".local", "bin"), + wantData: filepath.Join(home, ".local", "share", "stew")}, + {name: "linux honors XDG_DATA_HOME", home: home, goos: "linux", getenv: xdgData, + wantBin: filepath.Join(home, ".local", "bin"), + wantData: filepath.Join("/xdg/data", "stew")}, + {name: "darwin same defaults as linux", home: home, goos: "darwin", getenv: noEnv, + wantBin: filepath.Join(home, ".local", "bin"), + wantData: filepath.Join(home, ".local", "share", "stew")}, + {name: "windows defaults", home: home, goos: "windows", getenv: noEnv, + wantBin: filepath.Join(home, "AppData", "Local", "stew", "bin"), + wantData: filepath.Join(home, "AppData", "Local", "stew")}, + {name: "config overrides both", config: `{"stewPath":"/cfg/data","stewBinPath":"/cfg/bin"}`, home: home, goos: "linux", getenv: noEnv, + wantBin: "/cfg/bin", wantData: "/cfg/data"}, + {name: "config overrides one field", config: `{"stewBinPath":"/cfg/bin"}`, home: home, goos: "windows", getenv: noEnv, + wantBin: "/cfg/bin", + wantData: filepath.Join(home, "AppData", "Local", "stew")}, + {name: "malformed config falls back to defaults", config: `{"stewBinPath":`, home: home, goos: "linux", getenv: noEnv, + wantBin: filepath.Join(home, ".local", "bin"), + wantData: filepath.Join(home, ".local", "share", "stew")}, + {name: "empty home disables defaults", home: "", goos: "linux", getenv: noEnv, + wantBin: "", wantData: ""}, + {name: "config values survive an empty home", config: `{"stewPath":"/cfg/data","stewBinPath":"/cfg/bin"}`, home: "", goos: "linux", getenv: noEnv, + wantBin: "/cfg/bin", wantData: "/cfg/data"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bin, data := stewDirsFrom(tt.config, tt.getenv, tt.home, tt.goos) + if bin != tt.wantBin || data != tt.wantData { + t.Errorf("stewDirsFrom() = (%q, %q), want (%q, %q)", bin, data, tt.wantBin, tt.wantData) + } + }) + } +} + +// TestStewConfigFilePath pins the config file location, stew's own resolution +// (GetStewConfigFilePath) rather than any convention of ours. +func TestStewConfigFilePath(t *testing.T) { + const home = "/home/tester" + + tests := []struct { + name string + home string + goos string + getenv func(string) string + want string + }{ + {name: "linux bare home", home: home, goos: "linux", getenv: noEnv, + want: filepath.Join(home, ".config", "stew", "stew.config.json")}, + {name: "linux honors XDG_CONFIG_HOME", home: home, goos: "darwin", getenv: env(map[string]string{"XDG_CONFIG_HOME": "/xdg/config"}), + want: filepath.Join("/xdg/config", "stew", "stew.config.json")}, + {name: "windows", home: home, goos: "windows", getenv: noEnv, + want: filepath.Join(home, "AppData", "Local", "stew", "Config", "stew.config.json")}, + {name: "empty home unix", home: "", goos: "linux", getenv: noEnv, want: ""}, + {name: "empty home windows", home: "", goos: "windows", getenv: noEnv, want: ""}, + { + // XDG_CONFIG_HOME is honored without a home too — stew reads it + // before touching the home dir at all. + name: "XDG without home", home: "", goos: "linux", getenv: env(map[string]string{"XDG_CONFIG_HOME": "/xdg/config"}), + want: filepath.Join("/xdg/config", "stew", "stew.config.json"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stewConfigFilePath(tt.getenv, tt.home, tt.goos); got != tt.want { + t.Errorf("stewConfigFilePath() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestStewLockHas pins the pure lock parser, including the two gates: the +// binary name must match and the source must be "github" — a URL-installed +// binary (source "other") is not upgradable by `stew upgrade`, so offering it +// would be a command we know will fail. +func TestStewLockHas(t *testing.T) { + lock := `{ + "os": "linux", + "arch": "amd64", + "packages": [ + {"source": "github", "owner": "sharkdp", "repo": "fd", "tag": "v10.2.0", "binary": "fd"}, + {"source": "github", "binary": "rg"}, + {"source": "other", "binary": "custom-bin"} + ] +}` + tests := []struct { + name string + lock string + bin string + want bool + }{ + {name: "github package", lock: lock, bin: "fd", want: true}, + {name: "second github package", lock: lock, bin: "rg", want: true}, + {name: "url-installed package is not upgradable", lock: lock, bin: "custom-bin", want: false}, + {name: "binary not in lock", lock: lock, bin: "nonexistent", want: false}, + {name: "empty lock", lock: "", bin: "fd", want: false}, + {name: "empty binary name", lock: lock, bin: "", want: false}, + {name: "malformed lock", lock: `{"packages":`, bin: "fd", want: false}, + { + // Keepkit never interpolates lock content into a command, so a + // hostile binary field is inert — but it must not match either. + name: "lookalike binary field", lock: `{"packages":[{"source":"github","binary":"fd & rm -rf /"}]}`, bin: "fd", want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stewLockHas(tt.lock, tt.bin); got != tt.want { + t.Errorf("stewLockHas(_, %q) = %v, want %v", tt.bin, got, tt.want) + } + }) + } +} + +// TestStewOwned pins the pure decision: both halves are required. The lock +// alone would claim a same-named binary shadowing stew's on PATH; the path +// alone would claim every hand-installed file in stew's (shared) default bin +// dir. +func TestStewOwned(t *testing.T) { + const home = "/home/tester" + bin := filepath.Join(home, ".local", "bin") + lock := `{"packages":[{"source":"github","binary":"fd"}]}` + shadowed := "/home/other/.cargo/bin/fd" + + tests := []struct { + name string + realPath string + stewBin string + lock string + want bool + }{ + {name: "under bin and in lock", realPath: filepath.Join(bin, "fd"), stewBin: bin, lock: lock, want: true}, + {name: "not under stew's bin", realPath: shadowed, stewBin: bin, lock: lock, want: false}, + {name: "under bin but not in lock", realPath: filepath.Join(bin, "rg"), stewBin: bin, lock: lock, want: false}, + {name: "empty stewBin disables the check", realPath: filepath.Join(bin, "fd"), stewBin: "", lock: lock, want: false}, + {name: "empty lock", realPath: filepath.Join(bin, "fd"), stewBin: bin, lock: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stewOwned(tt.realPath, tt.stewBin, tt.lock); got != tt.want { + t.Errorf("stewOwned() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestStewManaged pins the OS-facing wiring end to end: config + lock file are +// read, defaults resolve against a test home, and the two directories can be +// relocated via the config exactly as stew allows. +func TestStewManaged(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fixture relies on unix config/data env vars") + } + base := t.TempDir() + home := filepath.Join(base, "home") + mustMkdirAll(t, home) + setTestHomeDir(t, home) + + // Stew's defaults are what they would resolve to with no config file. + // The binary sits in the default bin dir and the lock in the default data + // dir, so only the file contents matter — no config to write. + defaultBin := filepath.Join(home, ".local", "bin") + defaultData := filepath.Join(home, ".local", "share", "stew") + mustMkdirAll(t, defaultBin) + mustMkdirAll(t, defaultData) + if err := os.WriteFile(filepath.Join(defaultData, "Stewfile.lock.json"), + []byte(`{"packages":[{"source":"github","binary":"fd"}]}`), 0o644); err != nil { + t.Fatal(err) + } + binPath := filepath.Join(defaultBin, "fd") + if err := os.WriteFile(binPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + + if !stewManaged(binPath) { + t.Error("stewManaged() = false for a binary in the default stew bin dir and lock") + } + if stewManaged(filepath.Join(defaultBin, "rg")) { + t.Error("stewManaged() = true for a binary not in the lock file") + } + if stewManaged(filepath.Join(base, "somewhere", "fd")) { + t.Error("stewManaged() = true for a binary outside stew's bin dir") + } + + // A relocated install: the config moves both directories and keepkit must + // follow them rather than its (or stew's) defaults. + customBin := filepath.Join(base, "custom", "bin") + customData := filepath.Join(base, "custom", "data") + mustMkdirAll(t, customBin) + mustMkdirAll(t, customData) + configDir := filepath.Join(home, ".config", "stew") + mustMkdirAll(t, configDir) + if err := os.WriteFile(filepath.Join(configDir, "stew.config.json"), + []byte(`{"stewPath":"`+customData+`","stewBinPath":"`+customBin+`"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(customData, "Stewfile.lock.json"), + []byte(`{"packages":[{"source":"github","binary":"rg"}]}`), 0o644); err != nil { + t.Fatal(err) + } + customPath := filepath.Join(customBin, "rg") + if err := os.WriteFile(customPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + if !stewManaged(customPath) { + t.Error("stewManaged() = false for a binary under the configured stew directories") + } + if stewManaged(binPath) { + t.Error("stewManaged() = true for a default-dir binary after the config moved the roots") + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index e58b88a..da47ddc 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -30,7 +30,7 @@ var ErrUnknownManager = errors.New("no known package manager for tool") // shell) except for the "custom" manager, where it is ["sh", "-c", ]. type Plan struct { // Manager names the detected package manager, in chain order: - // "brew" | "go" | "cargo" | "pipx" | "uv" | "pnpm" | "bun" | "npm" | "custom". + // "brew" | "stew" | "go" | "cargo" | "pipx" | "uv" | "pnpm" | "bun" | "npm" | "custom". Manager string Argv []string // e.g. ["brew", "upgrade", "ripgrep"] Display string // human-facing command shown in the confirm dialog @@ -228,6 +228,8 @@ func brewNamePlanAt(name, prefix string) (Plan, bool) { // sh -c (the user may write pipes or &&) and detection is skipped entirely. // 2. Otherwise the binary is located via LookPath + EvalSymlinks, Go buildinfo // is collected via `go version -m`, and the pair is fed to detectFromPath. +// stew's ownership is decided the same way — its config and lock file are +// read once, and the verdict (a bool) rides into the core beside buildinfo. // A cargo hit is refined with the real crate name from `cargo install // --list`. Helper failures degrade softly — a missing `go`/`cargo` just // leaves the corresponding signal empty, never aborting detection. @@ -271,7 +273,7 @@ func Detect(t loader.Tool) (Plan, error) { shimTarget = readPnpmShim(realPath) } - plan, err := detectFromPath(realPath, buildinfo, shimTarget, dirs) + plan, err := detectFromPath(realPath, buildinfo, shimTarget, stewManaged(realPath), dirs) if err != nil { // The chain can exhaust with the binary found: a Homebrew cask app keeps // its launcher on PATH while the executable lives inside the .app bundle @@ -429,47 +431,60 @@ var goPathRe = regexp.MustCompile(`(?m)^\s*path\s+(\S+)\s*$`) // detectFromPath is the pure detection core: given a binary's real (symlink // resolved) path, the output of `go version -m ` (may be empty), the pnpm -// cmd-shim target Detect read off that binary (empty when there is none) and -// the resolved manager directories, it returns the update Plan. It performs no -// I/O, reads no environment and spawns no subprocesses — every root arrives in -// dirs — so table tests need no real package managers installed. +// cmd-shim target Detect read off that binary (empty when there is none), +// whether stew's lock file names it (Detect's read; the verdict arrives as a +// bool) and the resolved manager directories, it returns the update Plan. It +// performs no I/O, reads no environment and spawns no subprocesses — every root +// and signal arrives in its arguments — so table tests need no real package +// managers installed. // -// Order matters twice over: brew is checked before go because a brew-installed -// Go binary carries buildinfo and would otherwise be misrouted to `go install`, -// and pnpm/bun are checked before npm because their layouts contain -// node_modules segments the npm step would otherwise claim. -func detectFromPath(realPath, buildinfo, shimTarget string, dirs managerDirs) (Plan, error) { +// Order matters twice over: brew and stew are checked before go because a +// brew/stew-installed Go binary carries buildinfo and would otherwise be +// misrouted to `go install`, and pnpm/bun are checked before npm because their +// layouts contain node_modules segments the npm step would otherwise claim. +func detectFromPath(realPath, buildinfo, shimTarget string, stewManaged bool, dirs managerDirs) (Plan, error) { // 1. Homebrew Cellar. if m := cellarRe.FindStringSubmatch(realPath); m != nil { formula := m[1] return autoPlan("brew", []string{"brew", "upgrade", formula}), nil } - // 2. Go buildinfo module path. + // 2. stew (/), before go for the buildinfo reason: a + // stew-built Go binary carries module metadata the go step would claim and + // reroute to `go install`, which installs a duplicate under ~/go/bin while + // the stew copy keeps shadowing it on PATH. The verdict is Detect's — it + // reads stew.config.json and Stewfile.lock.json — so the core only sees the + // bool; the binary name is the upgrade argument (stew upgrades by the + // installed binary's name, `stew upgrade rg`). + if stewManaged { + return autoPlan("stew", []string{"stew", "upgrade", binaryName(realPath)}), nil + } + + // 3. Go buildinfo module path. if m := goPathRe.FindStringSubmatch(buildinfo); m != nil { module := m[1] return autoPlan("go", []string{"go", "install", module + "@latest"}), nil } - // 3. Cargo (~/.cargo/bin). Crate name defaults to the binary name; the OS + // 4. Cargo (~/.cargo/bin). Crate name defaults to the binary name; the OS // wrapper refines it via `cargo install --list`. if underDir(realPath, dirs.cargoBin) { crate := binaryName(realPath) return autoPlan("cargo", []string{"cargo", "install", crate}), nil } - // 4. pipx (~/.local/pipx/venvs//...). + // 5. pipx (~/.local/pipx/venvs//...). if pkg := segmentUnder(realPath, dirs.pipxVenvs); pkg != "" { return autoPlan("pipx", []string{"pipx", "upgrade", pkg}), nil } - // 5. uv (//bin/, reached through a symlink in uv's own + // 6. uv (//bin/, reached through a symlink in uv's own // bin dir) — the same technique as pipx, one ecosystem tool over. if pkg := segmentUnder(realPath, dirs.uvTools); pkg != "" { return autoPlan("uv", []string{"uv", "tool", "upgrade", pkg}), nil } - // 6. pnpm globals, before npm because the store path carries node_modules + // 7. pnpm globals, before npm because the store path carries node_modules // segments the npm step would otherwise claim. The package name comes from // the shim target Detect read for us (pnpm >= 9) or, for the legacy symlink // layouts, from the resolved path itself. Neither yielding a name means this @@ -484,7 +499,7 @@ func detectFromPath(realPath, buildinfo, shimTarget string, dirs managerDirs) (P } } - // 7. bun globals ($BUN_INSTALL/bin/ symlinked into + // 8. bun globals ($BUN_INSTALL/bin/ symlinked into // install/global/node_modules//...). Before npm for the same reason as // pnpm, and not merely for tidiness: the npm step used to claim these and // offer `npm install -g `, installing a duplicate under npm's prefix. @@ -496,7 +511,7 @@ func detectFromPath(realPath, buildinfo, shimTarget string, dirs managerDirs) (P } } - // 8. npm global (.../node_modules//...). A path through pnpm's virtual + // 9. npm global (.../node_modules//...). A path through pnpm's virtual // store that got this far is a pnpm layout the pnpm step failed to // attribute — claiming it for npm would offer `npm install -g `, which // silently installs a second working copy under npm's prefix while the pnpm diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 29c04bf..e37683d 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -27,6 +27,7 @@ func TestDetectFromPath(t *testing.T) { realPath string buildinfo string shimTarget string + stewManaged bool dirs managerDirs wantManager string wantArgv []string @@ -38,6 +39,48 @@ func TestDetectFromPath(t *testing.T) { wantManager: "brew", wantArgv: []string{"brew", "upgrade", "ripgrep"}, }, + { + // stew's verdict is computed in Detect from its config + lock file; + // the core just sees the bool. The upgrade argument is the installed + // binary's name, which is what `stew upgrade` takes. + name: "stew-managed binary", + realPath: filepath.Join(home, ".local", "bin", "fd"), + stewManaged: true, + wantManager: "stew", + wantArgv: []string{"stew", "upgrade", "fd"}, + }, + { + // Ordering: a stew-built Go binary carries buildinfo, and with stew + // checked after go it resolved to `go install @latest` — the + // wrong manager, installing a duplicate under ~/go/bin while the stew + // copy keeps shadowing it on PATH. + name: "order: stew with buildinfo yields stew not go", + realPath: filepath.Join(home, ".local", "bin", "fd"), + buildinfo: goBuildinfo, + stewManaged: true, + wantManager: "stew", + wantArgv: []string{"stew", "upgrade", "fd"}, + }, + { + // The verdict is the signal: without it, the path goes to whatever + // the rest of the chain can prove. A Go binary here is still go's — + // stew's silence means stew has no claim, not that the chain must + // return nothing. + name: "stew verdict absent with buildinfo yields go", + realPath: filepath.Join(home, ".local", "bin", "fd"), + buildinfo: goBuildinfo, + wantManager: "go", + wantArgv: []string{"go", "install", "github.com/junegunn/fzf@latest"}, + }, + { + // The honest-degradation half: a plain binary the user dropped into + // ~/.local/bin by hand. stew's default bin dir is shared, and without + // the lock-file gate this would have claimed it and offered `stew + // upgrade fd` for something stew does not manage. + name: "stew verdict absent on a hand-installed binary", + realPath: filepath.Join(home, ".local", "bin", "fd"), + wantErr: ErrUnknownManager, + }, { name: "go buildinfo module", realPath: "/home/tester/go/bin/fzf", @@ -211,7 +254,7 @@ func TestDetectFromPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - plan, err := detectFromPath(tt.realPath, tt.buildinfo, tt.shimTarget, tt.dirs) + plan, err := detectFromPath(tt.realPath, tt.buildinfo, tt.shimTarget, tt.stewManaged, tt.dirs) if tt.wantErr != nil { if !errors.Is(err, tt.wantErr) { t.Fatalf("err = %v, want %v", err, tt.wantErr)