Install / update dev tools (uv, Go, nvm)
Goal
Keep three developer tools current, installed per-user (no sudo), each fetched from its official source — and placed so that their unbounded caches don't threaten the root filesystem.
| Tool | What it is | Official source |
|---|---|---|
uv |
Python package & project manager | https://astral.sh/uv/install.sh |
go |
Go toolchain | https://go.dev/dl/go<ver>.<os>-<arch>.tar.gz |
nvm |
Node Version Manager | https://github.com/nvm-sh/nvm install.sh (pinned tag) |
This skill manages nvm itself, not Node — run nvm install --lts afterward.
Why layout matters (read this first)
On these machines $HOME is a bind mount of a subdirectory on the same
partition as /. df ~ and df / print two lines that look independent while
reporting one device, so it's easy to miss. The consequence: anything unbounded
landing in $HOME fills /, and a full root filesystem wedges the machine —
systemd, journald and /tmp all lose the ability to write. This has actually
happened to the user.
Go's module cache and nvm's Node versions are exactly that kind of growth:
| Path | Growth | Self-cleaning? |
|---|---|---|
$GOPATH/pkg/mod |
unbounded; 1–2G from one build of a cloud-SDK project | No — Go never evicts module sources |
$GOCACHE (~/.cache/go-build) |
large but bounded | Yes, trims entries unused ~5 days |
$NVM_DIR/versions/node/* |
~200M per Node version | No |
~/.npm |
npm's own cache; ignores XDG and the ~/.cache symlink |
No |
So scripts/lib.sh resolves an install prefix: $DEV_TOOLS_PREFIX if set,
else the first writable candidate (/local-ssd/$USER, /scratch/$USER,
/local/$USER) that is on a different filesystem than /, else $HOME/.local.
Being a separate filesystem is the whole criterion — a roomier directory on the
root partition wouldn't protect /. That gives two modes:
volume mode |
home mode |
|
|---|---|---|
| go | $PREFIX/opt/go |
~/.local/go |
| uv, uvx | $PREFIX/bin |
~/.local/bin |
| nvm | $PREFIX/nvm |
~/.nvm |
| caches | $PREFIX/cache |
~/.cache |
| GOPATH | $PREFIX/go |
~/go |
Every path is overridable from the environment, and an already-exported value wins — so a machine configured by a previous run is detected as configured, not reinstalled somewhere new.
The core rule
Audit first, then ask before changing anything. The audit is read-only. Each tool is installed or moved only after the user confirms that specific tool.
Workflow
1. Audit (read-only, always safe)
bash scripts/audit.sh
Reports the filesystem situation (whether $HOME shares /, free space, the
resolved prefix and every install target), then installed vs. latest official
version per tool with a verdict: missing, update-available, up-to-date, or
unknown (couldn't resolve latest — offline or a proxy blocking the GitHub API).
--tsv gives machine-readable rows (name<TAB>installed<TAB>latest<TAB>verdict).
Run this in a login shell (bash -lic 'bash scripts/audit.sh'). A
non-login shell may carry a stale environment snapshot that lacks the user's PATH
and NVM_DIR, making installed tools look missing.
2. Report
Show the table. Lead with the filesystem finding if $HOME shares / — that's
usually news to the user and it's what justifies the layout. Then call out which
tools are missing or update-available, with current → target versions.
3. Ask per tool
For each tool needing work, ask before touching it. Do not batch-upgrade. Skip
anything up-to-date. If unknown, say the latest couldn't be resolved and skip
unless the user gives an explicit --version.
4. Install (only what the user approved)
bash scripts/install-tool.sh <uv|go|nvm> # latest official
bash scripts/install-tool.sh go --version 1.23.4 # a specific version
bash scripts/install-tool.sh uv --dry-run # preview, no changes
The script prints a plan line, prompts for itself, and honors --yes when the
user already confirmed in conversation.
5. Wire up caches and the shell rc
bash scripts/setup-env.sh # show the plan, change nothing
bash scripts/setup-env.sh --apply # do it, prompting per change
This makes ~/.cache a symlink to the prefix, appends a marker-delimited block
to the shell rc (~/.bashrc.mine if present, else ~/.bashrc), and checks for rc
lines that would override it. Backs up the rc first; idempotent — re-running
replaces its own block rather than duplicating it.
6. Verify in a new login shell
bash -lic 'go env GOROOT GOPATH GOMODCACHE GOCACHE; command -v uv; echo $NVM_DIR; nvm ls'
Then prove it end-to-end: build a trivial Go program and confirm the cache landed
on the prefix and nothing appeared in $HOME.
Notes & gotchas
- Never export
GOROOT. The go binary derives it from its own real path, resolving symlinks correctly — verified through a/usr/local/binsymlink. A hardcodedGOROOTsilently breaks builds the moment the toolchain moves, which is exactly how a previous setup went stale. - Getting
goonto PATH. Either add$GO_INSTALL_DIR/bin, orsudo ln -s $GO_INSTALL_DIR/bin/{go,gofmt} /usr/local/bin/. Symlinkgofmttoo — editors andgo vetshell out to it. - Move nvm, don't reinstall it.
install-tool.sh nvmdetects an existing~/.nvmthat belongs on the prefix and offers tomvit. A freshinstall.shinto a newNVM_DIRyields an empty nvm: installed Node versions and thedefault -> lts/*alias live in the old directory and get abandoned. Moving is safe — Node binaries are path-independent and global packages use#!/usr/bin/env nodeshebangs. Check first that nothing is running on that Node. - Installer-appended rc lines can silently win. The uv and nvm installers
append their setup to the end of
~/.bashrc. When the managed block lives in a file that~/.bashrcsources (the usual~/.bashrc.minearrangement), those appended lines run afterwards and override it. nvm's hardcodesNVM_DIR="$HOME/.nvm". The symptom is nasty:nodeandnpmkeep working because PATH was already set, whileNVM_DIRpoints at a directory that may not exist — nothing looks wrong until the nextnvm install.setup-env.shchecks for this; re-check after any reinstall. - Symlink
~/.cache, don't just setXDG_CACHE_HOME. Plenty of tools hardcode~/.cacheand ignore the variable, and the symlink covers tools installed in future with no further configuration. - Cache contents are never copied. They're regenerable by definition, so
setup-env.shmoves an existing~/.cacheaside to~/.cache.oldand leaves it for the user to delete. - Don't run
rm -rf. It's blocked in this user's permission setup. Move things aside and hand them a!-prefixed command to delete. - Gate rc blocks on the volume existing, never on
hostname. A hostname test protects the one machine it was written for and fails silently on every other — which is how an earlier[ "$(hostname)" = '<some-host>' ]block ended up doing nothing on a second machine for months, leavingGOROOT/GOPATHunset there while looking correct in the file. - Official sources only. No third-party mirrors, no distro package managers.
- No sudo for installs; only the optional
/usr/local/binsymlinks need it. - Go upgrades replace the install dir wholesale per Go's official guidance —
it does not merge over an old tree. Only
$GO_INSTALL_DIRis touched. - Offline / proxied. If latest can't be resolved the audit shows
unknown(the GitHub API is often blocked whilego.devandastral.shstill work); pass--versionto install a known version explicitly. - Environment overrides:
DEV_TOOLS_PREFIX,GO_INSTALL_DIR,UV_INSTALL_DIR,NVM_DIR,DEV_CACHE_DIR,DEV_GOPATH.