WSL2 / Ubuntu → a full-stack TypeScript workstation.
Fresh WSL Ubuntu that still can’t find node over SSH, silently breaks pnpm,
and never starts an ssh-agent? This fixes that. Two scripts, no framework,
idempotent, and CI-tested on Ubuntu 24.04 and 26.04.
- PATH works outside interactive bash — SSH sessions, cron, and scripts get
Node/Rust without sourcing
nvm.sh - pnpm is installed standalone — avoids corepack refusing the semver range
that
pnpm initwrites - Global npm installs land in the nvm tree — even when Windows Node on
/mnt/cis earlier inPATH - ssh-agent liveness is correct on WSL — no
kill -0 0false positive .wslconfigomitssparseVhd— avoids a known corruption risk- CI provisions real Ubuntu containers and asserts idempotency, not just lint
- Ubuntu 24.04 or 26.04 (WSL2 or bare metal)
- A normal user account with
sudo - Network access for apt and toolchain downloads
git clone https://github.com/chowjiaming/wsl-dev-bootstrap.git
cd wsl-dev-bootstrap
./install.shYou'll be prompted for a git identity and an SSH key passphrase. For an unattended run, set the identity up front:
GIT_NAME="Ada Lovelace" GIT_EMAIL="ada@example.com" ./install.shEither stage can be run alone with ./install.sh --system or
./install.sh --user.
Afterwards, add the printed public key to GitHub twice — once as an Authentication key and once as a Signing key. They are separate entries, and signatures show as Unverified if you only add the first:
gh auth login
gh ssh-key add ~/.ssh/id_ed25519.pub --type authentication
gh ssh-key add ~/.ssh/id_ed25519.pub --type signingOn WSL, run wsl --shutdown from Windows once provisioning finishes. Docker group
membership only applies to new logins.
| Area | Contents |
|---|---|
| Toolchains | Node via nvm, pnpm, Rust via rustup, Go, Python pip/venv/uv |
| Containers | Docker Engine, Compose and Buildx, usable without sudo |
| Database | PostgreSQL client (psql, libpq-dev) |
| CLI | git, gh, jq, fzf, ripgrep, fd, bat, tmux, htop, tree |
| Build | build-essential, pkg-config, libssl-dev |
And it configures git (with SSH commit signing), an ed25519 SSH key, a global
gitignore, and bash: a git-aware prompt, shared history, fzf key bindings, and
.nvmrc auto-switching.
Pinned versions live in versions.env.
For: developers who want a repeatable Ubuntu/WSL TypeScript workstation and are happy to fork and tweak an opinionated baseline.
Not for: multi-OS fleets, non-Ubuntu distros, or a full configuration- management platform. Fork it; don’t expect every preference to be a flag.
install.sh entrypoint; runs the two stages
system.sh apt packages, Docker, Go (needs sudo)
user.sh Node, Rust, uv, git, ssh, shell (no sudo)
versions.env pinned toolchain versions
lib/common.sh logging, platform detection, symlinking
shell/path.sh PATH for every shell context (POSIX sh)
shell/dev.sh interactive bash configuration
git/ignore global gitignore
windows/ example .wslconfig for tuning the WSL VM
docs/assets/ social preview and demo media
test/assertions.sh verifies a provisioned machine
test/container-install.sh full provision inside a throwaway container
user.sh symlinks shell/ and git/ignore into ~/.config, so editing a file
here takes effect in new shells immediately. Any pre-existing file is moved to
*.backup.<timestamp> rather than overwritten.
Several of these exist because the naive version is quietly broken.
PATH lives in ~/.profile, not just ~/.bashrc. nvm and rustup only write
to interactive startup files, so ssh host 'pnpm build', cron jobs and systemd
units get a shell without Node. shell/path.sh is sourced from both, and derives
Node's directory by reading nvm's alias/default rather than sourcing nvm.sh,
which is bash-only and exists mainly to define the nvm function that scripts
don't need.
PATH is deduplicated at the end. Ubuntu's stock ~/.profile sources
.bashrc first and then prepends ~/.local/bin unconditionally, so any dedupe
that runs earlier is defeated. Entries are split by string slicing rather than
word splitting, because under WSL PATH inherits Windows directories containing
spaces and parentheses.
pnpm is installed standalone, not through corepack. pnpm init writes a
semver range into devEngines.packageManager; corepack demands an exact version
and refuses, which breaks pnpm add in every newly created project. Corepack is
also being unbundled from Node.
npm install -g is run through the nvm bin directory explicitly. npm derives
its global prefix from whichever node executes it, not from where npm itself
lives. nvm use leaves an existing PATH entry in place rather than moving it to
the front, so if another node is already earlier in PATH — Windows node via
/mnt/c, a distro nodejs, an editor's bundled runtime — that node wins, and
npm install -g pnpm lands outside the nvm tree. The install reports success and
pnpm is simply missing from every later shell. user.sh therefore pins the nvm
bin directory before installing, and test/assertions.sh checks the invariant
against a deliberately planted decoy node.
Package availability is checked by candidate version. apt-cache show exits 0
for a package that exists but has no installable version, so it can't be used to
test installability.
The ssh-agent liveness check doesn't use kill -0 "${PID:-0}". PID 0 means
the current process group, so that test always succeeds and the agent never
starts. The agent is also spawned with stdin, stdout and stderr detached — it
outlives the shell, and an inherited pipe blocks anything reading from it forever.
sparseVhd is deliberately absent from the example .wslconfig. WSL gates it
behind --allow-unsafe because of unresolved reports of corruption affecting both
the virtual disk and the host NTFS volume
(microsoft/WSL#10609). Reclaim
space with sudo fstrim -av plus a diskpart compaction instead; see
windows/wslconfig.example.
shellcheck --severity=warning install.sh system.sh user.sh lib/*.sh shell/*.sh test/*.sh
./test/container-install.sh 26.04CI runs the same shellcheck, then provisions Ubuntu 24.04 and 26.04 containers and
runs test/assertions.sh against each. The assertions cover toolchain resolution
in a non-interactive login shell, absence of duplicate PATH entries, symlink
placement, git configuration, a real pnpm add, and idempotency — a second run
must leave ~/.profile and ~/.bashrc byte-identical.
A container has no init system, so CI cannot verify that the Docker daemon starts; it verifies installation and configuration only.
This is opinionated by design. The intended way to use it is to fork it and edit:
change the package list in system.sh, the versions in versions.env, and the
aliases and prompt in shell/dev.sh.
See CONTRIBUTING.md for bug reports and pull requests.
