Przejdź do treści
Lekcja 55 z 117

Kurs › Moduł 5 › Lekcja 55

Duży skrypt w porządku

⏱ ok. 14 min czytania· moduł 5: Skrypty, które się nie sypią

Po tej lekcji: kursant dzieli większy skrypt na części: wspólne funkcje trzyma w bibliotece wczytywanej przez source, buduje skrypt według stałej struktury z funkcją main, pisze w spójnym stylu i zabezpiecza go blokadą flock przed podwójnym uruchomieniem.

Wstęp

folder ~/skrypty, w nim siedem plików .sh, w każdym ta sama funkcja blad(), w trzech z nich lekko różna

Masz siedem skryptów. W każdym są te same funkcje do komunikatów, przeklejone. W zeszłym miesiącu poprawiłeś błąd w jednej kopii. Sześć zostało zepsutych. Którą poprawiłeś? Nie pamiętasz.

A do tego skrypt raportu odpala budzik co pięć minut. Zwykle kończy się po minucie. Ale dziś serwer był zajęty i jeden przebieg trwał siedem minut. W tym czasie wystartował drugi. Oba pisały do tego samego pliku. Wynik wyglądał jak dwa listy wydrukowane na jednej kartce.

Dziś robimy z tego porządek: jedna biblioteka zamiast siedmiu kopii, skrypt zbudowany według planu, który zrozumiesz po roku, i klucz do łazienki, który sprawi, że drugi egzemplarz grzecznie poczeka albo zrezygnuje.

source: przepis wczytany do własnej kuchni

Pamiętasz, że skrypt to przepis, a proces to kucharz, który go realizuje? Kiedy uruchamiasz skrypt przez ./skrypt.sh albo bash skrypt.sh, zatrudniasz nowego kucharza w osobnej kuchni. On gotuje, sprząta, wychodzi. Wszystko, co zrobił u siebie, zostaje u niego.

source działa inaczej. Bierzesz cudzy przepis i wykonujesz go sam, we własnej kuchni. Wszystko, co ten przepis ustawi, zostaje u Ciebie.

cat ustawienia.sh
kolor=niebieski
cd /tmp
$ bash ustawienia.sh
$ echo "kolor=[$kolor], pwd=$PWD"
kolor=[], pwd=/home/eryk/kurs
$ source ./ustawienia.sh
$ echo "kolor=[$kolor], pwd=$PWD"
kolor=[niebieski], pwd=/tmp

Pierwszy raz: osobny kucharz ustawił zmienną i przeszedł do /tmp, ale u siebie. Ty dalej nie masz zmiennej i stoisz tam, gdzie stałeś. Drugi raz: przepis wykonał się w Twojej powłoce, więc masz i zmienną, i nowy folder.

Krótsza forma to kropka: . ./ustawienia.sh. Kropka jest ze standardu POSIX i działa nawet w sh, a source to wygodniejsza nazwa z Basha. Robią to samo. I tak, source ~/.bashrc po edycji ustawień to dokładnie ten mechanizm. Wczytujesz nowe ustawienia do już otwartego terminala.

Biblioteka: jedna szuflada na wspólne narzędzia

Skoro source wczytuje funkcje do Twojej powłoki, to wspólne funkcje można trzymać w jednym pliku i wczytywać je w każdym skrypcie. Taki plik nazywa się biblioteką. Jedna szuflada z narzędziami w warsztacie zamiast śrubokręta w każdej kieszeni każdej kurtki.

# shellcheck shell=bash
# lib/komunikaty.sh — wspólne komunikaty: info, uwaga, blad, umrzyj
# Tego pliku się nie uruchamia. Wczytuje się go przez: source lib/komunikaty.sh

# Strażnik: drugi raz nie wczytuj
[[ -n ${_KOMUNIKATY_ZALADOWANE-} ]] && return 0
_KOMUNIKATY_ZALADOWANE=1

