Skip to content

About

Idempotent Ubuntu/WSL2 bootstrap for a full-stack TypeScript workstation — Node, Docker, Rust, Go, CI-tested

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

wsl-dev-bootstrap

ci Ubuntu 24.04 Ubuntu 26.04 shellcheck License: MIT

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.

Demo: clone, install, verify toolchains

Why this, not a gist or Ansible

  • 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 init writes
  • Global npm installs land in the nvm tree — even when Windows Node on /mnt/c is earlier in PATH
  • ssh-agent liveness is correct on WSL — no kill -0 0 false positive
  • .wslconfig omits sparseVhd — avoids a known corruption risk
  • CI provisions real Ubuntu containers and asserts idempotency, not just lint

Requirements

  • Ubuntu 24.04 or 26.04 (WSL2 or bare metal)
  • A normal user account with sudo
  • Network access for apt and toolchain downloads

Install

git clone https://github.com/chowjiaming/wsl-dev-bootstrap.git
cd wsl-dev-bootstrap
./install.sh

You'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.sh

Either 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 signing

On WSL, run wsl --shutdown from Windows once provisioning finishes. Docker group membership only applies to new logins.

What it installs

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.

Who this is for

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.

Layout

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.

Notes on decisions that aren't obvious

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.

Development

shellcheck --severity=warning install.sh system.sh user.sh lib/*.sh shell/*.sh test/*.sh
./test/container-install.sh 26.04

CI 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.

Forking

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.

License

MIT

About

Idempotent Ubuntu/WSL2 bootstrap for a full-stack TypeScript workstation — Node, Docker, Rust, Go, CI-tested

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages