Three live hours a week, on alternate days.
Reading goes out before and after every session.
PATH, and what a package manager really does.By the end you will navigate a machine you have never seen, read and fix a permissions error, explain why a command the shell cannot find is sitting right there on disk, and get from a five-thousand-line log to the one line that matters. You type in every part.
The terminal is the app — Terminal, iTerm, Windows Terminal. All it does is draw text
and collect your keystrokes. The shell is the program running inside it: it reads the
line you typed, works out which program you meant, runs it, and prints what comes back.
macOS gives you zsh. Ubuntu and WSL give you bash. Everything today behaves the same
in both.
rajbeer1@mac ~ % ls -l notes.txt
│ │ │ │ │ │ └──────── argument — the thing to work on
│ │ │ │ │ └─────────── flag — -l asks for the long listing
│ │ │ │ └────────────── program — the first word is always the command
│ │ │ └───────────────── the prompt. You type after it. bash prints $, zsh %
│ │ └───────────────────── where you are standing right now. ~ means home
│ └───────────────────────── the machine you are logged into
└──────────────────────────────── the user you are logged in as
A command is a program plus arguments. None of it is secret syntax.
| command | short for | what it does | the flag worth knowing |
|---|---|---|---|
pwd | print working directory | prints where you are standing, in full | — |
cd | change directory | moves you somewhere else | cd - returns to the last place |
ls | list | names what is inside a directory | ls -l long rows · ls -a includes dotfiles |
mkdir | make directory | creates a folder | mkdir -p a/b/c builds the whole chain |
$ pwd
/tmp/lab
$ ls -l
drwxr-xr-x 2 rajbeer1 wheel 64 3 Aug 15:03 logs
-rw-r--r-- 1 rajbeer1 wheel 6 3 Aug 15:03 notes.txt
ls on its own gives you names. ls -l gives you the row the next half hour is about:
type, permissions, owner, group, size, date, name.
/ means the same thing from anywhereAn absolute path starts at the root and spells out every step: /var/log/syslog. It
names the same file whether you type it at home, inside another folder, or from a script
running on a server at 3am. A relative path starts from wherever you happen to be
standing, so the same text means different files in different places.
cd / # the root of the whole tree
cd ~ # your home — /Users/you on macOS, /home/you on Linux and WSL
cd .. # up one level. cd ../.. goes up two
cd logs # relative: into ./logs, from here. Same as cd ./logs
cd - # back to the directory you were just in
/ is the root · ~ is home · . is here · .. is the parent. Scripts should use
absolute paths, because a script has no idea where you were standing when you ran it.
/. Everything hangs off it.There is no C: and no D:. A filesystem is one single tree — every disk, USB stick
and network share gets grafted onto a branch of it rather than getting a letter of its own.
So a path is never "which drive", only "which directions from the root".
| directory | what lives in it |
|---|---|
/etc | configuration, in plain text files you are allowed to read |
/var | things that change while the machine runs — logs are in /var/log |
/usr | installed programs — /usr/bin holds the commands you type |
/home | one folder per user. macOS calls this one /Users |
/tmp | scratch space, writable by everyone, wiped on reboot |
/proc | not a real disk at all. One directory per running program |
Remember /proc. We come back to it.
WSL: your Windows drives are grafted on at /mnt/c and /mnt/d.
Go to the root first and look around from there. Then walk down into the log folder, then come home. Watch your prompt change as you move.
cd /
pwd
ls
cd /var/log
ls | head -5
cd ~
pwd
mkdir -p /tmp/lab && cd /tmp/lab && pwd
Read your last two lines out when I get to you. Hands up if your cd ~ printed something
that does not start with /Users or /home.
Stretch, if that took you thirty seconds: ls -la /etc | head, and find one file in there
you can read but not write.
mkdir -p /tmp/lab && cd /tmp/lab
printf '#!/usr/bin/env bash\necho "it ran"\n' > run.sh
chmod 644 run.sh
ls -l run.sh
./run.sh
-rw-r--r-- 1 rajbeer1 wheel 34 3 Aug 13:00 run.sh
bash: ./run.sh: Permission denied
It is my file. My name is on it, twice. Nobody else is on this machine.
So why am I not allowed?
-rw-r--r-- 1 rajbeer1 wheel 34 3 Aug 13:00 run.sh
│ │ │ └── other — everybody else on the machine
│ │ └───── group — anyone in the file's group, here "wheel"
│ └──────── user — the owner, whose name is in the third column
└────────── type — `-` plain file, `d` directory, `l` symlink
Inside every group the order is always the same: r read, w write, x execute, and
a - means that one is switched off. So rw- is read and write but not execute. r-- is
read only. The kernel checks exactly one group — the first that describes you — so being in
the group does not also hand you the owner's rights.
-rw-r--r-- means: I read and write it, everyone reads it, and nobody, me included, may
run it.
Each group of three is stored as a single digit, and each letter has a value: r = 4, w = 2, x = 1. The digit is the sum of the letters that are switched on.
7 = 4+2+1 = rwx 5 = 4+0+1 = r-x 1 = 0+0+1 = --x
6 = 4+2+0 = rw- 4 = 4+0+0 = r-- 0 = 0+0+0 = ---
Three digits, always in the order user, group, other. So 644 is 6 for me (rw-), 4 for
my group (r--), 4 for everyone else (r--) → -rw-r--r--. And 755 is 7 for me (rwx),
5 for the group (r-x), 5 for everyone (r-x) → -rwxr-xr-x.
| mode | reads as | what it is for |
|---|---|---|
400 | -r-------- | read-only, even to me |
600 | -rw------- | private — an SSH key, a .env |
644 | -rw-r--r-- | data and config: I edit it, everyone reads it |
700 | -rwx------ | a script only I may run |
755 | -rwxr-xr-x | a program or script everyone may run |
777 | -rwxrwxrwx | never, on a machine that matters |
chmod numeric sets all nine bits. Symbolic changes only what you name.Numeric takes the three digits from the last slide and overwrites everything:
chmod 755 run.sh means "user rwx, group r-x, other r-x", no matter what was there before.
Symbolic names a who, an operator and some letters, and touches nothing else.
who u user g group o other a all three
op + add - remove = set exactly, clearing the rest
what r read w write x execute
chmod u+x run.sh # give the owner execute. Group and other unchanged.
chmod +x run.sh # no who named = all three columns
chmod go-w notes.txt # take write away from group and other
chmod u=rw,go=r f.txt # spelled out, exactly 644
Numeric when you know the whole answer. Symbolic when you want to change one thing and are not sure what the rest currently is — which, on a real server, is most of the time.
mkdir -p /tmp/lab && cd /tmp/lab
printf '#!/usr/bin/env bash\necho "it ran"\n' > run.sh
chmod 644 run.sh
ls -l run.sh # -rw-r--r--
./run.sh # Permission denied
chmod +x run.sh
ls -l run.sh # -rwxr-xr-x
./run.sh # it ran
One bit is the whole difference between a text file and a program. Read your two ls -l
rows out loud to the person next to you — the first field, character by character.
Stretch: reach the same place with chmod 755, then break it again with chmod u-x and
predict what ls -l will say before you run it.
chmod changes the rules. chown changes who they apply to.cd /tmp/lab
echo data > owned.txt
chmod 644 owned.txt
ls -l owned.txt
sudo chown root owned.txt
ls -l owned.txt
echo more >> owned.txt
-rw-r--r-- 1 intern intern 5 Aug 3 03:17 owned.txt
-rw-r--r-- 1 root intern 5 Aug 3 03:17 owned.txt
bash: owned.txt: Permission denied
The mode never changed — still 644, still rw- on the left. I stopped being the owner, so
that rw- now belongs to root and I fell through to the r-- on the right.
Same rules, different person.
mkdir -p /tmp/mytools
printf '#!/usr/bin/env bash\necho "hello from mytool"\n' > /tmp/mytools/mytool
chmod +x /tmp/mytools/mytool
mytool
ls -l /tmp/mytools/mytool
bash: mytool: command not found
-rwxr-xr-x 1 rajbeer1 wheel 45 3 Aug 13:00 /tmp/mytools/mytool
The file is there. The execute bit is there — we set one of those in the last block.
So who is lying?
PATH is the shell's list of places to look, checked left to rightWhen you type ls, the shell does not search your disk — that would take minutes. It reads an
environment variable called PATH: a list of directory names separated by colons. It
tries them in order, first match wins, and it stops there. If no directory on the list has
the name, you get command not found. That is why a full path always works.
echo $PATH | tr ':' '\n' | head -6
/usr/local/bin
/System/Cryptexes/App/usr/bin
/usr/bin
/bin
/usr/sbin
/sbin
/tmp/mytools is not on that list, which is why mytool failed. export PATH="/tmp/mytools:$PATH" puts it on the front — for this terminal only.
Two different failures, and the message tells you which. Permission denied quotes back the
full path — found, and refused. command not found quotes back only what you typed — never
found at all. (The numbers behind them are 126 and 127; CI logs show the number.)
Found but blocked is a chmod problem. Never found is a PATH problem.
cp with a receiptInstalling by hand means downloading an archive, unpacking it somewhere, chasing the four
libraries it needs, and then having no idea what to delete later. A package manager does
four things instead. It fetches the package from a repository it trusts. It resolves
dependencies, so everything the package needs arrives too. It installs files into known
places — the binary into /usr/bin, which is already on your PATH, the man page into
/usr/share/man. And it records every path it touched, so it can upgrade or remove them
cleanly.
| OS | manager | install | what owns this file? |
|---|---|---|---|
| Ubuntu / Debian / WSL | apt | sudo apt install tree | dpkg -S /usr/bin/tree |
| Fedora / RHEL / Amazon Linux | dnf | sudo dnf install tree | rpm -qf /usr/bin/tree |
| macOS | brew | brew install tree | brew list tree |
WSL: run sudo apt update before your first install, or a real package reports
Unable to locate package.
npm is the same idea one layer up — and it lands on your PATHapt, brew and dnf manage the operating system. npm manages JavaScript packages,
and -g means "install it for the whole machine, not into this one project". Watch where
the files actually go.
npm install -g cowsay
npm ls -g --depth=0 | grep cowsay
npm root -g
ls -l "$(npm prefix -g)/bin/cowsay"
which cowsay
cowsay "PATH is why this works"
added 41 packages in 208ms
├── cowsay@1.6.0
/Users/you/.nvm/versions/node/v22.14.0/lib/node_modules
.../bin/cowsay -> ../lib/node_modules/cowsay/cli.js
/Users/you/.nvm/versions/node/v22.14.0/bin/cowsay
One command, forty-one packages resolved. The code lands in lib/node_modules; a symlink
lands in bin; and bin is on your PATH. That symlink is the whole reason you can type
cowsay instead of a full path.
Roqit is our data platform — fleet telemetry, carbon tracking. The log volume on the ingest box just alerted. You are on call. This is your terminal.
df -h /var/log/roqit
ls -la /var/log/roqit
du -sh /var/log/roqit
Filesystem Size Used Avail Use% Mounted on
tmpfs 32M 28M 4.6M 86% /var/log/roqit
total 0
drwxr-xr-x 2 root root 40 Aug 3 03:01 .
drwxr-xr-x 1 root root 10 Aug 3 03:01 ..
0 /var/log/roqit
df says 28 MB used. ls -la says empty. du says zero. Which one is lying?
A program sitting on disk is just a file. The moment you run it the kernel creates a process: that code loaded into memory, with its own working directory, its own user and its own list of open files. Every process gets a PID — a number, unique while it is alive, reused after it dies. Everything running is a process: your shell, your editor, the database, the thing quietly filling this disk.
Linux publishes all of it as files under /proc — one directory per PID.
/proc/1234/cmdline is the command it started with; /proc/1234/fd lists every file it has
open right now.
echo "this shell is PID $$"
ls /proc/$$/fd # Linux and WSL2
macOS has no /proc — Apple's kernel is a different family, and the equivalent tool
there is lsof. That is why this demo runs in a Linux container.
ps lists them. kill sends a signal. killall goes by name.A signal is a one-word message delivered to a running process. kill does not kill — it
sends. The process can catch the first two below and clean up before it goes. The third it
never even sees.
ps aux # every process: user, PID, CPU, memory, command
ps -ef # the same list, older style. Both work on macOS and Linux
ps -p 1234 -o pid,user,etime,command
kill 1234 # SIGTERM (15) — the default. "Please stop and tidy up."
kill -INT 1234 # SIGINT (2) — exactly what Ctrl-C sends
kill -9 1234 # SIGKILL (9) — the kernel removes it. No warning, no cleanup.
killall roqit-ingest # by name, not PID. Hits EVERY match, so be sure.
SIGKILL cannot be caught or ignored, so the program never gets to finish its write or
close its files. That is why -9 is a last resort, never a first move.
rm does not delete a file. It removes a name.A file is two separate things: the data sitting in blocks on the disk, and a name in a
directory pointing at that data. rm removes the name. The kernel throws the data away
only when two counters both hit zero — how many names point at it, and how many running
processes have it open.
So a log file you deleted last week can still be eating your disk today. The name is gone, so
ls and du have nothing left to walk and honestly report zero. But a process still holds
it open, so the blocks stay allocated and df still counts them.
A deleted-but-open file is invisible to every tool that walks the tree, and perfectly visible to the one that asks the filesystem.
PID=$(cat /run/roqit.pid); echo "PID=$PID"
ls -l /proc/$PID/fd
truncate -s 0 /proc/$PID/fd/1
df -h /var/log/roqit
l-wx------ 1 root root 64 Aug 3 03:02 1 -> /var/log/roqit/ingest.log (deleted)
l-wx------ 1 root root 64 Aug 3 03:02 2 -> /var/log/roqit/ingest.log (deleted)
Filesystem Size Used Avail Use% Mounted on
tmpfs 32M 0 32M 0% /var/log/roqit
Somebody ran rm on that log last week. The name went away; the file did not. Emptying it
through the descriptor gives the space back with the service still running — restarting would
also work, and at 2am that costs you every in-flight request.
86% to 0%, same process, never stopped.
| command | what it does | reach for it when |
|---|---|---|
cat f | prints the entire file, then exits | the file is small and you want all of it |
head -n 20 f | the first 20 lines | "is this even the format I think it is?" |
tail -n 20 f | the last 20 lines | "what happened just now?" |
tail -f f | prints new lines as they get written | watching a failure happen live |
less f | a full-screen pager you move around in | anything you actually have to read |
Inside less: /word searches forward, n jumps to the next hit, G goes to the end,
q quits.
less never loads the file into memory, so it opens a 40 GB log instantly on a box with
2 GB of RAM. cat on that same file pushes all 40 GB through your terminal, destroys your
scrollback and takes ten minutes. Check with wc -l before you cat anything you did not
create. Ctrl-C stops tail -f — and that is SIGINT, which you met in the last block.
grep PATTERN FILE prints every line that matchesTwo arguments: what to look for, and where to look. By default grep prints the whole line,
unchanged, for every line containing your pattern. Quote the pattern whenever it contains
anything but letters, or the shell will try to expand it before grep ever sees it.
grep FATAL app.log # every line containing FATAL
grep -c ERROR app.log # count matching lines instead of printing them
grep -ci error app.log # -i ignores case: ERROR, Error and error
grep -vc '"level":"INFO"' app.log # -v inverts — count everything that does NOT match
grep -n '"level":"FATAL"' app.log | head -1 # -n puts the line number in front
grep -rn checkout-api /var/log # -r walks a whole directory tree
294
447
1402
364:{"ts":"2026-08-01T08:16:56.646+05:30","level":"FATAL","service":"checkout-api",...
-c count · -i ignore case · -n line numbers · -v invert · -r recursive. Those
five carry almost every log hunt you will ever do.
wc -l app.log
head -3 app.log | cut -c1-100
grep -c ERROR app.log
grep -c '"level":"ERROR"' app.log
5000 app.log
{"ts":"2026-08-01T00:01:10.927+05:30","level":"ERROR","service":"checkout-api","msg":"request payloa
com.dataeko.checkout.ValidationError: field 'quantity' must be a positive integer, got -3
at com.dataeko.checkout.HttpClient.execute(HttpClient.java:204)
294
263
One event, three lines — the two indented ones are its stack trace. This file holds 5000
lines and 4525 events, and wc -l cannot tell the difference. Nor can grep: 294 lines
contain the word ERROR, but 31 of them are INFO records mentioning ERROR_RATE_ALERT or
stack-trace text. Tighten the pattern and you get 263. The census says 262 ERROR plus 17
FATAL — 279 real events. grep -c counts lines, not events, and 15 lines is the gap.
Decide out loud before you scroll: is the service with the most errors the same as the service that is most broken?
mkdir -p /tmp/lab && cd /tmp/lab
[ -s app.log ] || curl -fsS -o app.log http://localhost:8899/files/app-2026-08-01.log \
|| cp /Volumes/DATAEKO/app-2026-08-01.log app.log 2>/dev/null \
|| cp "/media/$USER/DATAEKO/app-2026-08-01.log" app.log 2>/dev/null \
|| cp /mnt/e/app-2026-08-01.log app.log 2>/dev/null \
|| echo "come and take app.log off the USB stick on my desk"
grep -n '"level":"FATAL"' app.log | head -1 | cut -c1-100
grep -o '"service":"[a-z-]*"' app.log | sort | uniq -c | sort -rn
grep '"level":"ERROR"' app.log | grep -o '"service":"[a-z-]*"' | sort | uniq -c | sort -rn
When I call time, read me your two lists and tell me which service you would look at first.
cat > logsum.sh <<'EOF'
#!/usr/bin/env bash
echo "reading app.log"
wc -l app.log
EOF
chmod +x logsum.sh
./logsum.sh
reading app.log
5000 app.log
Line one is the shebang: #! and then an interpreter. It is not a comment — the kernel
reads those first two bytes and hands the file to whichever program follows. env bash finds
whichever bash is on your PATH, rather than betting that it lives in the same place on every
machine. Then chmod +x — the one bit from block B — and run it as ./logsum.sh.
The shell splits the first word into two cases. A word with no slash in it is a name, and the
shell hunts for it along PATH. A word containing a slash is a path, so the shell skips the
list entirely and opens exactly that file. . means the directory you are standing in, so
./logsum.sh is a path to this one file, here. Your current directory is deliberately not on
PATH — otherwise a stray file named ls dropped in a shared folder would run instead of the
real one.
logsum.sh # no slash, so PATH gets searched. Not there.
./logsum.sh # has a slash, so this exact file gets opened
bash: logsum.sh: command not found
reading app.log
A slash anywhere in the word means path; no slash means search.
logfile="app.log" is a variable — a name for a value you use more than once. No spaces
around the =: logfile = "app.log" makes bash hunt for a program called logfile. Read
it back as "$logfile" — the dollar gets the value, the quotes keep it one word. Now the
filename is written once, and changing it changes every line that uses it.
mkdir -p /tmp/lab && cd /tmp/lab
[ -s app.log ] || curl -fsS -o app.log http://localhost:8899/files/app-2026-08-01.log \
|| cp /Volumes/DATAEKO/app-2026-08-01.log app.log 2>/dev/null \
|| cp "/media/$USER/DATAEKO/app-2026-08-01.log" app.log 2>/dev/null \
|| cp /mnt/e/app-2026-08-01.log app.log 2>/dev/null \
|| echo "come and take app.log off the USB stick on my desk"
cat > logsum.sh <<'EOF'
#!/usr/bin/env bash
logfile="app.log"
echo "reading $logfile"
wc -l "$logfile"
EOF
chmod +x logsum.sh
./logsum.sh
grep has to match more than a plain string.Session 2: shell scripting properly, then Git — all of it. Bring today's script.