if [[ -t 2 && -z ${NO_COLOR-} ]]; then
    _CZERWONY=$'\e[31m'; _ZOLTY=$'\e[33m'; _ZIELONY=$'\e[32m'; _RESET=$'\e[0m'
else
    _CZERWONY=''; _ZOLTY=''; _ZIELONY=''; _RESET=''
fi

info()  { printf '%s[ OK ]%s %s\n' "$_ZIELONY" "$_RESET" "$*" >&2; }

uwaga() {
    printf '%s[UWAGA]%s %s\n' "$_ZOLTY" "$_RESET" "$*" >&2
    logger -t "${0##*/}" -p user.warning -- "$*" || true
}

blad() {
    printf '%s[BŁĄD]%s %s\n' "$_CZERWONY" "$_RESET" "$*" >&2
    logger -t "${0##*/}" -p user.err -- "$*" || true
}

# umrzyj KOD KOMUNIKAT — wypisz błąd i zakończ skrypt
umrzyj() {
    local kod=$1
    shift
    blad "$*"
    exit "$kod"
}

Kilka zasad dobrej biblioteki:

  • Bez shebanga i bez prawa wykonania. Tego pliku się nie uruchamia, tylko wczytuje. Komentarz # shellcheck shell=bash mówi ShellCheckowi, w jakim to języku, skoro brakuje pierwszej linijki z #!.
  • Tylko definicje. Biblioteka niczego nie robi przy wczytaniu, tylko przygotowuje funkcje i zmienne. Nie kasuje plików, nie wypisuje „Witaj!”.
  • Strażnik. Jeśli dwie biblioteki wczytają trzecią, nie chcesz jej ładować dwa razy. Zmienna _KOMUNIKATY_ZALADOWANE to kartka na drzwiach „już tu byłem”. return w pliku wczytanym przez source kończy samo wczytywanie, a nie cały skrypt.
  • Podkreślnik dla rzeczy wewnętrznych. _CZERWONY z podkreślnikiem znaczy „to szczegół biblioteki, nie ruszaj z zewnątrz”. Bash tego nie wymusza, to umowa między ludźmi. Jak kartka „nie dotykać” na kablach w serwerowni.
  • || true przy logger. Na maszynie bez działającego dziennika logger zwróci błąd, a nie chcemy, żeby przez to skrypt z set -e padł w trakcie wypisywania innego błędu.

Teraz każdy skrypt pisze tylko jedną linijkę, a poprawka w bibliotece od razu działa wszędzie.

Skąd skrypt ma wiedzieć, gdzie leży biblioteka

Pierwszy odruch: source lib/komunikaty.sh. Działa, dopóki uruchamiasz skrypt z jego własnego folderu. Cron odpala skrypty z folderu domowego użytkownika, kolega z /tmp, a wtedy ścieżka lib/... prowadzi donikąd.

Dlatego skrypt sam ustala, gdzie leży, i od tego miejsca szuka biblioteki:

KATALOG_SKRYPTU=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
readonly KATALOG_SKRYPTU
# shellcheck source-path=SCRIPTDIR source=lib/komunikaty.sh
source "$KATALOG_SKRYPTU/lib/komunikaty.sh"

${BASH_SOURCE[0]} to ścieżka do tego pliku, dirname obcina nazwę pliku, a cd i pwd zamieniają to na pełną ścieżkę bez kropek. Skrypt wie, na którym piętrze stoi, niezależnie od tego, z którego piętra go zawołano.

Czemu przypisanie i readonly w dwóch linijkach? Bo readonly X=$(...) połyka kod wyjścia komendy w środku i gdyby cd się nie udało, nikt by się nie dowiedział. ShellCheck wyłapuje to jako SC2155.

Komentarz nad source jest dla ShellChecka: mówi, gdzie ma szukać biblioteki, żeby sprawdzić ją razem ze skryptem. Uruchamiasz go wtedy z -x:

