09/10 -Automate Your Workflow with Shell Scripting
You've learned how to navigate directories, search files, manage processes, troubleshoot networks, and use powerful command-line tools.
But there's another level of terminal mastery:
What if you could turn a sequence of commands into a reusable program?
Instead of typing the same commands every morning, you could run one script. Instead of manually creating project folders, you could generate them automatically. Instead of copying files one by one, you could build a backup workflow that does the job for you.
That's what shell scripting is all about.
In this article, we'll learn how to write practical scripts for:
- Linux and macOS using Bash
- Windows using PowerShell
- Variables, arguments, conditions, loops, and functions
- Error handling and exit codes
- Automating real developer workflows
- Debugging and maintaining scripts
By the end, you'll have the knowledge to automate repetitive tasks across all three operating systems.
1. What Is a Shell Script?
A shell script is a file containing commands that a shell executes in sequence.
Think about a task you perform regularly:
- Create a project directory.
- Create
src,tests, anddocsfolders. - Add a README file.
- Create a
.gitignorefile. - Print a success message.
You could execute every command manually.
Or you could save those commands in a file and run that file whenever you need the same setup.
That's a script.
Commands vs. scripts
A command is usually a single instruction:
mkdir my-project
A script can combine multiple instructions, variables, conditions, loops, and functions:
#!/usr/bin/env bash
mkdir -p my-project/src
mkdir -p my-project/tests
mkdir -p my-project/docs
echo "Project structure created!"
The important difference is reusability.
You write the workflow once and execute it whenever you need it.
2. Bash vs. PowerShell
Before writing scripts, let's understand the two environments we'll use.
| Feature | Bash | PowerShell |
|---|---|---|
| Common platforms | Linux, macOS | Windows, Linux, macOS |
| Typical extension | .sh |
.ps1 |
| Common interactive shell | Bash / Zsh | PowerShell |
| Variables | $name |
$name |
| Comments | # |
# |
| Main pipeline model | Text streams | Objects |
| Common script command | bash script.sh |
.\script.ps1 |
Bash is widely used on Linux servers and macOS development machines.
PowerShell is the primary scripting environment in modern Windows administration, but it also runs on Linux and macOS.
You don't need to learn both at once. Start with the environment you use most often, then learn the other.
3. Creating Your First Script
Let's create a simple script that prints a message.
Bash
Create a file named hello.sh:
#!/usr/bin/env bash
echo "Hello from Bash!"
echo "Welcome to shell scripting."
The first line is called a shebang:
#!/usr/bin/env bash
It tells the operating system which interpreter should execute the script when you run it directly.
Run it using:
bash hello.sh
On Linux and macOS, you can also make it executable:
chmod +x hello.sh
Then run:
./hello.sh
PowerShell
Create a file named hello.ps1:
Write-Host "Hello from PowerShell!"
Write-Host "Welcome to shell scripting."
Run it from PowerShell:
.\hello.ps1
Notice the .\ prefix.
It tells PowerShell to execute a script from the current directory.
A note about PowerShell execution policies
You might encounter an error saying that script execution is disabled.
Check your current policies with:
Get-ExecutionPolicy -List
PowerShell execution policies control the conditions under which scripts can run. They are not a complete security boundary.
On managed computers, follow your organization's policy rather than changing it yourself. Avoid setting the policy to Unrestricted as a general workaround.
If you are using a personal machine, understand the scope and implications of any policy change before making one.
4. Variables: Store and Reuse Values
Variables let you store information and use it throughout a script.
Bash
#!/usr/bin/env bash
name="Hamed"
language="Bash"
echo "Hello, $name!"
echo "You are learning $language."
Important: Bash does not allow spaces around the assignment operator.
Correct:
name="Hamed"
Incorrect:
name = "Hamed"
PowerShell
$name = "Hamed"
$language = "PowerShell"
Write-Host "Hello, $name!"
Write-Host "You are learning $language."
PowerShell variable names begin with $.
Why variables matter
Imagine writing a backup script that copies a project directory.
Without variables, you might hardcode the same directory in several places.
With variables, you define it once:
project_dir="/home/user/my-project"
Then reuse it:
echo "Backing up $project_dir"
This makes scripts easier to update and maintain.
5. Getting Input from the User
Scripts become more flexible when users can provide information.
Bash: read
#!/usr/bin/env bash
read -r -p "What is your name? " name
echo "Hello, $name!"
The read command waits for user input and stores it in a variable.
The -r option prevents backslashes from being interpreted as escape characters.
PowerShell: Read-Host
$name = Read-Host "What is your name?"
Write-Host "Hello, $name!"
Both scripts ask a question and use the answer.
This is useful when building setup scripts, interactive tools, and small automation utilities.
6. Passing Arguments to Scripts
Sometimes you don't want a script to ask questions.
You want to provide information when executing it.
For example:
bash greet.sh Hamed
Bash arguments
Inside a Bash script:
| Variable | Meaning |
|---|---|
$0 |
Script name |
$1 |
First argument |
$2 |
Second argument |
$@ |
All arguments |
$# |
Number of arguments |
Example:
#!/usr/bin/env bash
name="${1:-Developer}"
echo "Hello, $name!"
Run it:
bash greet.sh Hamed
Output:
Hello, Hamed!
If no argument is provided, the script uses Developer.
One important rule: quote arguments when passing them to other commands.
some_command "$@"
This preserves arguments containing spaces and prevents unwanted word splitting.
PowerShell arguments
PowerShell supports a param block:
param(
[string]$Name = "Developer",
[int]$Age = 25
)
Write-Host "Name: $Name"
Write-Host "Age: $Age"
Run it:
.\greet.ps1 -Name "Hamed" -Age 30
Named parameters make PowerShell scripts easier to understand and maintain.
For reusable scripts, prefer param() over manually reading values from $args.
7. Conditions: Make Decisions in Your Scripts
A script should sometimes behave differently depending on the situation.
For example:
- Does a directory exist?
- Was a required argument provided?
- Is a file empty?
- Did a command succeed?
That's where conditions come in.
Bash: if
#!/usr/bin/env bash
name="${1:-}"
if [[ -z "$name" ]]; then
echo "Please provide a name."
else
echo "Hello, $name!"
fi
Here, -z checks whether a string is empty.
Some useful Bash comparisons:
| Operator | Meaning |
|---|---|
-z |
String is empty |
-n |
String is not empty |
== |
Strings are equal |
-eq |
Numbers are equal |
-gt |
Greater than |
-lt |
Less than |
-f |
Regular file exists |
-d |
Directory exists |
Example:
age=20
if [[ "$age" -ge 18 ]]; then
echo "You are an adult."
else
echo "You are under 18."
fi
PowerShell: if, elseif, and else
$age = 20
if ($age -ge 18) {
Write-Host "You are an adult."
}
elseif ($age -ge 13) {
Write-Host "You are a teenager."
}
else {
Write-Host "You are a child."
}
Some useful PowerShell comparison operators:
| Operator | Meaning |
|---|---|
-eq |
Equal |
-ne |
Not equal |
-gt |
Greater than |
-lt |
Less than |
-ge |
Greater than or equal |
-le |
Less than or equal |
-like |
Wildcard matching |
-match |
Regular expression matching |
Unlike many programming languages, PowerShell uses comparison operators such as -eq and -gt.
8. Loops: Repeat Tasks Automatically
Loops are especially useful when processing multiple files or directories.
Imagine you have 100 text files and want to perform the same operation on each one.
You don't need to write the command 100 times.
Bash: for
#!/usr/bin/env bash
for name in Alice Bob Charlie; do
echo "Hello, $name!"
done
You can also loop through files:
for file in *.txt; do
echo "Processing: $file"
done
This example uses filename expansion.
One detail: if no files match *.txt, Bash normally leaves the pattern unchanged. For scripts where that matters, consider enabling nullglob or checking whether the file exists before processing it.
Bash: while
counter=1
while [[ "$counter" -le 5 ]]; do
echo "Count: $counter"
((counter++))
done
PowerShell: foreach
$names = @("Alice", "Bob", "Charlie")
foreach ($name in $names) {
Write-Host "Hello, $name!"
}
PowerShell: for
for ($i = 1; $i -le 5; $i++) {
Write-Host "Count: $i"
}
PowerShell: while
$counter = 1
while ($counter -le 5) {
Write-Host "Count: $counter"
$counter++
}
Loops are the foundation of file processing, batch operations, and repetitive automation.
9. Functions: Organize Your Scripts
As scripts become larger, repeating the same code makes them harder to maintain.
Functions solve this problem.
Bash functions
#!/usr/bin/env bash
greet() {
local name="$1"
echo "Hello, $name!"
}
greet "Hamed"
greet "Developer"
The local keyword keeps the variable inside the function's scope.
A useful Bash detail: return is primarily used to return an exit status, not a string value. To produce text from a function, use standard output.
For example:
get_greeting() {
printf 'Hello, %s!\n' "$1"
}
message="$(get_greeting "Hamed")"
echo "$message"
PowerShell functions
function Get-Greeting {
param(
[string]$Name
)
"Hello, $Name!"
}
$message = Get-Greeting -Name "Hamed"
Write-Host $message
PowerShell functions can emit objects through the pipeline. Their output is not limited to strings.
This is one of the major differences between Bash and PowerShell: PowerShell is designed around structured objects rather than treating everything as plain text.
10. Exit Codes: Know Whether a Command Succeeded
Automation isn't just about running commands.
It's also about knowing whether they worked.
Most command-line programs communicate success or failure through an exit code.
A common convention is:
-
0: Success - Non-zero: Some kind of failure
Bash
The special variable $? contains the exit status of the most recently executed command.
mkdir test-folder
if [[ "$?" -eq 0 ]]; then
echo "Directory created successfully."
else
echo "Something went wrong."
fi
A more reliable approach is to use the command directly in a condition:
if mkdir test-folder; then
echo "Directory created successfully."
else
echo "Could not create directory."
fi
You can also explicitly exit a script:
exit 0
Or indicate failure:
exit 1
PowerShell
PowerShell has two important variables:
-
$?: Indicates whether the most recent PowerShell operation succeeded. -
$LASTEXITCODE: Contains the exit code returned by the last native executable.
For example:
node --version
if ($LASTEXITCODE -eq 0) {
Write-Host "Node.js is available."
}
else {
Write-Host "Node.js command failed."
}
Don't confuse $? with $LASTEXITCODE. When working with external programs such as node, git, or npm, check $LASTEXITCODE when you need the native program's actual exit code.
11. Error Handling: Make Scripts More Reliable
A script that works only when everything goes perfectly isn't very useful.
Real automation needs to handle unexpected situations.
Bash: safer execution
You'll often see this near the beginning of Bash scripts:
set -Eeuo pipefail
Here's what the options generally mean:
-
-e: Exit when an eligible command fails. -
-E: AllowERRtraps to be inherited by functions and certain command contexts. -
-u: Treat unset variables as errors. -
-o pipefail: Make a pipeline fail if a command within it fails, rather than only considering the last command.
These options improve reliability, but they aren't a complete error-handling system. In particular, -e has exceptions and context-dependent behavior. Explicitly check important operations.
For example:
#!/usr/bin/env bash
set -Eeuo pipefail
if ! mkdir -p "my-project"; then
echo "Could not create project directory." >&2
exit 1
fi
echo "Project directory is ready."
The >&2 redirects the error message to standard error.
PowerShell: try and catch
PowerShell provides structured exception handling:
$ErrorActionPreference = "Stop"
try {
New-Item -ItemType Directory -Path "my-project"
Write-Host "Project directory created."
}
catch {
Write-Error "Something went wrong: $_"
}
finally {
Write-Host "Operation finished."
}
The three main blocks:
-
try: Code that might fail. -
catch: Handles terminating errors. -
finally: Runs whether an error occurs or not.
Setting $ErrorActionPreference to Stop makes many otherwise non-terminating PowerShell errors catchable.
However, native programs have their own exit-code behavior. Check $LASTEXITCODE separately when running external executables.
12. Practical Project: Automatically Create a Project Structure
Let's put our knowledge into practice.
Suppose you frequently create small development projects and want a standard directory structure.
Every project should contain:
my-project/
├── src/
├── tests/
├── docs/
├── README.md
└── .gitignore
We'll create one script for Bash and another for PowerShell.
Bash version
Create create-project.sh:
#!/usr/bin/env bash
set -Eeuo pipefail
PROJECT_NAME="${1:-my-project}"
if [[ -e "$PROJECT_NAME" ]]; then
echo "Error: '$PROJECT_NAME' already exists." >&2
exit 1
fi
mkdir -p "$PROJECT_NAME"/{src,tests,docs}
cd "$PROJECT_NAME"
cat > README.md <<EOF
# $PROJECT_NAME
Project documentation.
EOF
cat > .gitignore <<'EOF'
node_modules/
dist/
.env
EOF
echo "Project '$PROJECT_NAME' created successfully!"
Make it executable:
chmod +x create-project.sh
Run it:
./create-project.sh my-awesome-app
PowerShell version
Create Create-Project.ps1:
param(
[string]$ProjectName = "my-project"
)
$ErrorActionPreference = "Stop"
if (Test-Path -LiteralPath $ProjectName) {
throw "The project '$ProjectName' already exists."
}
New-Item -ItemType Directory -Path $ProjectName | Out-Null
$directories = @("src", "tests", "docs")
foreach ($directory in $directories) {
New-Item -ItemType Directory `
-Path (Join-Path $ProjectName $directory) |
Out-Null
}
$readme = "# $ProjectName`n`nProject documentation."
$gitignore = @"
node_modules/
dist/
.env
"@
Set-Content `
-Path (Join-Path $ProjectName "README.md") `
-Value $readme
Set-Content `
-Path (Join-Path $ProjectName ".gitignore") `
-Value $gitignore
Write-Host "Project '$ProjectName' created successfully!"
Run it:
.\Create-Project.ps1 -ProjectName "my-awesome-app"
Both scripts create the same basic structure.
That's the beauty of automation: different shells, same outcome.
13. Practical Project: Create a Backup Script
Let's build something more useful.
Imagine you have a project directory that you want to back up regularly.
Instead of manually copying it, we'll create a script that:
- Checks whether the source directory exists.
- Creates a backup directory if necessary.
- Adds a timestamp to the backup name.
- Copies the source directory.
- Prints the backup location.
Bash backup script
Create backup.sh:
#!/usr/bin/env bash
set -Eeuo pipefail
SOURCE="${1:-}"
BACKUP_ROOT="${2:-$HOME/backups}"
if [[ -z "$SOURCE" ]]; then
echo "Usage: ./backup.sh <source-directory> [backup-directory]" >&2
exit 1
fi
if [[ ! -d "$SOURCE" ]]; then
echo "Error: Source directory does not exist." >&2
exit 1
fi
SOURCE="$(cd -- "$SOURCE" && pwd)"
mkdir -p "$BACKUP_ROOT"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
PROJECT_NAME="$(basename "$SOURCE")"
DESTINATION="$BACKUP_ROOT/${PROJECT_NAME}-${TIMESTAMP}"
cp -a "$SOURCE" "$DESTINATION"
echo "Backup completed successfully!"
echo "Saved to: $DESTINATION"
Run it:
chmod +x backup.sh
./backup.sh ~/projects/my-app
Or provide a custom backup location:
./backup.sh ~/projects/my-app ~/external-backups
The cp -a option preserves important file attributes where supported and copies directories recursively.
PowerShell backup script
Create Backup-Project.ps1:
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[string]$BackupRoot = (Join-Path $HOME "backups")
)
$ErrorActionPreference = "Stop"
if (-not (Test-Path -LiteralPath $Source -PathType Container)) {
throw "Source directory does not exist: $Source"
}
$Source = (Resolve-Path -LiteralPath $Source).Path
if (-not (Test-Path -LiteralPath $BackupRoot)) {
New-Item -ItemType Directory -Path $BackupRoot |
Out-Null
}
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$projectName = Split-Path -Path $Source -Leaf
$destination = Join-Path `
$BackupRoot `
"$projectName-$timestamp"
Copy-Item `
-LiteralPath $Source `
-Destination $destination `
-Recurse
Write-Host "Backup completed successfully!"
Write-Host "Saved to: $destination"
Run it:
.\Backup-Project.ps1 -Source "C:\Projects\My App"
Or specify another backup directory:
.\Backup-Project.ps1 `
-Source "C:\Projects\My App" `
-BackupRoot "D:\Backups"
Notice how we quote paths containing spaces.
This is an important habit in shell scripting. Paths are not always simple strings without spaces.
Important: These examples create new timestamped backups. They don't automatically delete older backups. That's intentional: deleting backups is a destructive operation and should be added only with a clear retention policy and careful testing.
14. Script Paths: Don't Depend on the Current Directory
Here's a common scripting mistake.
You write a script that reads a file:
cat config.json
It works when you run the script from its own directory.
But when you run it from somewhere else, it fails.
Why?
Because the relative path is resolved from the current working directory, not necessarily from the script's location.
Bash: find the script directory
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json"
cat "$CONFIG_FILE"
Now the script can locate config.json relative to itself.
PowerShell: use $PSScriptRoot
$configFile = Join-Path $PSScriptRoot "config.json"
Get-Content $configFile
$PSScriptRoot contains the directory where the current script is stored.
This is especially useful when distributing scripts to other developers or running them through automation tools.
15. Debugging Your Scripts
Scripts don't always work on the first attempt.
The ability to debug them is just as important as the ability to write them.
Bash debugging
Run a script with execution tracing:
bash -x script.sh
Bash prints commands as they execute.
For example:
#!/usr/bin/env bash
name="Hamed"
echo "Hello, $name!"
Running it with bash -x shows the expanded commands and helps you understand what happened.
PowerShell debugging
Enable tracing:
Set-PSDebug -Trace 1
.\script.ps1
Set-PSDebug -Off
PowerShell displays additional execution information.
Remember to turn tracing off after debugging.
Common scripting mistakes
| Problem | Possible cause |
|---|---|
| Command not found | Tool isn't installed or isn't in PATH
|
| Permission denied | Missing execute permission or insufficient access |
| File not found | Incorrect working directory or path |
| Unexpected variable value | Incorrect quoting or variable expansion |
| Script works manually but fails elsewhere | Environment or working-directory differences |
| PowerShell script won't run | Execution policy or script invocation issue |
| Pipeline reports success unexpectedly | Exit code or pipeline error handling isn't being checked |
When debugging, don't immediately assume the shell is broken.
Check the command, its arguments, the current directory, permissions, and environment first.
16. Best Practices for Writing Maintainable Scripts
A script is still software.
Treat it with the same care you would give to an application.
1. Use meaningful names
Avoid:
x="my-project"
Prefer:
project_name="my-project"
2. Quote paths and variables
Bash:
mkdir -p "$project_name"
PowerShell:
New-Item -ItemType Directory -Path $projectName
Quoting and using literal-path parameters appropriately helps avoid problems with spaces and special characters.
3. Validate input
Don't assume users will always provide the correct arguments.
Check required values and verify that files or directories exist before using them.
4. Avoid hardcoding secrets
Never store passwords, API keys, or access tokens directly in scripts that might be committed to Git.
Use environment variables or an appropriate secret-management solution.
5. Make failures visible
Print useful error messages and return meaningful exit codes.
A script should tell the user what went wrong, not silently continue.
6. Avoid destructive operations by default
Be especially careful with commands such as:
rm -rf
And their PowerShell equivalents:
Remove-Item -Recurse -Force
Test scripts with temporary directories and sample files before using them on important data.
7. Keep scripts focused
A script that does one thing well is usually easier to understand, test, and reuse than one enormous script that handles everything.
17. Practice: Build Your Own Automation Scripts
Now it's your turn.
Try these exercises without looking at the examples above.
Exercise 1: Personal Greeting
Create a script that:
- Accepts a user's name.
- Accepts their programming language.
- Prints a personalized message.
- Uses default values if arguments are missing.
Exercise 2: File Organizer
Create a script that:
- Accepts a directory path.
- Finds all
.jpgfiles. - Creates an
imagesdirectory. - Moves the matching files into that directory.
Test it on a temporary folder first.
Exercise 3: Project Generator
Improve the project generator from this article.
Add:
- A choice between JavaScript and TypeScript.
- A
src/componentsdirectory. - A
src/utilsdirectory. - A
tests/unitdirectory. - A generated README containing the project name and creation date.
Exercise 4: Automated Backup
Improve the backup script.
Add:
- A check that the destination is writable.
- A log file containing backup results.
- A clear error message if copying fails.
- A way to choose a custom backup location.
Exercise 5: Cross-Platform Challenge
Write the same small automation task twice:
- Once in Bash.
- Once in PowerShell.
Compare the two implementations.
Which language feels more natural? Which one handles structured data more conveniently? What differences did you notice?
18. Quick Reference: Bash vs. PowerShell
| Task | Bash | PowerShell |
|---|---|---|
| Print text | echo "Hello" |
Write-Host "Hello" |
| Create variable | name="Hamed" |
$name = "Hamed" |
| Read input | read -r name |
Read-Host |
| First argument | $1 |
param() |
| Condition | if [[ ... ]] |
if (...) |
| Loop | for ...; do |
foreach (...) |
| Function | greet() { } |
function Greet { } |
| Create directory | mkdir -p app |
New-Item -ItemType Directory app |
| Check file | [[ -f file ]] |
Test-Path file -PathType Leaf |
| Copy directory | cp -a source destination |
Copy-Item source destination -Recurse |
| Exit code | $? |
$LASTEXITCODE for native programs |
| Debug | bash -x script.sh |
Set-PSDebug -Trace 1 |
Conclusion
You've now moved beyond executing individual terminal commands.
You know how to:
- Create reusable shell scripts.
- Work with variables and arguments.
- Accept user input.
- Make decisions with conditions.
- Repeat operations with loops.
- Organize code using functions.
- Handle failures and exit codes.
- Automate project creation and backups.
- Debug scripts across different environments.
The real value of scripting isn't just saving a few keystrokes.
It's turning repetitive work into reliable, reusable workflows.
And once you start automating your everyday tasks, you'll notice that many terminal operations can become much simpler.
Next up:
In Article 10, we'll bring everything together with advanced terminal workflows and troubleshooting. We'll explore how to combine commands, investigate difficult problems, improve productivity, and build a more professional command-line workflow across Windows, macOS, and Linux.
See you in the final chapter!
Tags: terminal automation bash powershell
Series: Terminal Mastery: Windows, macOS & Linux
Reading level: Beginner to Intermediate
Top comments (0)