Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

CLI-First Workflow: Visual Studio to the Terminal

For .NET engineers who know: Visual Studio 2022, Solution Explorer, the Build menu, NuGet Package Manager, Team Explorer, SQL Server Management Studio You’ll learn: The terminal-based equivalents of every Visual Studio workflow our team uses, and how to become fluent in a CLI-first development environment Time: 15-20 min read

The .NET Way (What You Already Know)

Visual Studio is one of the most capable IDEs ever built. The Build menu compiles and runs. Solution Explorer shows the project tree. NuGet Package Manager resolves dependencies. Team Explorer (or the newer Git integration) handles branches and PRs. The integrated debugger sets breakpoints with a click. The test runner runs and visualizes tests. SSMS or the built-in data tools query the database. Everything is point-and-click, keyboard-shortcut-driven, and integrated.

This is a legitimately good development experience. The tradeoff is that it is opaque (you rarely know exactly what MSBuild is doing), hard to script (you cannot easily automate a sequence of IDE actions), and tied to a specific machine configuration (the workspace settings, extensions, and window layouts live on your machine).

Our stack is different: the primary interface is the terminal, and VS Code is a code editor that sits alongside it, not above it. This article is about making that transition comfortable.

The JS/TS Stack Way

Why CLI-First

The shift to terminal-first is not aesthetic preference. There are practical reasons:

Reproducibility. A command you type in the terminal can be put in a Makefile, a GitHub Actions workflow, a package.json script, or a team wiki. A sequence of clicks in a GUI cannot.

Speed. For common tasks — installing packages, running tests, checking git status, switching branches — the terminal is faster once muscle memory is established.

Portability. Every team member, every CI server, and every Docker container has a shell. Not everyone has Visual Studio.

Scriptability. Common sequences become one-line aliases. Multi-step setups become shell scripts. Repetitive tasks become automated.

The mental model shift: in Visual Studio, the GUI is the primary interface and the terminal is optional. In our workflow, the terminal is the primary interface and the GUI (VS Code) is the editor.

Essential CLI Tools and Their VS Equivalents

pnpm — The Package Manager (NuGet + dotnet CLI)

# Install all dependencies (like: right-click solution → Restore NuGet Packages)
pnpm install

# Add a package (like: NuGet Package Manager → Install)
pnpm add zod

# Add a dev dependency (like: NuGet Package Manager → Install, but marked PrivateAssets="all")
pnpm add -D vitest

# Remove a package
pnpm remove lodash

# Update a specific package
pnpm update zod

# Run a script defined in package.json (like: Build menu → Build/Run/Test)
pnpm dev         # Start development server
pnpm build       # Production build
pnpm test        # Run tests
pnpm lint        # ESLint check

# Add a global CLI tool (like: dotnet tool install -g)
pnpm add -g @anthropic/claude-code

# List installed packages
pnpm list

# Check for security vulnerabilities (like: Dependabot or OWASP check)
pnpm audit

gh — GitHub CLI (Team Explorer + Azure DevOps)

The gh CLI is the terminal interface to GitHub. For .NET engineers used to Team Explorer or Azure DevOps, this is the equivalent for every workflow that involves PRs, issues, and repository management.

# Install (macOS)
brew install gh

# Authenticate
gh auth login

# Create a PR for the current branch (like: Create Pull Request in Team Explorer)
gh pr create --title "feat: add invoice sending" --body "Closes #123"

# List open PRs
gh pr list

# View a specific PR
gh pr view 42

# Check out a PR locally (useful for reviewing)
gh pr checkout 42

# View PR status (CI checks, review status)
gh pr status

# Merge a PR (squash — our default)
gh pr merge 42 --squash

# List issues
gh issue list

# Create an issue
gh issue create --title "Bug: webhook 500 on retry" --body "..."

# View repository in browser
gh browse

# View CI run status (like: Azure Pipelines build status)
gh run list
gh run view [run-id]
gh run watch  # Live watch current CI run

gh removes the need to switch between terminal and browser for most PR and CI workflows. Use gh pr status before a standup to see what is waiting for your review.