$ shellcheck -x raport.sh && echo "czysto"
czysto

Bez tego komentarza i bez -x dostaniesz informację SC1091: Not following: lib/komunikaty.sh. ShellCheck nie wie, co jest w bibliotece, więc nie sprawdza, czy funkcje istnieją.

Struktura: plan biurowca dla skryptu

Biurowiec ma plan: recepcja na dole, biura wyżej, kotłownia w piwnicy. Duży skrypt też powinien mieć stały plan, ten sam w każdym Twoim skrypcie. Wtedy po roku otwierasz dowolny plik i od razu wiesz, gdzie czego szukać.

1. #!/bin/bash
2. komentarz: co robi, jak używać, jakie kody wyjścia
3. set -euo pipefail
4. stałe (WIELKIE_LITERY, readonly)
5. wczytanie bibliotek
6. funkcje
7. main() — główna logika
8. main "$@"

Najważniejszy pomysł to funkcja main na dole i jej wywołanie w ostatniej linijce. Po co, skoro można pisać komendy luzem?

  • Bash czyta plik od góry. Kiedy dojdzie do main "$@", wszystkie funkcje są już zdefiniowane. Kolejność ich wypisania przestaje mieć znaczenie.
  • W funkcji można używać local. Zmienne main nie wyciekają do reszty skryptu.
  • Czytelnik od razu widzi: tu są narzędzia, a tu jest to, co skrypt naprawdę robi.

Przewodnik stylu Google dla skryptów powłoki mówi wprost: jeśli skrypt ma choć jedną funkcję poza główną, ma mieć funkcję main, a ostatnią linijką kodu ma być main "$@". Ten sam przewodnik ma też radę, którą warto zapamiętać: jeśli skrypt przekracza mniej więcej sto linijek, zastanów się, czy nie powinien być napisany w Pythonie. Bash jest świetny do sklejania komend, ale do wielkiej logiki jest jak scyzoryk do budowy domu.

Styl: żeby dało się to czytać po roku

Styl to nie estetyka, tylko szacunek dla osoby, która będzie ten skrypt naprawiać o trzeciej w nocy. Najczęściej to Ty.

  • Wcięcia zawsze takie same. Google zaleca dwie spacje, ja w kursie używam czterech. Ważniejsze od liczby jest to, żeby w całym pliku było tak samo i bez tabulatorów pomieszanych ze spacjami.
  • Nazwy mówią, co trzymają. plik_wyjsciowy zamiast x. Stałe i zmienne środowiskowe WIELKIMI LITERAMI, zwykłe zmienne i funkcje małymi z podkreślnikiem.
  • local w każdej funkcji. Zmienna bez local w funkcji jest globalna i potrafi nadpisać coś, czego się nie spodziewasz.
  • readonly dla stałych. Jeśli coś ma się nie zmieniać, niech Bash pilnuje, żeby się nie zmieniło.
  • Cudzysłowy wokół zmiennych, [[ ]] zamiast [ ], $( ) zamiast odwrotnych apostrofów. Wszystko to już znasz.
  • Komentarz mówi „dlaczego”, a nie „co”. # zwiększ licznik nad licznik=$((licznik + 1)) to komentarz dla nikogo. # globalna, bo sprząta ją trap po wyjściu z main to komentarz, który ratuje życie.
  • ShellCheck na zero. Każdy skrypt przed wrzuceniem na serwer.

flock: klucz do łazienki na stacji benzynowej

Na stacji benzynowej jest jedna łazienka i jeden klucz, przyczepiony do wielkiej drewnianej łopaty, żeby nikt go nie wyniósł. Kto ma klucz, ten wchodzi. Kto przyjdzie drugi, musi poczekać albo pójść gdzie indziej.

flock to taki klucz dla skryptów. Jest plik blokady, czyli łopata. Pierwszy egzemplarz skryptu bierze na niej blokadę. Drugi próbuje, dostaje odmowę i rezygnuje albo czeka.

