Skip to content

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/bash tells the system
  • Write a script in nano, make it executable with chmod +x and 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 with for
  • 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:

mkdir -p ~/scripts && cd ~/scripts
nano hello.sh

Type these four lines. Save with Ctrl+O then Enter, exit with Ctrl+X.

#!/bin/bash
# hello.sh - my first script
echo "Hello from $(hostname)"
date
  • 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:

$ ./hello.sh
bash: ./hello.sh: Permission denied

A new file is a document, not a program. chmod +x marks it as executable:

chmod +x hello.sh
ls -l hello.sh
./hello.sh

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'
Hello, Marta. Today is Tuesday.
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:

#!/bin/bash
echo "Script name: $0"
echo "First:  $1"
echo "Second: $2"
echo "How many: $#"
Script name: ./args.sh
First:  report.txt
Second: /tmp
How many: 2

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 $?:

$ ls /nope; echo $?
ls: cannot access '/nope': No such file or directory
2

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:

for dept in dev design admin; do
    echo "Department: $dept"
done

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/dev gives dev, and dirname /home/x/dev gives /home/x.
  • tar -C folder means "go into that folder first", so the archive holds dev/... and not the whole path.
  • > sends what a command prints into a file, here the checksum into file.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/bash says which program reads the file; chmod +x makes 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 N reports your own result, and for repeats 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.