git — Version Control (Team Explorer + git)

.NET engineers who use Team Explorer may not have deep git CLI fluency. These are the commands used daily:

# Status, staging, committing
git status
git add src/payments/payments.service.ts src/payments/payments.module.ts
git commit -m "feat: add webhook retry logic"

# Branching
git checkout -b feat/PROJ-123-add-webhook-retry
git checkout main
git branch --list
git branch -d feat/PROJ-123-add-webhook-retry  # Delete merged branch

# Syncing
git fetch origin
git pull origin main
git push origin feat/PROJ-123-add-webhook-retry

# Inspection
git log --oneline -20          # Last 20 commits, condensed
git diff                       # Unstaged changes
git diff --staged              # Staged changes (what will be committed)
git diff main...HEAD           # All changes since branching from main

# Stashing (like: shelving in TFS)
git stash
git stash pop
git stash list

psql — PostgreSQL Client (SQL Server Management Studio)

Our database is PostgreSQL. psql is the terminal client. It is less graphical than SSMS but more scriptable.

# Connect to a database
psql postgresql://user:password@localhost:5432/mydb

# Or using environment variable (set in .env)
psql $DATABASE_URL

# Inside psql:
\l           -- List databases (like: Object Explorer → Databases)
\c mydb      -- Switch to database
\dt          -- List tables (like: Object Explorer → Tables)
\d users     -- Describe table schema (like: right-click → Design)
\i file.sql  -- Execute a SQL file
\q           -- Quit

# Run a one-liner query without entering the REPL
psql $DATABASE_URL -c "SELECT count(*) FROM users;"

In practice, most database interaction goes through Prisma Studio for data browsing and Prisma migrations for schema changes. psql is for debugging, running ad-hoc queries, and scripted operations.

# Prisma equivalents for the most common SSMS tasks
pnpm prisma studio          # Open Prisma Studio (web UI, like SSMS table viewer)
pnpm prisma migrate dev     # Apply pending migrations (like: Update-Database)
pnpm prisma migrate status  # Check migration status (like: __EFMigrationsHistory)
pnpm prisma db pull         # Reverse-engineer schema from DB (like: Scaffold-DbContext)

docker — Containers (Visual Studio Container Tools)

Docker is used locally to run PostgreSQL and other services without installing them directly.

# Start the local development database
docker compose up -d

# Stop it
docker compose down

# View running containers
docker ps

# View logs from a specific container
docker logs my-postgres

# Open a shell inside a container
docker exec -it my-postgres bash

# Remove all stopped containers and unused images (periodic cleanup)
docker system prune

Our projects include a docker-compose.yml that defines the local development stack. docker compose up -d replaces “install and configure SQL Server Express” as the database setup step for new team members.

# docker-compose.yml — typical project setup
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp_dev
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: devpassword
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Shell Productivity

Aliases

Shell aliases reduce multi-word commands to single letters. Add these to your ~/.zshrc or ~/.bashrc:

# Git aliases
alias gs="git status"
alias ga="git add"
alias gc="git commit"
alias gp="git push"
alias gl="git log --oneline -20"
alias gd="git diff"
alias gds="git diff --staged"
alias gco="git checkout"
alias gcb="git checkout -b"

# pnpm aliases
alias pd="pnpm dev"
alias pb="pnpm build"
alias pt="pnpm test"
alias pl="pnpm lint"
alias pi="pnpm install"
alias pa="pnpm add"

# Navigation
alias ..="cd .."
alias ...="cd ../.."
alias ll="ls -la"

# Project-specific (customize per project)
alias dbstudio="pnpm prisma studio"
alias dbmigrate="pnpm prisma migrate dev"

After editing .zshrc, reload it:

source ~/.zshrc

Shell Functions

For multi-step operations, functions are more useful than aliases:

# Start a new feature branch with the right naming convention
# Usage: newfeat PROJ-123 add-webhook-retry
newfeat() {
  git checkout main
  git pull origin main
  git checkout -b "feat/$1-$2"
  echo "Created branch: feat/$1-$2"
}