exec 9> /tmp/raport.lock
flock -n 9 || umrzyj 75 "Raport już się robi (blokada: /tmp/raport.lock)"

Pierwsza linijka otwiera plik blokady do zapisu pod numerem 9. To numer kolejnego głośnika, takiego jak 1 i 2, tylko naszego własnego. Druga linijka mówi: „weź blokadę na pliku numer 9, a jeśli ktoś już ją ma, nie czekaj (-n), tylko od razu zgłoś porażkę”. Wtedy kończymy się kodem 75, czyli „chwilowy problem, spróbuj później” z poprzedniej lekcji.

Sprawdzamy. Udajemy długi raport, trzymając blokadę przez 30 sekund, i w tym czasie odpalamy skrypt:

flock /tmp/raport.lock sleep 30 &
./raport.sh
[BŁĄD] Raport już się robi (blokada: /tmp/raport.lock)
$ echo $?
75

Po 30 sekundach blokada znika i skrypt rusza normalnie.

Zamiast od razu się poddawać, możesz poczekać chwilę: flock -w 10 9 czeka najwyżej 10 sekund. Przydaje się, kiedy drugi egzemplarz ma zrobić swoje, tylko nie w tym samym czasie.

A najlepsze jest to, czego nie musisz robić. Blokada znika sama, kiedy proces się kończy. Nawet przy kill -9, nawet przy wyłączeniu prądu w środku pracy. Stary sposób z plikiem PID, w którym skrypt zapisywał „pracuję” i kasował to na końcu, miał wieczny problem: skrypt zabity w połowie zostawiał plik i następny przebieg myślał, że ktoś dalej pracuje. Klucz zostawał w kieszeni kogoś, kto dawno pojechał.

W cronie można też owinąć cały skrypt w flock bez zmieniania jego kodu:

*/5 * * * * flock -n /run/lock/raport.lock /opt/skrypty/raport.sh

Wszystko razem: raport.sh

raport/
├── raport.sh
└── lib/
    └── komunikaty.sh
#!/bin/bash
#
# raport.sh — zbiera podstawowe informacje o serwerze do jednego pliku.
# Użycie: raport.sh [-o PLIK] [-h]
# Kody wyjścia: 0 OK, 64 złe użycie, 73 nie da się zapisać, 75 już działa
#
set -euo pipefail

# ---------- stałe ----------
KATALOG_SKRYPTU=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
readonly KATALOG_SKRYPTU
readonly BLOKADA=/tmp/raport.lock
TYMCZASOWY=''        # globalna, bo sprząta ją trap po wyjściu z main

# ---------- biblioteki ----------
# shellcheck source-path=SCRIPTDIR source=lib/komunikaty.sh
source "$KATALOG_SKRYPTU/lib/komunikaty.sh"

# ---------- funkcje ----------
pomoc() {
    cat <<KONIEC
Użycie: ${0##*/} [-o PLIK] [-h]
Zbiera nazwę hosta, jądro, procesor, pamięć i dyski do pliku.
  -o PLIK  gdzie zapisać raport (domyślnie: raport-DATA.txt)
  -h       ta pomoc
KONIEC
}

zbierz() {
    echo "== Raport z $(hostname), $(date '+%F %T') =="
    echo "Jądro:     $(uname -r)"
    echo "Procesor:  $(LC_ALL=C lscpu | sed -n 's/^Model name: *//p')"
    echo "Rdzenie:   $(nproc)"
    echo
    free -h
    echo
    lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS
}

main() {
    local plik
    plik="raport-$(date +%F).txt"

    local opcja
    while getopts ":o:h" opcja; do
        case $opcja in
            o) plik=$OPTARG ;;
            h) pomoc; exit 0 ;;
            :) umrzyj 64 "Opcja -$OPTARG wymaga wartości" ;;
            '?') umrzyj 64 "Nieznana opcja: -$OPTARG" ;;
        esac
    done
    shift $((OPTIND - 1))

    # Blokada: drugi egzemplarz nie wejdzie
    exec 9> "$BLOKADA"
    flock -n 9 || umrzyj 75 "Raport już się robi (blokada: $BLOKADA)"

    # Plik tymczasowy i sprzątaczka
    TYMCZASOWY=$(mktemp)
    trap 'rm -f -- "$TYMCZASOWY"' EXIT

    zbierz > "$TYMCZASOWY"
    mv -- "$TYMCZASOWY" "$plik" || umrzyj 73 "Nie mogę zapisać: $plik"
    info "Raport zapisany: $plik"
}

