Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,44 @@ Key invariants:
## Updating a tool (`u`)

`updater.Detect` identifies the manager from the installed binary — the chain is
brew → go → cargo → pipx → npm (order matters: brew before go, so a brew-installed
Go binary is not misrouted to `go install`). `update_cmd` from `meta.yaml` always
wins and runs via `sh -c`. Detection spawns subprocesses, so it runs as a `tea.Cmd`,
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 <pkg>`, 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()`.

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
`$BUN_INSTALL` with per-platform defaults, plus home-derived `~/.cargo/bin` and
`~/.local/pipx/venvs` (no `$CARGO_HOME`/`$PIPX_HOME` — unchanged behaviour, and a
separate question from path resolution). Carrying all five is what lets
`detectFromPath` stop calling `homeDir()`, so its "no I/O, no environment" contract
holds literally. An empty field switches that step off, which is what makes the zero
value backwards compatible. That check lives inside `underDir`/`segmentUnder`, which
answer "no match" for an empty dir, rather than in a `!= ""` guard repeated at every
step: `filepath.Rel("", "bin/exa")` succeeds, so a relative path would otherwise read
as living under every disabled root.

The wrapper then expands symlinks in every root (`resolveDir`, keeping the raw path
when it does not resolve). This is load-bearing: `Detect` matches these roots against
a symlink-*resolved* binary path, so a root reached through a symlink — a relocated
`~/.bun`, a home on a secondary volume, `/home` under autofs — never matches. uv and
pnpm's shim/store layouts then merely lose the update offer, but bun and legacy pnpm
globals keep a plain `node_modules/<pkg>` segment, so npm claims them and offers the
duplicate install this chain exists to prevent.

pnpm needs a second signal: its
global bins are cmd-shim shell scripts, not symlinks, so `Detect` does a bounded
best-effort read of the binary (only under `$PNPM_HOME`) and passes the
`# cmd-shim-target=` path into the core exactly as `go version -m` output rides in.
A file over the 8 KiB cap is rejected whole rather than parsed truncated — a cut
inside the marker line shortens the path, and a shortened package name is a
confidently wrong update command rather than the honest degradation everything
else in the chain falls back to.

When the tool has no binary of its own on PATH, one fallback runs before
`ErrUnknownManager`: a `Cellar/<name>`/`Caskroom/<name>` directory means brew owns
the name, so the plan is `brew upgrade <name>`. That is the `rust` case — the
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ Pure TUI, no subcommands; the only flags are `--version` and `--help`.
and cargo fallbacks for tools that won't answer `--version`), the latest release
comes from GitHub; outdated tools are marked `↑` and gathered at the top of the list
- **Update from inside the TUI** — one key detects the package manager
(brew / go / cargo / pipx / npm, or `update_cmd` from `meta.yaml`), asks for
(brew / go / cargo / pipx / uv / pnpm / bun / npm, or `update_cmd` from
`meta.yaml`), asks for
confirmation and streams the command output into panel `[3]` in real time
- **Self-update** — when a newer keepkit release exists, the status bar offers it;
after the update one more key restarts keepkit in place, in the same terminal tab
Expand Down Expand Up @@ -141,15 +142,45 @@ on the tool card. keepkit detects the package manager the binary was installed w
- `go` — buildinfo (`go version -m`) with a `path` field → `go install <module>@latest`;
- `cargo` — a binary in `~/.cargo/bin` → `cargo install <crate>`;
- `pipx` — a venv in `~/.local/pipx/venvs/<pkg>/` → `pipx upgrade <pkg>`;
- `uv` — a tool under `$UV_TOOL_DIR` (default `~/.local/share/uv/tools/<pkg>/`) →
`uv tool upgrade <pkg>`;
- `pnpm` — a global under `$PNPM_HOME` (default `~/Library/pnpm` on macOS,
`~/.local/share/pnpm` on Linux) → `pnpm add -g <pkg>`;
- `bun` — a global under `$BUN_INSTALL` (default `~/.bun`) → `bun add -g <pkg>`;
- `npm` — a global `node_modules/<pkg>` → `npm install -g <pkg>`.

pnpm and bun are checked before npm on purpose: both keep their globals behind
`node_modules` paths, so npm would otherwise claim them and offer
`npm install -g <pkg>` — a duplicate copy under npm's prefix while the original keeps
shadowing it on `PATH`. `add -g` rather than `update -g` for both, because `update`
respects the range recorded at install time and can quietly refuse the major bump the
card is offering.

If the binary cannot be attributed to any of those — or the tool has no binary of
its own on `PATH` at all — one more check runs before giving up: a Homebrew keg or
cask named after the tool → `brew upgrade <name>`. That covers formulae whose
binaries are named differently (`rust` installs `rustc` and `cargo`, so there is no
`rust` binary to detect from) and cask apps whose executable lives inside the `.app`
bundle, where the path itself names no manager.

Two limits are worth knowing. Detection for uv, pnpm, bun and npm reads path
conventions, so on **Windows** their bins are `.cmd` shims keepkit does not parse —
a pre-existing npm limitation the three new managers inherit; on macOS and Linux
pnpm's shims are `#!/bin/sh` scripts, which keepkit does read. And a manager's **own**
binary is out of scope: `bun upgrade`, `pnpm self-update` and friends are not offered,
those tools update themselves. A layout no check recognises normally degrades to the
same `update_cmd` hint as an unknown manager.

One case does not, and it is worth knowing: pnpm and bun globals sit behind
`node_modules` paths, so if keepkit cannot work out where their root is, the path
still looks like an ordinary npm install and the offer becomes
`npm install -g <package>`. Running it would install a second copy under npm's
prefix while the original keeps shadowing it on `PATH`. keepkit reads `$PNPM_HOME`
and `$BUN_INSTALL` (and the platform defaults) and expands symlinks in them, so this
only bites when the root is somewhere the environment does not name — a store moved
by a config file, or a session started without your shell profile. Export the
variable, or set `update_cmd` on the tool, and the right manager is picked.

The command is shown in the status bar for confirmation; its output streams into
panel `[3] Update` in real time and the TUI stays responsive. After a successful
update the version is re-detected and the `↑` marker disappears. One update runs at
Expand Down
Loading
Loading