Shell scripts¶
Exercise shell-scripts-annex.pdf · PDF
Marta, at Tramuntana Apps, must back up the dev folder every evening: tar, then sha256sum, with today's date in the file name. Same commands, same order, every single day. Would you like to type that every evening until you retire?
Nobody would. And you do not have to. In Compression, encryption, integrity you typed those commands by hand. Today you write them once, in a file, and the computer types them for you. By the end of the page you have a real, tested backup.sh.
What you will learn
- What a script is and what the shebang line
#!/bin/bashtells the system - Write a script in
nano, make it executable withchmod +xand run it with./ - Use variables, command substitution
$(...)and the arguments$1,$2,$# - Read an exit code with
$?and say what 0 and "not 0" mean - Take decisions with
if [ ... ]and repeat work withfor - Why
name = Marta(with spaces) fails, and how one missing check deleted a user's files
A recipe card for the shell¶
A script is a plain text file with the commands you would type, one per line. The shell reads it from top to bottom and runs each line.
In the kitchen of Unit 1, a script is a recipe card: you write the steps once, and the chef repeats them without you standing there. Nothing new happens in a script. Every line is something you can already type.
The shell you use is Bash. Brian Fox wrote it for the GNU project and released it on 8 June 1989. The name is a joke: Bourne-Again SHell, because it replaced the Bourne shell, written by Stephen Bourne at Bell Labs and shipped with Unix Version 7 in 1979. Check yours with bash --version (deb-lab runs Bash 5.2).
Your first script¶
Make a folder for your scripts and open a new file in nano:
Type these four lines. Save with Ctrl+O then Enter, exit with Ctrl+X.
- Line 1 is the shebang:
#!followed by the full path of the program that must read this file. Dennis Ritchie added it to Unix in January 1980. The kernel looks at the first two bytes of the file to know what to do with it. - Line 2 is a comment: the shell ignores everything after
#. It is for humans. - Lines 3 and 4 are ordinary commands.
Now run it, and watch it fail:
A new file is a document, not a program. chmod +x marks it as executable:
ls -l now shows -rwxr-xr-x: the three x are new (the full story of permissions comes later in the course). And the script prints your machine's name and the date.
Why ./? The shell looks for commands only in a fixed list of folders. Your folder is not on the list, so you give the path: . means "here". Without it you get hello.sh: command not found.
Careful: the .sh does not make it run
Linux does not care about the extension. What makes a file run is the shebang and the x permission. A small typo matters, though: #!bin/bash (no first /) fails with cannot execute: required file not found, and a blank line before the shebang turns it into a plain comment.
Variables, quotes, arguments¶
| Idea | Example | Remember |
|---|---|---|
| Variable: a box with a name | name="Marta" then echo "$name" |
No spaces around =. Write $ only to read the box |
| Command substitution | today=$(date +%A) |
Run the command and keep what it prints |
| Arguments: what comes after the script's name | ./args.sh report.txt /tmp |
$1 is the first, $2 the second, $# how many, $0 the script itself |
Quotes matter. Inside "double" quotes the shell still replaces $name with its value. Inside 'single' quotes it replaces nothing.
#!/bin/bash
name="Marta"
today=$(date +%A)
echo "Hello, $name. Today is $today."
echo 'Single quotes: $name'
The most common first error
name = "Marta" with spaces is not the same as name="Marta". With spaces, the shell thinks name is a command and answers name: command not found.
Now arguments. Save this as args.sh, make it executable, and run ./args.sh report.txt /tmp:
Try ./args.sh "my file.txt": How many: 1. Without the quotes it is 2, because the shell cuts arguments at every space. That is why, inside a script, you always write "$1" in double quotes.
Exit codes: did it work?¶
Every command, when it ends, leaves a number from 0 to 255: its exit code. 0 means "it went well". Any other number means "something failed". The shell keeps the last one in $?:
Why is zero the good one? There is one way to succeed and many ways to fail, and each failure can have its own number:
| Command | Exit code | Why |
|---|---|---|
true |
0 | Always succeeds |
ls /nope |
2 | ls could not find it |
grep -q root /etc/passwd |
0 | The word is there |
grep -q marta /etc/passwd |
1 | The word is not there (0 if a user marta exists) |
nosuchcommand |
127 | The shell found no such command |
if: check before you act¶
if runs a command and looks at its exit code. [ is a command too: it tests something and answers 0 (true) or 1 (false).
#!/bin/bash
if [ -d "$1" ]; then
echo "$1 is a directory"
else
echo "$1 is not a directory"
exit 1
fi
Save it as check.sh. ./check.sh /etc answers /etc is a directory with exit code 0; ./check.sh /etc/passwd answers /etc/passwd is not a directory with exit code 1. exit 1 stops the script and gives your exit code to whoever called it.
| Test | True when |
|---|---|
[ -d "$x" ] |
$x is a directory |
[ -f "$x" ] |
$x is a normal file |
[ $# -ne 2 ] |
The number of arguments is not equal to 2 |
[ ! -d "$x" ] |
$x is not a directory (! = not) |
The spaces after [ and before ] are compulsory: [ -d "$1"] gives an error about a missing ]. A missing then or fi gives syntax error: unexpected end of file.
Why checks matter: Steam, 2015
In January 2015, the Steam client for Linux contained the line rm -rf "$STEAMROOT/"*. A user moved his Steam folder; the variable ended up empty; the line became rm -rf /* and deleted every file he owned, his backup disk included. Above that line the programmer had written the comment # Scary!. One if checking the variable would have stopped it. A script does exactly what you wrote, at full speed, without asking.
for: the same steps for every item¶
Tramuntana has three departments. Do you write three scripts? No. A for loop repeats the same lines once for each word of a list:
The list can also be a wildcard. Create the sample folders and go through every file:
mkdir -p ~/tramuntana/{dev,design,admin}
echo "print(42)" > ~/tramuntana/dev/app.py
echo "<svg/>" > ~/tramuntana/design/logo.svg
echo "2026-001;Hotel Bellver;1200" > ~/tramuntana/admin/invoices.csv
for f in ~/tramuntana/*/*; do
echo "$f: $(wc -c < "$f") bytes"
done
Three lines come out, one per file: invoices.csv 28 bytes, logo.svg 7 bytes, app.py 10 bytes.
backup.sh: everything together¶
Here is the real script. Read it top to bottom: every line is something from this page.
#!/bin/bash
# backup.sh - back up one folder into a dated .tar.gz
# Usage: backup.sh SOURCE DEST
if [ $# -ne 2 ]; then
echo "Usage: $0 SOURCE DEST"
exit 1
fi
src="$1"
dest="$2"
if [ ! -d "$src" ]; then
echo "Error: $src is not a directory"
exit 2
fi
mkdir -p "$dest"
stamp=$(date +%Y%m%d-%H%M%S)
name=$(basename "$src")
file="$dest/$name-$stamp.tar.gz"
if tar -czf "$file" -C "$(dirname "$src")" "$name"; then
sha256sum "$file" > "$file.sha256"
echo "$(date '+%F %T') OK $file"
else
echo "$(date '+%F %T') ERROR tar failed for $src"
exit 3
fi
flowchart TB
A["<b>Start</b><br>./backup.sh SOURCE DEST"] --> B{"Exactly 2<br>arguments?"}
B -->|"no"| E1["print Usage<br><b>exit 1</b>"]
B -->|"yes"| C{"SOURCE is a<br>directory?"}
C -->|"no"| E2["print Error<br><b>exit 2</b>"]
C -->|"yes"| D["build the file name<br>name-YYYYMMDD-HHMMSS.tar.gz"]
D --> T{"tar worked?"}
T -->|"yes"| OK["write the .sha256<br>print OK, <b>exit 0</b>"]
T -->|"no"| E3["print ERROR<br><b>exit 3</b>"]
Three new pieces:
basename /home/x/devgivesdev, anddirname /home/x/devgives/home/x.tar -C foldermeans "go into that folder first", so the archive holdsdev/...and not the whole path.>sends what a command prints into a file, here the checksum intofile.sha256.
Test all three exits, then check the result:
$ ./backup.sh; echo $?
Usage: ./backup.sh SOURCE DEST
1
$ ./backup.sh ~/nothing ~/backups; echo $?
Error: /home/student/nothing is not a directory
2
$ ./backup.sh ~/tramuntana/dev ~/backups; echo $?
2026-10-05 20:59:34 OK /home/student/backups/dev-20261005-205934.tar.gz
0
$ sha256sum -c ~/backups/*.sha256
/home/student/backups/dev-20261005-205934.tar.gz: OK
tar -tzf ~/backups/dev-*.tar.gz lists dev/app.py. On the Exercise sheet you build this script yourself by filling six gaps.
Write scripts inside the VM, not in Windows Notepad
Windows ends each line with two bytes (CR LF). Bash then looks for a program called bash plus an invisible character, and fails with required file not found.
Key takeaways¶
- A script is a text file of commands that the shell runs top to bottom: a recipe card for the chef.
- The shebang
#!/bin/bashsays which program reads the file;chmod +xmakes it executable;./runs it from here. - Variables have no spaces around
=;$(...)keeps a command's output;$1,$2,$#are the arguments. Quote them:"$1". - Every command leaves an exit code in
$?: 0 is success, anything else is failure. if [ ... ]checks before acting,exit Nreports your own result, andforrepeats work for every item of a list.
Quick check¶
1. What is the first line #!/bin/bash of a script for?
- It says which program must read the file
- It is a comment with the author's name
- It makes the file executable for everybody
- It imports the commands that Bash knows
The shebang (#! plus a path) is read by the kernel to know which program must run the file. For Bash itself it is just a comment; making the file executable is the job of chmod +x.
2. ./backup.sh answers Permission denied. What do you do?
- Run chmod +x backup.sh
- Rename it to backup.exe
- Run bash --version first
- Delete the shebang line
A new file is a document, not a program. chmod +x adds the execute permission. Linux ignores the extension, and the shebang is needed to say who runs the file.
3. Why does name = “Pau” fail in a script?
- The spaces make the shell treat name as a command
- Variable names must be written in capital letters
- The value must be written in single quotes
- A variable needs a $ when you create it
A variable is assigned with no spaces: name="Pau". With spaces, the shell reads name as a command and answers name: command not found. The $ is only used to read the variable.
4. A command ends with exit code 2. What does that tell you?
- It failed
- It worked, with two results
- It ran twice in a row
- It needs two arguments
By convention an exit code of 0 means success and any other number means failure. The number can say which failure: ls returns 2 when it cannot find the path.
5. In ./backup.sh ~/tramuntana/dev ~/backups, what is the value of $#?
- 2
- 3
- ./backup.sh
- ~/backups
$# counts the arguments, not the script's name: ~/tramuntana/dev and ~/backups. The script's own name is in $0, and ~/backups is in $2.
6. What does [ ! -d “$src” ] test?
- That $src is not a directory
- That $src is an empty directory
- That $src is a normal file
- That $src was deleted before
-d is true when the path is a directory, and ! turns it around. In backup.sh this check stops the script with exit code 2 before tar touches anything.
7. A Linux script contains rm -rf “$DIR/”* and $DIR ends up empty. Select the two that are true.
- The line becomes rm -rf /* and deletes everything it can
- An if that checks $DIR first would have stopped it
- Bash refuses to run rm when a variable is empty
- The comment above the line protects the files
That is the Steam bug of 2015. An empty variable turns the path into /, and a script does exactly what it says, without asking. Bash does not protect you and comments are ignored: only a check with if does.
backup.sh works, but someone still has to remember to run it every evening. Next, the computer runs it by itself: Scheduling tasks.