main "$@"

W zbierz jest LC_ALL=C przed lscpu, bo na systemie z polskimi ustawieniami lscpu potrafi wypisać „Nazwa modelu” zamiast „Model name” i nasz sed nic by nie znalazł. Raport składamy najpierw w pliku tymczasowym, a dopiero gotowy przenosimy pod docelową nazwę. Nikt nigdy nie zobaczy raportu w połowie pisania. Jak kelner, który wynosi danie dopiero po udekorowaniu, a nie podaje surowego kotleta z obietnicą, że reszta zaraz dojdzie.

Sprawdzamy, i to z zupełnie innego folderu:

$ cd /tmp
$ ~/raport/raport.sh -o /tmp/raport.txt
[ OK ] Raport zapisany: /tmp/raport.txt
$ ~/raport/raport.sh -x
[BŁĄD] Nieznana opcja: -x
$ echo $?
64
$ ~/raport/raport.sh -o /nie/ma/raport.txt
mv: cannot move '/tmp/tmp.ohOqlaescS' to '/nie/ma/raport.txt': No such file or directory
[BŁĄD] Nie mogę zapisać: /nie/ma/raport.txt
$ echo $?
73
$ head -4 /tmp/raport.txt
== Raport z vm, 2026-11-03 16:41:19 ==
Jądro:     6.8.0-48-generic
Procesor:  Intel(R) Xeon(R) Processor @ 2.10GHz
Rdzenie:   2

Po nieudanym mv w /tmp nie zostaje żaden plik tymczasowy, bo sprzątaczka z trap zrobiła swoje.

Jak to działa pod spodem

./skrypt.sh i bash skrypt.sh tworzą nowy proces. Jądro kopiuje Twoją powłokę, w kopii uruchamia Basha i ten nowy Bash czyta plik. Kiedy skończy, proces znika razem ze wszystkimi swoimi zmiennymi i swoim bieżącym folderem.

source nie tworzy żadnego procesu. Twój Bash otwiera plik i czyta z niego komendy dokładnie tak, jakbyś wpisywał je ręcznie, jedna po drugiej. Dlatego zmienne, funkcje, cd, a nawet exit działają u Ciebie. Uwaga: exit w pliku wczytanym przez source zamyka Twój terminal. Stąd w bibliotekach return, a nie exit.

flock korzysta z wywołania systemowego flock o tej samej nazwie. Jądro przypina blokadę do otwartego pliku, a nie do nazwy na dysku. Kiedy ostatni proces, który ma ten plik otwarty, go zamknie albo umrze, jądro samo zdejmuje blokadę. Nie ma tu żadnego zapisu na dysku, który mógłby zostać po awarii. Sam plik /tmp/raport.lock zostaje, pusty, i to normalne. To łopata, nie klucz. Klucz trzyma jądro.

Ściąga flag