# Create a PR from the current branch
# Usage: pr "Add webhook retry logic"
pr() {
  local branch=$(git rev-parse --abbrev-ref HEAD)
  gh pr create --title "$1" --body "" --draft
  echo "Draft PR created for branch: $branch"
}

# Clean up merged local branches
cleanup-branches() {
  git fetch --prune
  git branch --merged main | grep -v "^\* \|  main$" | xargs -r git branch -d
  echo "Cleaned up merged branches."
}

Add these to your ~/.zshrc after the aliases.

Zsh and Bash both support searching command history. The most efficient approach:

# In zsh: Ctrl+R opens an interactive history search
# Type any part of a previous command, press Ctrl+R to cycle through matches

# Or install fzf for fuzzy history search (highly recommended)
brew install fzf
# Follow the install instructions to enable fzf key bindings:
$(brew --prefix)/opt/fzf/install

# After fzf install: Ctrl+R opens a fuzzy-searchable history picker

The fzf integration for history search is one of the highest-leverage quality-of-life improvements available. Once installed, searching “prisma migrate dev” in history is a 3-keystroke operation.

Tab Completion

Zsh has excellent tab completion. For it to work well with tools like git, gh, and pnpm:

# Install Oh My Zsh (optional but useful — sets up completions automatically)
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

# Or manually: enable zsh completions in ~/.zshrc
autoload -Uz compinit
compinit

# pnpm tab completion (add to ~/.zshrc)
[[ -f ~/.config/tabtab/__tabtab.zsh ]] && . ~/.config/tabtab/__tabtab.zsh

Tab completion for git shows branch names. Tab completion for gh shows subcommands. Tab completion for pnpm shows scripts from package.json. These alone eliminate a significant amount of typing.

Terminal Multiplexing with tmux

When working on a feature, you typically need multiple terminal panes open simultaneously: one for the dev server, one for running commands, one for logs. Instead of multiple terminal windows, use tmux (terminal multiplexer).

# Install
brew install tmux

# Start a new named session
tmux new -s myproject

# Key bindings (Ctrl+B is the prefix key):
Ctrl+B, c         # Create new window (like a new tab)
Ctrl+B, ,         # Rename current window
Ctrl+B, n         # Next window
Ctrl+B, p         # Previous window
Ctrl+B, %         # Split pane vertically
Ctrl+B, "         # Split pane horizontally
Ctrl+B, arrow     # Move between panes
Ctrl+B, z         # Zoom current pane (full screen toggle)
Ctrl+B, d         # Detach from session (leaves it running)

# Reattach to a detached session
tmux attach -t myproject

# List active sessions
tmux ls

A typical tmux layout for our stack:

+----------------------------------+
| pnpm dev (NestJS dev server)     |
+------------------+---------------+
| git / gh / pnpm  | docker logs   |
+------------------+---------------+

You leave pnpm dev running in the top pane all day, and use the bottom panes for everything else. When you detach (Ctrl+B, d) and reattach later, the dev server is still running exactly where you left it.

VS Code Terminal Integration

