The terminal without fear
Every developer tool has buttons, but its full power is in the terminal. Engineers type Git, npm and Node commands all day. This lesson covers the twenty or so commands and four ideas behind almost everything they type, on both Windows and macOS.
By the end you can
- Move around folders with absolute and relative paths without getting lost
- Read any command as program, flags and arguments, and use its exit code
- Explain why "command not found" happens, and fix it by reading PATH
- Chain small commands with pipes and redirection to answer a real question about a CSV
- Use environment variables, run scripts and
npx, and delete files safely
The everyday picture
At head office, you can ask the stock team for a report by filling a form in the portal. You pick from dropdowns, press Submit, and get exactly what the form allows. Or you can walk to the store manager and say: "Show me the Riverside store's rice sales since Monday, only the 5 kg bags, sorted by day." You get the answer in one sentence, as long as you know how to ask.
A graphical app is the form. The terminal is talking to the manager directly. It feels harder at first, because there are no dropdowns to remind you of the options. But every request is short, exact and repeatable, and you can write it down and give it to a teammate, or to a robot like GitHub Actions, which has no mouse at all.
Terminal, shell and prompt
Three words get mixed up. The terminal is the window. The shell is the program inside it that reads your command and runs it. The prompt is the text the shell prints when it is ready, such as PS C:\Users\you> or you@mac ~ %.
macOS uses zsh by default. Linux servers usually use bash. zsh and bash take the same commands for everything in this course. Windows has PowerShell, which is powerful but different: its commands pass objects, not text, and the names differ (Get-ChildItem instead of ls). Git for Windows also installs Git Bash, a bash shell for Windows.
This course's rule: on Windows, use Git Bash for Git and npm work, so you type the same commands as your teammates on macOS and as GitHub Actions on Linux. Where PowerShell is common, this lesson shows it too. To make Git Bash the default in VS Code, run Terminal: Select Default Profile from the Command Palette and choose Git Bash.
Where am I? Paths and the current folder
A shell is always "standing" in one folder, the current directory. pwd prints it, cd changes it, ls lists what is in it. Everything you type with a file name is looked up from there.
An absolute path starts at the top: /Users/you/work on macOS, C:\Users\you\work on Windows (Git Bash writes it /c/Users/you/work). It means the same place no matter where you stand. A relative path starts from where you are. Three shortcuts make relative paths useful: . means "this folder", .. means "one folder up", and ~ means "my home folder".
Two habits prevent most confusion. First, run pwd when an error says a file does not exist. Usually the file exists and you are standing in the wrong folder. Second, avoid spaces in folder names (store-report, not Store Report). A space splits a name into two arguments unless you put quotes around it.
Quick check
You are standing in /Users/you/work/store-report/scripts. Which relative path reaches reports/s014.csv inside store-report?
.. goes up from scripts to store-report, then reports/ goes down. ./reports would look for a reports folder inside scripts, and ~ starts at your home folder, /Users/you, which has no reports folder.
scripts, the relative path ../data/sales.csv means: go up one level to store-report, then down into data. The absolute path /Users/you/work/store-report/data/sales.csv reaches the same file from anywhere.Reading any command: program, flags, arguments, exit code
Every command has the same shape. Take git log --oneline -n 5 main. The first word, git, is the program. Words starting with dashes are flags, options that change its behaviour: --oneline (long form, two dashes) and -n 5 (short form, one dash, with a value). The rest are arguments, the things it works on: main. Short flags can often be joined: ls -la is ls -l -a. When unsure, add --help.
When a program finishes, it returns an exit code. Zero means success. Anything else is failure, and some numbers have meanings: 127 is "command not found", 126 is "found but not allowed to run". You rarely see the exit code, but everything automatic depends on it. && runs the next command only if the previous one succeeded, || only if it failed. A CI job in GitHub Actions fails when any step's command exits with a non-zero code (GitHub Actions: continuous integration). A pre-commit hook that exits non-zero stops the commit (VS Code for teams, later in this course). In bash or zsh, echo $? prints the last exit code; in PowerShell, use $LASTEXITCODE.
Quick check
A data export script prints "ERROR: file missing" but ends with exit code 0. What does a CI job running it do?
Automation reads only the exit code, never the words. Exit code 0 means success, so CI shows green while the export is broken. A script must exit 1 (or another non-zero number) when it fails.
How the shell finds a program: PATH
When you type node, how does the shell know where Node is? It reads an environment variable called PATH, a list of folders separated by : (macOS, Linux, Git Bash) or ; (Windows). It checks each folder in order and runs the first match. If no folder has it, you get command not found (bash, zsh) or "is not recognized as a name of a cmdlet" (PowerShell).
That one rule explains three common problems. A tool you just installed is "not found" because your open terminal read PATH when it started, before the installer changed it; open a new one. A tool runs the wrong version because an older copy sits in a folder earlier in the list; which -a node (bash, zsh) or Get-Command node -All (PowerShell) shows every match in order. And a tool installed by npm into a project is "not found" because node_modules/.bin is not on PATH at all. That last one is what npx and npm scripts solve, as you will see in the Build it section.
Environment variables also carry settings into programs. Node reads them through process.env. A variable set with export (bash, zsh) or $env:NAME = 'value' (PowerShell) lasts until you close that terminal, and every program started from it inherits a copy. Writing NAME=value command on one line sets it for that one command only. This is how the same partner-api code connects to a test database on a laptop and the real one on the server: same code, different environment.
Quick check
which -a node prints two paths, one under /opt/homebrew/bin and one under /usr/local/bin. When you type node, which one runs?
The shell checks PATH folders in order and stops at the first match. It never compares versions. To make another copy win, remove the first one or put its folder earlier in PATH.
PATH folder in order and run the first match. A tool installed into the project lives in node_modules/.bin, which is not on PATH. npx and npm scripts add that folder for you, so the project's own version runs.Small tools, joined by pipes
Unix-style shells give you many tiny programs that each do one job: head shows the first lines, tail -n +2 skips the header line, cut -d, -f1 takes the first comma-separated column, sort sorts, uniq -c counts repeated lines, grep keeps lines that match, wc -l counts lines. A pipe, written |, feeds one program's output into the next. Together they answer questions no single tool can.
Redirection sends output to a file instead of the screen: > replaces the file, >> appends to it. Programs have two output streams. Normal results go to stdout, error messages to stderr, so an error does not end up inside your report file. 2> errors.log sends only the errors to a file.
PowerShell pipes objects instead of text. Import-Csv turns each row into an object with named fields, so you can group and sum without counting columns. The PowerShell version is often longer to type and easier to read.
Speed and safety habits
Press Tab to complete a file or folder name; press it twice to see the options. Press Up to bring back earlier commands, and Ctrl+R in bash or zsh to search your history (PowerShell has the same with PSReadLine). Ctrl+C stops the running command, which you will use for every dev server. On macOS this one really is Ctrl, not Cmd.
Deleting from the terminal skips the bin. rm file deletes a file, rm -r folder deletes a folder and everything inside, and rm -rf does it without asking and without complaining. A space in the wrong place turns rm -rf ./build into rm -rf . /build, which deletes the current folder. Before any rm -r, run pwd, then ls the exact path you are about to delete. In PowerShell, Remove-Item -Recurse accepts -WhatIf, which prints what it would delete and deletes nothing. If the files were committed, Git can bring them back (Undo anything, in Git from the Inside). Uncommitted work is gone.
Build it
Build a small store report folder, then answer real questions about a sales CSV with pipes, environment variables, a script and PATH. Outputs are from zsh on macOS; Git Bash prints the same results, though its error messages are worded a little differently and start with bash: instead of zsh:. PowerShell versions are in the tabs. The Practice section has a path exercise you can try in your browser, too.
-
Create the folder structure
mkdir -pcreates folders, including missing parent folders, and does not complain if they already exist.Terminalpwd mkdir -p ~/work/store-report/data ~/work/store-report/scripts ~/work/store-report/reports cd ~/work/store-report lsPowerShellGet-Location New-Item -ItemType Directory -Force -Path ~/work/store-report/data, ~/work/store-report/scripts, ~/work/store-report/reports | Out-Null Set-Location ~/work/store-report Get-ChildItem -NameOutput/Users/you data reports scripts -
Add a sales file and walk around with relative paths
In VS Code, create
data/sales.csvwith these lines (amounts in cents). Then practise moving with..and coming back with~.OutputsiteId,articleCode,qty,amountCents S-014,A-1001,2,1700 S-014,A-2040,3,1137 S-014,A-0007,1,300 S-102,A-1001,1,850 S-102,A-3311,4,1000 S-014,A-3311,2,500Terminalcd data pwd cd ../scripts pwd cd ~/work/store-report head -n 3 data/sales.csvOutput/Users/you/work/store-report/data /Users/you/work/store-report/scripts siteId,articleCode,qty,amountCents S-014,A-1001,2,1700 S-014,A-2040,3,1137Windows has the same idea with different names. PowerShell has
ls,cp,mv,rmandcatas aliases for its own commands, so simple uses work. Flags do not:Remove-Item -rffails with "A parameter cannot be found that matches parameter name 'rf'". Another reason to use Git Bash for this course. -
Copy, rename and delete safely
Terminalcp data/sales.csv reports/sales-copy.csv mv reports/sales-copy.csv reports/sales-2026-10-07.csv ls reports rm reports/sales-2026-10-07.csvOutputsales-2026-10-07.csvmvboth moves and renames.rmprinted nothing, which in the shell means it worked. -
Answer questions with pipes
How many lines (including the header)? How many lines per store? Read the pipeline left to right: skip the header, keep column 1, sort so equal stores sit together, count each group.
Terminalwc -l data/sales.csv tail -n +2 data/sales.csv | cut -d, -f1 | sort | uniq -c grep '^S-014' data/sales.csv > reports/s014.csv # keep S-014 lines in a file awk -F, '$1 == "S-014" { sum += $4 } END { print sum }' data/sales.csvPowerShell(Get-Content data/sales.csv).Count Import-Csv data/sales.csv | Group-Object siteId -NoElement | Format-Table Count, Name Select-String -Path data/sales.csv -Pattern '^S-014' | ForEach-Object Line | Set-Content reports/s014.csv (Import-Csv data/sales.csv | Where-Object siteId -eq 'S-014' | Measure-Object amountCents -Sum).SumOutput7 data/sales.csv 4 S-014 2 S-102 3637S-014 sold 3,637 cents, which is $36.37. The
grepline printed nothing because>sent its output into the file. -
Set and read an environment variable
Set
STORE_IDfor the session, then once for a single command, and read it from Node.Terminalexport STORE_ID=S-014 node -e 'console.log("Store from env:", process.env.STORE_ID)' STORE_ID=S-102 node -e 'console.log(process.env.STORE_ID)' # this command only echo $STORE_IDPowerShell$env:STORE_ID = 'S-014' node -e "console.log('Store from env:', process.env.STORE_ID)" # PowerShell has no one-command form: set it, run, then put the old value back $env:STORE_ID = 'S-102'; node -e "console.log(process.env.STORE_ID)"; $env:STORE_ID = 'S-014' $env:STORE_IDOutputStore from env: S-014 S-102 S-014 -
Write a script that fails properly
Create
scripts/count-sales.sh. It takes a file name as its first argument, writes errors to stderr and exits with 1 on failure, so other tools can tell that it failed.Terminal#!/usr/bin/env bash # count-sales.sh: print how many sale lines each store has in a CSV file file="${1:-data/sales.csv}" # first argument, or this default if [ ! -f "$file" ]; then echo "count-sales: no such file: $file" >&2 # >&2 sends the message to stderr exit 1 # non-zero exit code = failure fi tail -n +2 "$file" | cut -d, -f1 | sort | uniq -cThe first line, called a shebang, tells the system to run the file with bash. Then run it three ways:
Terminal./scripts/count-sales.sh; echo "exit code: $?" chmod +x scripts/count-sales.sh # allow it to run as a program ./scripts/count-sales.sh; echo "exit code: $?" ./scripts/count-sales.sh data/nope.csv && echo "report ready" || echo "report failed"Outputzsh: permission denied: ./scripts/count-sales.sh exit code: 126 4 S-014 2 S-102 exit code: 0 count-sales: no such file: data/nope.csv report failedOn Windows, Git Bash usually treats any file that starts with
#!as runnable, sochmodmakes no difference there. To mark it executable for teammates on macOS and Linux, Git hasgit update-index --chmod=+x, which you will meet after What Git really is, the first lesson of Git from the Inside. -
Cause and fix "command not found"
Typing the script's name alone fails, because its folder is not on PATH. Print PATH one folder per line, add the folder, and try again.
Terminalcount-sales.sh echo $PATH | tr ':' '\n' export PATH="$HOME/work/store-report/scripts:$PATH" which count-sales.shOutputzsh: command not found: count-sales.sh /opt/homebrew/bin /usr/local/bin /usr/bin /bin /usr/sbin /sbin /Users/you/work/store-report/scripts/count-sales.shThis change lasts until you close the terminal. To keep it, add the
exportline to~/.zshrc(macOS) or~/.bashrc(Git Bash). In PowerShell,$env:PATH -split ';'prints the list, and the permanent setting is under "Edit environment variables for your account" in Windows settings. Restart the terminal after changing it. -
Run project tools with npx and npm scripts
Install Prettier into a project. It lands in
node_modules/.bin, which is not on PATH, so the bare name fails, whilenpxand npm scripts find it. On Windows, run this in Git Bash:/dev/nulland&&are bash syntax.Terminalmkdir -p ~/work/price-tools && cd ~/work/price-tools npm init -y > /dev/null npm install --save-dev --save-exact prettier@3.9.9 prettier --version npx prettier --version npm pkg set scripts.format:check="prettier --check ." npm run format:checkOutputadded 1 package, and audited 2 packages in 522ms 1 package is looking for funding run `npm fund` for details found 0 vulnerabilities zsh: command not found: prettier 3.9.9 > price-tools@1.0.0 format:check > prettier --check . Checking formatting... All matched files use Prettier code style!If a package is not installed,
npxasks "Need to install the following packages ... Ok to proceed?" and downloads it. Read the package name before you say yes, because a misspelt name can be someone else's package (Security and quality in JavaScript projects).
| Task | bash / zsh / Git Bash | PowerShell |
|---|---|---|
| Where am I | pwd | Get-Location (pwd) |
| List files, including hidden | ls -la | Get-ChildItem -Force |
| Make nested folders | mkdir -p a/b/c | New-Item -ItemType Directory -Force a/b/c |
| Copy, move | cp, mv | Copy-Item, Move-Item |
| Delete a folder | rm -r folder | Remove-Item -Recurse folder (try -WhatIf) |
| Show a file | cat file | Get-Content file |
| Find lines | grep 'S-014' file | Select-String 'S-014' file |
| Set env var (session) | export NAME=value | $env:NAME = 'value' |
| Last exit code | echo $? | $LASTEXITCODE |
| Where is this program | which -a node | Get-Command node -All |
Bring it to your team
Terminal habits decide whether a team's setup is repeatable. Ask these when you join a repo or review a README.
- "Can every setup step in our README be copied and pasted into a terminal, in order, on Windows and macOS?"
- "Which shell do we assume on Windows, Git Bash or PowerShell, and is that written down?"
- "Which environment variables does this service need, and where is the example file that lists them?"
- "Do our scripts exit with a non-zero code when they fail, so CI really stops?"
- "Do we run project tools through npm scripts, so everyone uses the version in the lockfile?"
Red flags
- A setup guide that says "install prettier globally" instead of using the project's own version.
- A script that prints "ERROR" but still exits with 0, so the pipeline shows green.
- Production passwords pasted into a terminal command, where they stay in shell history.
- A cleanup step built from
rm -rf $SOME_VAR/, which deletes from the top if the variable is empty.
Practice
Work out where a path points
Write resolvePath(cwd, input, home) that does what the shell does with a path: it returns the absolute path that input means when you stand in cwd. Paths use /. A path starting with / is absolute, ~ at the start means home, . means "this folder", .. means "one folder up", and you can never go above /. Return the result without a trailing slash (except for / itself).
function resolvePath(cwd, input, home) {
// Start from the right place, then walk the parts one by one
return cwd + '/' + input;
}
const scripts = '/Users/you/work/store-report/scripts';
test('a plain name goes down from the current folder', () => {
expect(resolvePath('/Users/you/work/store-report', 'data/sales.csv', '/Users/you')).toBe('/Users/you/work/store-report/data/sales.csv');
});
test('.. goes up one folder before going down', () => {
expect(resolvePath(scripts, '../data/sales.csv', '/Users/you')).toBe('/Users/you/work/store-report/data/sales.csv');
});
test('. means this folder', () => {
expect(resolvePath(scripts, './count-sales.sh', '/Users/you')).toBe('/Users/you/work/store-report/scripts/count-sales.sh');
});
test('an absolute path ignores the current folder', () => {
expect(resolvePath(scripts, '/tmp/report.csv', '/Users/you')).toBe('/tmp/report.csv');
});
test('~ at the start means the home folder', () => {
expect(resolvePath(scripts, '~/work', '/Users/you')).toBe('/Users/you/work');
});
test('.. never goes above the top folder', () => {
expect(resolvePath('/Users', '../../..', '/Users/you')).toBe('/');
});
Show a solution
function resolvePath(cwd, input, home) {
let start = cwd;
if (input.startsWith('/')) start = ''; // absolute: ignore cwd
else if (input === '~' || input.startsWith('~/')) { // ~ means home
start = home;
input = input.slice(1);
}
const parts = [];
for (const part of (start + '/' + input).split('/')) {
if (part === '' || part === '.') continue; // empty or "this folder"
if (part === '..') parts.pop(); // up one (pop on [] does nothing)
else parts.push(part);
}
return '/' + parts.join('/');
}Answer a question with one pipeline
In the store-report folder from Build it, write one pipeline that prints how many sale lines each article has (the second column), most frequent first. Run it, then use grep -c to count how many lines store S-102 has. Write both commands down as you would paste them into a team chat.
- The pipeline skips the header line before counting.
- It cuts the second column, not the first.
- It sorts before
uniq -c, becauseuniqonly counts lines that sit next to each other. - It sorts the counts as numbers, largest first (
sort -rn). - The
greppattern is anchored with^, so it matches the store column only.
Show a model answer
tail -n +2 data/sales.csv | cut -d, -f2 | sort | uniq -c | sort -rn
grep -c '^S-102' data/sales.csv 2 A-3311
2 A-1001
1 A-2040
1 A-0007
2Lines with the same count may come out in a different order on your machine. Without the first sort, uniq -c would count A-1001 twice as two separate groups of one, because its two lines are not next to each other.
Check yourself
You installed Node with the official installer. In the terminal that was already open, node -v says "command not found". What is the most likely cause?
A shell reads PATH when it starts. Installers add a folder to PATH for new terminals, not for the ones already open. Reinstalling as admin changes nothing if the real problem is a stale PATH, which is far more common.
In bash, what does npm test && git push do when one test fails?
&& runs the right side only if the left side exits with 0. A failing test run exits non-zero, so the push is skipped. Running both whatever happens would be ;.
Prettier is in a project's devDependencies. Typing prettier --check . fails, but npm run format:check (which runs the same thing) works. Why?
Locally installed tools live in node_modules/.bin. npm scripts and npx put that folder on PATH, so the project's exact version runs. Using a global copy would defeat the point of pinning a version in the project.
Flashcards
Key takeaways
- The terminal is the window, the shell reads your commands. On Windows, use Git Bash so your commands match macOS, Linux and CI.
- Relative paths start where you stand. Run
pwdfirst when a file "does not exist". - Every command is program, flags and arguments, and it ends with an exit code. 0 means success, and automation relies on it.
- "Command not found" means no folder in PATH has that program. Open a new terminal, check with
which -a, or usenpxfor project tools. - Pipes join small tools into answers. Check the path before every
rm -r.