KomendaFlagaCo robi
source PLIK / . PLIK—wykonuje plik w bieżącej powłoce
returnNkończy funkcję albo wczytywanie pliku przez source
${BASH_SOURCE[0]}—ścieżka do pliku, w którym jest ta linijka
readonlyNAZWAstała, której nie da się zmienić
localNAZWAzmienna widoczna tylko w funkcji
shellcheck-xsprawdza też pliki wczytywane przez source
# shellchecksource= / source-path=SCRIPTDIRpodpowiedź, gdzie leży biblioteka
# shellcheckshell=bashjęzyk pliku bez shebanga
flock-nnie czekaj, od razu porażka, gdy zajęte
flock-w SEKczekaj najwyżej tyle sekund
flock-E KODkod wyjścia przy zajętej blokadzie (domyślnie 1)
flock-sblokada współdzielona (wielu czytelników naraz)
flockPLIK KOMENDAuruchamia komendę pod blokadą, wygodne w cronie
exec9> PLIKotwiera plik pod numerem 9 na cały skrypt

⚠ Typowy błąd

Wersja tego skryptu sprzed poprawki miała w main takie linijki:

    local tymczasowy
    tymczasowy=$(mktemp)
    trap 'rm -f -- "$tymczasowy"' EXIT

Wygląda porządnie. local, cudzysłowy, sprzątaczka. ShellCheck: czysto. Uruchamiamy:

$ ./raport.sh -o /tmp/raport.txt
[ OK ] Raport zapisany: /tmp/raport.txt
./raport.sh: line 1: tymczasowy: unbound variable
$ echo $?
1

Raport zapisany, a skrypt kończy się błędem. Cron wyśle maila „coś poszło nie tak”, monitoring zapali się na czerwono, a wszystko jest w porządku. Najgorszy rodzaj błędu, bo uczy ludzi ignorować alarmy.

Dlaczego? Pułapka EXIT odpala się na samym końcu skryptu, już po wyjściu z main. A wtedy zmiennej lokalnej tymczasowy już nie ma. Sprzątaczka przychodzi po imprezie z listą „posprzątać pokój Kowalskiego”, a Kowalski się wymeldował i pokoju o tej nazwie już nie ma. A set -u każe krzyczeć przy każdej nieistniejącej zmiennej.

Naprawa: zmienna, której używa trap na EXIT, musi być globalna. Deklarujemy ją na górze z pustą wartością i w main tylko przypisujemy, bez local. Dokładnie tak jest w wersji powyżej i dlatego przy tej zmiennej stoi komentarz „dlaczego”.

✎ Zadanie

Przerób skrypt z funkcją kopia na porządne narzędzie: set -euo pipefail, trap sprzątający pliki tymczasowe, opcje -d FOLDER (gdzie kopiować) i -h (pomoc) przez getopts, komunikaty błędów na stderr, blokada flock przed podwójnym uruchomieniem, czysty wynik w ShellChecku.

Pokaż rozwiązanie

Zadanie było konkretne: z małej funkcji kopia zrobić narzędzie, któremu nie wstyd dać koledze. Oto moja wersja:

#!/usr/bin/env bash
# kopia.sh — robi kopię pliku z dzisiejszą datą w nazwie
set -euo pipefail