VS Code has an integrated terminal (Ctrl+`). For most work, the integrated terminal and the external terminal are interchangeable. The integrated terminal has one advantage: it opens at the workspace root automatically.

Useful VS Code terminal features for our workflow:

# Split the terminal (like tmux but in VS Code)
# Terminal → Split Terminal (or Ctrl+Shift+5 on Mac)

# Run a task from package.json without typing in the terminal
# Terminal → Run Task → select from package.json scripts

# Open a new terminal and run a command
# Ctrl+Shift+P → "Create New Integrated Terminal"

One common point of confusion: VS Code’s terminal inherits the shell environment from when VS Code was launched, not from your current .zshrc state. If you add an alias to .zshrc and it does not appear in VS Code’s terminal, restart VS Code or open a fresh terminal tab.

Here is a minimal but effective ~/.zshrc for our stack:

# ~/.zshrc

# ---- Node Version Manager ----
# Use fnm (fast node manager) instead of nvm — same concept, much faster
# Install: curl -fsSL https://fnm.vercel.app/install | bash
eval "$(fnm env --use-on-cd)"

# ---- pnpm ----
export PNPM_HOME="$HOME/Library/pnpm"
export PATH="$PNPM_HOME:$PATH"

# ---- Aliases ----
alias gs="git status"
alias ga="git add"
alias gp="git push"
alias gl="git log --oneline -20"
alias gd="git diff"
alias gds="git diff --staged"
alias gco="git checkout"
alias gcb="git checkout -b"
alias pd="pnpm dev"
alias pt="pnpm test"
alias pl="pnpm lint"
alias ll="ls -la"

# ---- Functions ----
newfeat() {
  git checkout main && git pull origin main && git checkout -b "feat/$1-$2"
}

# ---- History ----
HISTSIZE=10000
SAVEHIST=10000
setopt SHARE_HISTORY
setopt HIST_IGNORE_DUPS

# ---- Completions ----
autoload -Uz compinit
compinit

# ---- fzf (fuzzy history search) ----
[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh

Key Differences

TaskVisual StudioOur CLI Workflow
Build projectBuild menu → Build Solutionpnpm build
Run with hot reloadDebug → Start Debugging (or F5)pnpm dev
Run testsTest Explorer → Run Allpnpm test
Install a packageNuGet Package Manager GUIpnpm add [package]
Create a branchTeam Explorer → Branchesgit checkout -b feat/...
Create a PRTeam Explorer → Pull Requestsgh pr create
View PR statusAzure DevOps browsergh pr status
Query the databaseSSMS GUIpsql $DATABASE_URL or Prisma Studio
Run a migrationPM Console: Update-Databasepnpm prisma migrate dev
Start a containerDocker Desktop GUIdocker compose up -d
View container logsDocker Desktop GUIdocker logs [name]
Run multiple terminalsMultiple VS windowstmux panes

Gotchas for .NET Engineers

Gotcha 1: There Is No Build Button — Every Step Is Explicit

In Visual Studio, pressing F5 implicitly compiles, resolves references, starts the app, and attaches the debugger. Our stack does not have an equivalent one-button experience. pnpm dev starts the development server with hot reload — but if dependencies are not installed, you get an error. If a migration is missing, the app starts but database calls fail. If environment variables are not set, the app crashes at startup.

The discipline required: when starting work on a project, follow a checklist before running pnpm dev:

pnpm install                    # Ensure dependencies are current
docker compose up -d            # Ensure the database is running
pnpm prisma migrate dev         # Ensure migrations are applied
cp .env.example .env            # Ensure .env exists (first time)
pnpm dev                        # Now start the server

This checklist is what Visual Studio’s F5 used to do invisibly. Knowing the steps explicitly is actually better — it makes the system understandable and reproducible.

Gotcha 2: Shell State Does Not Persist Between Sessions

Environment variables set with export VAR=value in a terminal session are gone when you close the terminal. Configuration that should persist goes in ~/.zshrc or .env files. This catches .NET engineers who are used to system-level environment variables (set via System Properties on Windows) persisting indefinitely.

The pattern:

  • Project-specific variables go in .env (committed as .env.example, never committed with real values)
  • Tool configuration goes in ~/.zshrc (aliases, PATH modifications)
  • Secret values go in .env files or your shell profile, never in package.json scripts

If a command works in one terminal but not another, check whether you sourced ~/.zshrc after a recent change.

Gotcha 3: Command Availability Depends on PATH

On Windows with Visual Studio, tools are available globally after installation because the installer modifies the system PATH. On macOS/Linux, a tool installed via pnpm add -g or brew install is only available in terminals where the relevant directories are in your PATH. If pnpm is not in your PATH, pnpm: command not found. If gh is not in your PATH after a brew install, you need to restart your terminal or source your shell config.

The diagnostic: which gh, which pnpm, which node. If the command returns a path, the tool is available. If it returns nothing, it is either not installed or not in PATH.

Gotcha 4: Git CLI Requires Explicit Staging

Visual Studio’s Git integration shows modified files and commits them in a GUI. The git CLI requires explicitly staging files with git add before committing. This trips up engineers who run git commit -m "message" and get a message like “nothing to commit, working tree clean” — because the files are modified but not staged.

The workflow:

git status          # See what is modified (equivalent to Team Explorer's Changes view)
git diff            # See what changed (equivalent to the diff view)
git add src/file.ts # Stage a specific file
git add -p          # Interactively stage hunks (very useful for partial commits)
git commit -m "..."

git add -p (patch mode) lets you review and selectively stage parts of a file — useful when you have multiple logical changes in one file and want to commit them separately. There is no equivalent in most GUI tools.

Gotcha 5: pnpm Scripts Are Not Global — They Run in Context

When you run pnpm test in a monorepo root, it runs the test script defined in the root package.json. When you run it inside a package directory, it runs that package’s test script. The behavior changes depending on where your terminal’s working directory is. This is different from MSBuild, which builds the entire solution regardless of where you invoke it.

If pnpm test seems to do nothing, or runs the wrong tests, check pwd to confirm you are in the right directory.

Hands-On Exercise

Complete this setup on your machine. Every step is a CLI command — do not use any GUIs.

Step 1: Install the tools

# Package manager
brew install pnpm

# GitHub CLI
brew install gh

# Node version manager (fnm)
curl -fsSL https://fnm.vercel.app/install | bash

# tmux
brew install tmux

# fzf
brew install fzf
$(brew --prefix)/opt/fzf/install

# Authenticate with GitHub
gh auth login

Step 2: Configure your shell

Add the aliases, functions, and tool initializations from the “Recommended Shell Configuration” section above to your ~/.zshrc. Reload it:

source ~/.zshrc

Verify aliases work:

gs        # Should run git status
ll        # Should run ls -la

Step 3: Clone a project and start it with CLI only

# Clone a project you work on
gh repo clone [org/repo]
cd [repo]

# Install dependencies
pnpm install

# Start the database
docker compose up -d

# Apply migrations
pnpm prisma migrate dev

# Start the dev server
pnpm dev

Step 4: Create a tmux session

tmux new -s dev

# In the new session, split into panes:
# Top pane: pnpm dev (already running)
# Bottom left: for git and gh commands
# Bottom right: for docker logs

Practice navigating between panes with Ctrl+B, arrow and detaching/reattaching with Ctrl+B, d and tmux attach -t dev.

Step 5: Practice the PR workflow entirely in the terminal

git checkout -b feat/cli-practice
# Make a trivial change (add a comment to any file)
git add [file]
git commit -m "chore: cli workflow practice"
git push origin feat/cli-practice
gh pr create --title "CLI practice PR" --body "Practice only, do not merge" --draft
gh pr view --web  # Open in browser to verify it worked
gh pr close $(gh pr list --json number --jq '.[0].number')  # Close it
git checkout main
git branch -d feat/cli-practice

Quick Reference

First-Day CLI Cheat Sheet

TaskCommand
Install dependenciespnpm install
Start dev serverpnpm dev
Run testspnpm test
Run tests in watch modepnpm test --watch
Lint codepnpm lint
Build for productionpnpm build
Start databasedocker compose up -d
Apply migrationspnpm prisma migrate dev
Open Prisma Studiopnpm prisma studio
Git statusgit status
Create feature branchgit checkout -b feat/PROJ-123-description
Stage and commitgit add [files] && git commit -m "message"
Push branchgit push origin [branch-name]
Create PRgh pr create
Check PR statusgh pr status
View CI runsgh run list
Watch current CI rungh run watch
Connect to databasepsql $DATABASE_URL
Container logsdocker logs [container-name]

Essential Tool Summary

.NET / VS ToolCLI EquivalentInstall
NuGet Package Managerpnpm add [pkg]brew install pnpm
dotnet CLIpnpm [script](with pnpm)
Team Explorer / Gitgit + ghbrew install gh
SQL Server Management Studiopsql / Prisma Studiobrew install libpq
Docker Desktopdocker CLIbrew install --cask docker
Azure DevOps browsergh pr / gh run(with gh)
Multiple VS windowstmuxbrew install tmux
History searchfzf (Ctrl+R)brew install fzf

Further Reading