Last updated: 2026-05-19
dev-setup provides a single-command, idempotent developer environment setup for:
- GitHub Codespaces
- Dev Containers
- Fresh Linux, macOS, or Windows machines
Run bash setup.sh (Unix) or powershell -File setup.ps1 (Windows) and walk away. Every tool this project installs is safe to re-install -- the scripts check first and skip if already present.
dev-setup/
|---- setup.sh # Entry point -- Unix (Linux / macOS / WSL); thin router
|---- setup.ps1 # Entry point -- Windows (PowerShell); thin router
|---- .tool-versions # asdf-style pinned versions (node, nvm, uv, gh, copilot-cli)
|---- .gitattributes # eol=lf for *.sh / *.md / *.yml; eol=crlf for *.ps1 / *.psm1 / *.psd1
|---- ARCHITECTURE.md # This file
|---- CHANGELOG.md # Keep-a-Changelog format
|---- CONTRIBUTING.md # Contribution guide
|---- README.md # Project overview and quick start
|
|---- scripts/
| |---- lib/ # Cross-platform shared libraries (PS + sh)
| | |---- Read-ToolVersion.ps1 # Get-ToolVersion -Name X -- reads pin from .tool-versions
| | `---- read-tool-version.sh # Same contract for POSIX shells (prints version to stdout)
| |
| |---- linux/
| | |---- setup.sh # Core Linux/macOS/WSL installer -- runs tools in order
| | |---- uninstall.sh # Idempotent reverse of the installer
| | |---- lib/
| | | `---- log.sh # Shared log_info / log_ok / log_warn / log_error helpers
| | `---- tools/ # Per-tool installers (sourced by core in dependency order)
| | |---- auth.sh # GitHub CLI authentication (interactive)
| | |---- copilot-cli.sh # Install GitHub Copilot CLI (pin from .tool-versions)
| | |---- gh.sh # Install GitHub CLI (pin from .tool-versions)
| | |---- nvm.sh # Install nvm + Node (pin from .tool-versions)
| | |---- uv.sh # Install uv Python package manager (pin from .tool-versions)
| | `---- zsh.sh # Install zsh + set as default shell
| |
| `---- windows/
| |---- setup.ps1 # Orchestrator -- dot-sources lib + tool modules below
| |---- uninstall.ps1 # Idempotent reverse of the installer
| |---- lib/
| | |---- logging.ps1 # Write-Info / Write-Ok / Write-Warn / Write-Err + Assert-LastExit
| | `---- path.ps1 # Refresh-SessionPath -- re-reads Machine+User PATH from registry
| `---- tools/ # Per-tool installers (orchestrator + 10 modules)
| |---- auth.ps1 # GitHub CLI authentication (interactive)
| |---- copilot.ps1 # GitHub Copilot CLI (pin from .tool-versions)
| |---- dotfiles.ps1 # Apply config/dotfiles/ on Windows
| |---- gh.ps1 # GitHub CLI (pin from .tool-versions)
| |---- git.ps1 # Git configuration
| |---- nvm.ps1 # nvm-windows + Node (pin from .tool-versions)
| |---- profile.ps1 # PowerShell profile injection (PS 5.1 + PS 7+ paths)
| |---- psmux.ps1 # psmux terminal multiplexer (Windows tmux alias)
| |---- uv.ps1 # uv Python package manager (pin from .tool-versions)
| `---- vim.ps1 # Vim editor
|
|---- config/
| `---- dotfiles/ # Dotfile templates
| |---- .aliases # Shell aliases (git, dev, utility)
| |---- .editorconfig # Editor formatting rules
| |---- .gitconfig.template # Git config template
| |---- .npmrc.template # npm config template
| |---- .vimrc # Vim configuration
| |---- .zshrc.template # Zsh config template
| |---- install.sh # Dotfile installer script
| `---- README.md # Documents each dotfile and install behaviour
|
|---- hooks/ # Git hooks; auto-wired via `git config core.hooksPath hooks`
| |---- pre-commit # Branch ancestry + ASCII guard + shellcheck
| |---- prepare-commit-msg # Rewrite auto-merge/revert messages into Conventional Commits form
| |---- commit-msg # Enforce Conventional Commits format (hard reject on non-conforming)
| `---- pre-push # Block direct pushes to main; advisory shellcheck + PSScriptAnalyzer
|
|---- tests/ # Validation tests
| |---- README.md # Test documentation
| |---- test_alias_parity.sh # Linux/Windows alias parity test
| |---- test_aliases.sh # Alias loading tests (bash)
| |---- test_git_hooks.ps1 # Git hook tests (PowerShell)
| |---- test_idempotency.sh # Idempotency tests (bash)
| |---- test_nvm_bootstrap.sh # nvm bootstrap tests
| |---- test_precommit_hygiene.sh # pre-commit hygiene checks (ancestry, ASCII, rogue-path)
| |---- test_remove_custom_item.ps1 # Custom item removal tests (PowerShell)
| |---- test_shared_logging.sh # scripts/linux/lib/log.sh contract tests
| |---- test_tool_versions.sh # .tool-versions parser + Get-ToolVersion contract tests
| `---- test_windows_setup.ps1 # Windows setup tests (PowerShell)
|
|---- .devcontainer/
| |---- devcontainer.json # Dev Container / Codespace config
| `---- README.md # Dev container documentation
|
|---- .github/
| `---- workflows/ # CI workflows
| |---- validate.yml # Main CI validation (6 jobs)
| |---- e2e-install.yml # E2E smoke test on fresh runners (PR + nightly cron + summary)
| `---- sprint-end-labels.yml # Sprint label automation
|
This is the only file a Linux/macOS/WSL user needs to know about. It:
- Detects the OS via
uname -sand/proc/version - Logs what it found
- Delegates to
scripts/linux/setup.sh
It does not install anything itself. It is a thin router.
bash setup.sh
# or, after chmod +x:
./setup.shThe only file a Windows user needs to know about. It:
- Detects the platform via PowerShell's
$IsWindows/$IsLinux/$IsMacOS - Delegates to
scripts\windows\setup.ps1
scripts\windows\setup.ps1 is a small orchestrator that dot-sources shared libraries (lib\logging.ps1, lib\path.ps1) and per-tool installers from scripts\windows\tools\ (split from a 451-line monolith in PR #195 into an orchestrator + 10 per-tool modules + a lib/ of shared helpers).
powershell -ExecutionPolicy Bypass -File setup.ps1uname -s output -> Platform label
-----------------------------------------------------
Linux + /proc/version -> "wsl" (Windows Subsystem for Linux)
contains "microsoft"
Linux (otherwise) -> "linux"
Darwin -> "macos"
CYGWIN* / MINGW* / MSYS* -> "windows-compat" (warn + try linux path)
* -> "unknown" (error + exit 1)
WSL is treated as Linux. The root setup.sh routes WSL to scripts/linux/setup.sh, not to the Windows path. This is intentional: WSL users have a full Linux environment and benefit from the same tooling as native Linux.
Uses PowerShell's built-in $IsWindows, $IsLinux, $IsMacOS booleans. If PowerShell is running inside WSL (edge case), it routes to the Windows installer with a warning.
Shared helpers live in dedicated lib/ directories. Tool scripts load them rather than redefining or copy-pasting. Source of truth:
| File | Purpose | Loaded by |
|---|---|---|
scripts/linux/lib/log.sh |
log_info, log_ok, log_warn, log_error |
setup.sh and every tools/*.sh |
scripts/windows/lib/logging.ps1 |
Write-Info, Write-Ok, Write-Warn, Write-Err, Assert-LastExit |
setup.ps1 and every tools/*.ps1 |
scripts/windows/lib/path.ps1 |
Refresh-SessionPath (re-reads Machine + User PATH from the registry into the session) |
setup.ps1 and any tool that mutates PATH |
scripts/lib/read-tool-version.sh |
POSIX parser for .tool-versions (prints the pinned version to stdout) |
Any tools/*.sh that needs a pinned version |
scripts/lib/Read-ToolVersion.ps1 |
PowerShell Get-ToolVersion -Name <tool> (returns the pinned version) |
Any tools/*.ps1 that needs a pinned version |
Rule: New helpers go in the appropriate lib/ directory. Do not copy helper definitions into setup.sh, setup.ps1, or individual tool scripts.
Bash uses POSIX source (.). At the top of setup.sh or any tools/*.sh, after the safety flags:
# From scripts/linux/setup.sh (lib is one level down):
. "$(dirname "${BASH_SOURCE[0]}")/lib/log.sh"
# From scripts/linux/tools/<tool>.sh (lib is one level up):
. "$(dirname "${BASH_SOURCE[0]}")/../lib/log.sh"Note that setup.sh runs each tools/*.sh via bash <script> (a subshell), so every tool script must re-source lib/log.sh itself; the parent scope is not inherited.
PowerShell uses dot-sourcing (.). At the top of setup.ps1 or any tools/*.ps1, after Set-StrictMode / $ErrorActionPreference:
# From scripts/windows/setup.ps1 (lib is alongside):
. "$PSScriptRoot\lib\logging.ps1"
. "$PSScriptRoot\lib\path.ps1"
# From scripts/windows/tools/<tool>.ps1 (lib is one level up):
. "$PSScriptRoot\..\lib\logging.ps1"$PSScriptRoot is the directory of the currently-executing file. Unlike the bash path, setup.ps1 dot-sources each tools/*.ps1, so tool functions (Install-Nvm, Install-GhCli, ...) live in the parent scope and are invoked by name from Main. Tool scripts still re-dot-source any lib/ files they need so they are also runnable standalone.
.tool-versions is the single source of truth for tool versions (see "Tool Version Pinning" below). Tool scripts must read pins via the shared parsers in scripts/lib/; they must not hard-code versions.
Bash: invoke the POSIX script and capture stdout. From scripts/linux/tools/<tool>.sh:
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PINNED_NODE="$(sh "${SCRIPT_DIR}/../../lib/read-tool-version.sh" nodejs)"The parser walks up two levels to find the repo root, so the path from scripts/linux/tools/ is ../../lib/read-tool-version.sh. It exits non-zero if the tool is missing or .tool-versions is not found; set -euo pipefail will surface either.
PowerShell: dot-source the parser, then call Get-ToolVersion. From scripts/windows/tools/<tool>.ps1:
$libDir = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'lib'
. (Join-Path $libDir 'Read-ToolVersion.ps1')
$pinnedNode = Get-ToolVersion -Name 'nodejs'Two Split-Path -Parent calls climb tools/ -> windows/ -> scripts/, then lib reaches the shared parser. Get-ToolVersion throws on missing tool or missing .tool-versions, which surfaces under $ErrorActionPreference = 'Stop'.
Reference implementations: scripts/linux/tools/nvm.sh and scripts/windows/tools/nvm.ps1.
| Convention | Rule |
|---|---|
| Shebang | #!/usr/bin/env bash |
| Safety flags | set -euo pipefail at top of every script |
| Idempotency | Check command -v <tool> before installing; skip if present |
| Logging | Source scripts/linux/lib/log.sh; call log_info, log_ok, log_warn, log_error |
| Version pinning | Read from .tool-versions via sh scripts/lib/read-tool-version.sh <tool>; never hard-code versions |
| Sourcing | setup.sh runs each tools/*.sh via bash <script> (subshell); tool scripts re-source lib/log.sh themselves |
| Exit codes | exit 0 on success or skip, exit 1 on unrecoverable error |
| Convention | Rule |
|---|---|
| Safety | Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' |
| Idempotency | Get-Command <tool> -ErrorAction SilentlyContinue before installing |
| Logging | Dot-source scripts/windows/lib/logging.ps1; call Write-Info, Write-Ok, Write-Warn, Write-Err |
| Exit-code discipline | After any external install, call Assert-LastExit -ToolName <name> (use -AllowedExitCodes for cases like winget ALREADY_INSTALLED) |
| PATH refresh | After an install mutates PATH, dot-source scripts/windows/lib/path.ps1 and call Refresh-SessionPath so node, uv, gh, etc. become callable in the same session |
| Version pinning | Read from .tool-versions via Get-ToolVersion (dot-source scripts/lib/Read-ToolVersion.ps1); never hard-code versions |
| Install method | Prefer winget; fall back to scoop or direct download (see nvm.ps1 for the portable-zip pattern) |
| Sourcing | setup.ps1 dot-sources each tools/*.ps1; tool functions live in the parent scope and are invoked by name. Tool scripts re-dot-source their own lib/ files so they remain runnable standalone. |
| Profile injection | Write-PowerShellProfile writes aliases to both PS 5.1 (Documents\WindowsPowerShell\) and PS 7+ (Documents\PowerShell\) paths; sentinel strip+re-inject makes it idempotent |
| Alias registration | All Set-Alias calls use -Force -Scope Global so aliases work in the current session immediately |
-
Create
scripts/linux/tools/<toolname>.sh#!/usr/bin/env bash # scripts/linux/tools/<toolname>.sh -- Install <toolname> set -euo pipefail log_info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; } log_ok() { printf '\033[0;32m[OK]\033[0m %s\n' "$*"; } if command -v <toolname> &>/dev/null; then log_ok "<toolname> already installed: $(<toolname> --version)" exit 0 fi # Install logic here
-
Add a
run_tool "<toolname>"call inscripts/linux/setup.shrun_tool "toolname" -
Create a companion GitHub issue if it's a new tool install.
- Create a directory under
scripts/<platform>/ - Create a
setup.sh(orsetup.ps1) in that directory -- this is the platform's core installer - Add a detection branch in the root
setup.shand/orsetup.ps1 - Document the platform in this file
The tool scripts in scripts/linux/tools/ must run in this order (enforced by scripts/linux/setup.sh):
zsh -> uv -> nvm -> gh -> auth -> copilot-cli
copilot-cli depends on gh being installed and (ideally) authenticated. The auth script handles interactive GitHub CLI authentication.
The Windows orchestrator scripts/windows/setup.ps1 is a thin router: it dot-sources two shared libraries first (lib/logging.ps1 -> lib/path.ps1), then dot-sources every per-tool module under scripts/windows/tools/ so their Install-* functions are defined. Dot-source order does not drive dependencies -- the authoritative install order is the call sequence inside the Main function. The chain is fixed at:
git -> uv -> nvm -> gh -> auth -> vim -> psmux -> copilot -> dotfiles -> profile -> hooks
Mapped to functions and the tools/*.ps1 module that defines each:
| # | Function called by Main |
Source module | Mirrors Linux step |
|---|---|---|---|
| 1 | Install-Git |
tools/git.ps1 |
(Linux: pre-installed / package manager) |
| 2 | Install-Uv |
tools/uv.ps1 |
tools/uv.sh |
| 3 | Install-Nvm |
tools/nvm.ps1 |
tools/nvm.sh |
| 4 | Install-GhCli |
tools/gh.ps1 |
tools/gh.sh |
| 5 | Invoke-GhAuth |
tools/auth.ps1 |
tools/auth.sh |
| 6 | Install-Vim |
tools/vim.ps1 |
(Linux: pre-installed / package manager) |
| 7 | Install-Psmux |
tools/psmux.ps1 |
(Linux: tmux already on PATH) |
| 8 | Install-CopilotCli |
tools/copilot.ps1 |
tools/copilot-cli.sh |
| 9 | Install-Dotfiles |
tools/dotfiles.ps1 |
config/dotfiles/install.sh (driven from tools/zsh.sh) |
| 10 | Write-PowerShellProfile |
tools/profile.ps1 |
(Linux: shell-rc work folded into tools/zsh.sh) |
| 11 | Install-GitHook |
inline in setup.ps1 |
git config core.hooksPath hooks (same contract) |
Cross-platform invariants preserved from the Linux chain above:
auth(interactivegh auth login) runs afterghso the CLI is on PATH when the prompt fires.copilotruns afterauthso the install can detect an authenticatedghsession.
Windows-only additions vs. the Linux chain:
gitruns first -- Windows ships without git, and every downstream step that shells out togit(auth, dotfiles, hooks) needs it on PATH.vimandpsmuxare explicitwingetinstalls because Windows has no equivalent pre-installed editor/multiplexer.dotfiles+profileare Windows-specific finalizers: the Linux side rolls equivalent shell-rc work intotools/zsh.shplusconfig/dotfiles/install.sh, but Windows needs a discrete PowerShell profile injection step (PS 5.1 + PS 7+ profile paths) after the dotfile templates are applied.Install-GitHookis an inline function insidesetup.ps1(not a separatetools/*.ps1module), wired last socore.hooksPath=hooksis set only after the working tree is in its final state.
Every script in this project must be safe to run multiple times. The pattern is:
if <already installed check>; then
log_ok "<tool> already installed"
exit 0
fi
# installThis means running bash setup.sh on a fully-configured machine is a no-op.
Tool versions are pinned in the repo-root .tool-versions file (asdf-style: name<space>version, one per line). Both the Linux and Windows installers read pins through a shared library so the same version is installed across all platforms:
scripts/lib/Read-ToolVersion.ps1-- exposesGet-ToolVersion -Name <toolname>(PowerShell)scripts/lib/read-tool-version.sh-- same contract for POSIX shells (prints to stdout)
Currently pinned: nodejs, nvm, nvm-windows, uv, copilot-cli, gh. Tool installers (e.g. scripts/windows/tools/nvm.ps1, scripts/linux/tools/uv.sh) call the library at install time so version bumps are a single-file edit.
Hooks live in hooks/ and are wired automatically by the installers via git config core.hooksPath hooks (no manual install step). Four hooks ship today:
| Hook | Role |
|---|---|
pre-commit |
Branch-ancestry guard (feature branches must descend from develop), ASCII-only enforcement for staged *.ps1, refusal to commit on develop/main/master, shellcheck on staged *.sh |
prepare-commit-msg |
Rewrites git auto-generated Merge ... and Revert "..." messages into Conventional Commits form so commit-msg accepts them |
commit-msg |
Enforces Conventional Commits format (type(scope): description). Hard reject on non-conforming. |
pre-push |
Blocks direct pushes to main; runs shellcheck on changed *.sh (advisory) and PSScriptAnalyzer on changed *.ps1 (advisory) |
All workflows live in .github/workflows/.
| Job | Runner | Purpose |
|---|---|---|
validate-linux |
ubuntu-latest |
Run setup.sh, assert zsh/uv/nvm/node/gh, idempotency re-run, alias unit + parity tests |
validate-macos |
macos-latest |
Same shape as validate-linux + tool-version pin tests |
lint-shell-scripts |
ubuntu-latest |
shellcheck across setup.sh, scripts/linux/**, config/dotfiles/.aliases |
lint-powershell |
ubuntu-latest (pwsh) |
PSScriptAnalyzer across setup.ps1 + scripts/windows/setup.ps1 |
validate-powershell |
windows-latest |
Remove-CustomItem regression + git-hooks tests under PS 7 |
validate-ps51 |
windows-latest |
Syntax + PSScriptAnalyzer + profile-write + git-hooks tests under PS 5.1 (Windows stock) |
| Job | Runner | Purpose |
|---|---|---|
e2e-linux |
ubuntu-latest |
Run setup.sh on a fresh runner; assert every tool is reachable from a login shell |
e2e-macos |
macos-latest |
Same shape as e2e-linux |
e2e-windows |
windows-latest |
Run setup.ps1; PowerShell + winget path |
summary |
ubuntu-latest |
Aggregates the three platform results (needs: [...], if: always()) and fails the workflow if any platform failed |
Initially continue-on-error: true per platform job; the summary job is the single fail-gate. Triggers: pull_request, nightly cron: 0 4 * * *, and workflow_dispatch.