readonly PROG=${0##*/}
readonly BLOKADA=/tmp/kopia.lock
cel=.
tmp=

blad()  { printf '%s: BŁĄD: %s\n' "$PROG" "$*" >&2; }

pomoc() {
    cat <<KONIEC
Użycie: $PROG [-d FOLDER] PLIK...
Robi kopię każdego PLIKU z dzisiejszą datą w nazwie.
  -d FOLDER   gdzie zapisać kopie (domyślnie bieżący folder)
  -h          ta pomoc
KONIEC
}

sprzataj() {
    if [[ -n $tmp ]]; then
        rm -f -- "$tmp"
    fi
}
trap sprzataj EXIT

kopia() {
    local plik=$1 nazwa
    if [[ ! -f $plik ]]; then
        blad "nie ma pliku: $plik"
        return 1
    fi
    nazwa="$cel/${plik##*/}.$(date +%F)"
    tmp=$(mktemp "$cel/.kopia.XXXXXX")
    if ! cp -p -- "$plik" "$tmp"; then
        blad "nie udało się skopiować: $plik"
        return 1
    fi
    mv -- "$tmp" "$nazwa"
    tmp=
    echo "Zrobione: $nazwa"
}

main() {
    local opcja plik kod
    while getopts ':d:h' opcja; do
        case $opcja in
            d) cel=$OPTARG ;;
            h) pomoc; exit 0 ;;
            :) blad "opcja -$OPTARG wymaga argumentu"; pomoc >&2; exit 2 ;;
            *) blad "nieznana opcja: -$OPTARG"; pomoc >&2; exit 2 ;;
        esac
    done
    shift $((OPTIND - 1))

    if (( $# == 0 )); then
        blad "podaj co najmniej jeden plik"
        pomoc >&2
        exit 2
    fi
    if [[ ! -d $cel ]]; then
        blad "nie ma folderu: $cel"
        exit 1
    fi

    exec 9>"$BLOKADA"
    if ! flock -n 9; then
        blad "kopia już działa w innym oknie"
        exit 1
    fi

    kod=0
    for plik in "$@"; do
        kopia "$plik" || kod=1
    done
    return "$kod"
}

main "$@"

Przejdźmy po kolei. set -euo pipefail to nasz kierowca, który staje przy pierwszej kontrolce. trap sprzataj EXIT to sprzątaczka na koniec imprezy: cokolwiek się stanie, wychodząc, zabierze plik tymczasowy. A po co w ogóle plik tymczasowy? Bo kopiujemy najpierw do .kopia.XXXXXX, a dopiero na końcu zmieniamy nazwę. Jeśli ktoś przerwie skrypt w połowie kopiowania dużego pliku, nie zostanie pół kopii udającej całą. Zostanie nic. A „nic” jest uczciwsze niż „pół”.

getopts ':d:h' czyta opcje: dwukropek po d znaczy „ta opcja chce argumentu”, dwukropek na początku znaczy „błędy zgłaszam sam, po polsku”. Błędy idą przez >&2, czyli drugim głośnikiem. flock -n 9 zakłada blokadę na pliku /tmp/kopia.lock, a -n znaczy: nie czekaj w kolejce, tylko od razu powiedz, że zajęte. Wszystko jest w funkcji main, bo tak wygląda porządny duży skrypt z poprzedniej lekcji.

Sprawdzamy:

shellcheck kopia.sh && echo "czysto"
./kopia.sh -d kopie notatki.txt nie_ma.txt; echo "kod=$?"
./kopia.sh -d; echo "kod=$?"
flock /tmp/kopia.lock sleep 3 &
./kopia.sh notatki.txt; echo "kod=$?"
czysto
Zrobione: kopie/notatki.txt.2026-11-03
kopia.sh: BŁĄD: nie ma pliku: nie_ma.txt
kod=1
kopia.sh: BŁĄD: opcja -d wymaga argumentu
Użycie: kopia.sh [-d FOLDER] PLIK...
...
kod=2
kopia.sh: BŁĄD: kopia już działa w innym oknie
kod=1

Trzecia próba to symulacja: flock ... sleep 3 trzyma blokadę przez trzy sekundy, udając drugie uruchomienie. Testowałem też zabicie skryptu w trakcie kopiowania pliku półtoragigabajtowego: plik .kopia.… był w folderze, po kill zniknął. Sprzątaczka zadziałała, choć nikt jej nie podziękował. Jak zwykle.

Co dalej

Na tym kończymy moduł o skryptach. Twoje skrypty już nie sypią się od spacji, nie zostawiają śmieci, mówią, co się stało, i nie wchodzą sobie w drogę. W następnym module schodzimy piętro niżej, do dysków. Pierwsza lekcja: ile miejsca naprawdę zostało, co zjada dysk i dlaczego czasem dysk jest pełny, a du uparcie twierdzi, że nic tam nie ma.