Skip to content

Instantly share code, notes, and snippets.

@joseph-d
Last active March 24, 2025 12:36
Show Gist options
  • Save joseph-d/390419a7213cf2c8ccf9742b17d48e85 to your computer and use it in GitHub Desktop.
Save joseph-d/390419a7213cf2c8ccf9742b17d48e85 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
#
# setup_project.sh
#
# ------------------------------------------------------------------------------------------
# ABOUT THIS SCRIPT
# ------------------------------------------------------------------------------------------
# This script sets up a new Laravel Sail + DevContainer project on Linux distros
# It does the following:
#
# 1) Prompts the user for:
# - Project Name (e.g. "My Project")
# - Project Code (camelCase derived from project name by default, e.g. "myProject")
# - Project Root (e.g. "~/code/sail/my-project")
#
# 2) Changes directory to the chosen project root, then runs:
# composer require laravel/sail --dev
# php artisan sail:install --devcontainer
# to ensure the project has Laravel Sail with a DevContainer configuration.
#
# 3) Extracts the *default ports* for:
# APP_PORT, FORWARD_DB_PORT, VITE_PORT,
# FORWARD_REDIS_PORT, FORWARD_MAILPIT_DASHBOARD_PORT, FORWARD_MAILPIT_PORT
# from `docker-compose.yml` lines like:
# - ${APP_PORT:-80}:80
# - ${FORWARD_DB_PORT:-5432}:5432
# - ${VITE_PORT:-5173}:${VITE_PORT:-5173}
# - ${FORWARD_REDIS_PORT:-6379}:6379
# - ${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025
# - ${FORWARD_MAILPIT_PORT:-1025}:1025
#
# If any default is not found for a variable, the script uses a fallback:
# - APP_PORT => 80
# - FORWARD_DB_PORT => 5432
# - VITE_PORT => 5173
# - FORWARD_REDIS_PORT => 6379
# - FORWARD_MAILPIT_DASHBOARD_PORT => 8025
# - FORWARD_MAILPIT_PORT => 1025
#
# 4) Recursively scans all `.env` files under `~/code/sail/` to find if there
# are any existing used values for those ports. If it finds any, the
# script tracks the highest usage. If nothing is found (i.e. 0 usage),
# it uses the docker-compose default for that variable. Otherwise, it uses
# (max usage + 1).
#
# 5) Modifies:
# A) .devcontainer/devcontainer.json
# - Removes line 1 entirely (often "// https://aka.ms/devcontainer.json").
# - Removes all lines with "//" comments.
# - Removes keys: forwardPorts, runServices, shutdownAction
# - Sets ".name" => projectName, ".service" => projectCode
# - Replaces "customizations.vscode.extensions" with a set of Laravel/PHP dev extensions
# - Then re-inserts "// https://aka.ms/devcontainer.json" as line 1
#
# B) docker-compose.yml
# - Renames the "laravel.test" service to the chosen project code (e.g. "myProject")
# - Appends a set of Traefik labels to that service
# - Adds "proxy" to that service's networks
# - If services named "pgsql", "mysql", or "redis" exist, inserts:
# container_name: <projectNameInKebabCase>-<service>
# immediately after their "image:" line
# - Ensures "networks.proxy.external = true" at the top-level
#
# C) The project's ".env" file
# - Sets "APP_URL" => http://<projectCode>.docker.localhost
# - Sets "APP_NAME" => <projectName>
# - Sets "APP_PORT" to either the max usage found + 1, or the default from
# docker-compose if none existed in `~/code/sail/`.
# - Similarly sets "FORWARD_DB_PORT", "VITE_PORT",
# "FORWARD_REDIS_PORT", "FORWARD_MAILPIT_DASHBOARD_PORT", and "FORWARD_MAILPIT_PORT".
#
# 6) At the end, prints a summary of all the changes made.
#
# REQUIREMENTS:
# - PHP and Composer (to run "composer require laravel/sail" and "php artisan sail:install")
# - Python-based "yq" (the jq-wrapper for YAML) for editing docker-compose.yml
# - "jq" for editing JSON devcontainer files
#
# NOTE ON INDENTATION:
# The Python-based "yq" automatically uses 2-space indentation. If 4-space indentation
# is critical, you'd need to either:
# a) Switch to Mike Farah's Go-based yq and adjust syntax accordingly, or
# b) Post-process with sed or another tool to convert indentation.
#
# AUTHOR:
# Joseph D
# DATE:
# 2025-03-24
# ------------------------------------------------------------------------------------------
set -e
# -----------------------------------------------------------------------------
# 0) Check for yq (python-based) and jq, plus composer/php
# -----------------------------------------------------------------------------
if ! command -v yq &> /dev/null; then
echo "ERROR: yq (Python-based) is not installed or not in PATH."
echo " Please install via pip or your package manager."
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "ERROR: jq is not installed or not in PATH. Please install jq."
exit 1
fi
if ! command -v composer &> /dev/null; then
echo "WARNING: 'composer' is not found in PATH. The script will fail when trying to run 'composer require'."
fi
if ! command -v php &> /dev/null; then
echo "WARNING: 'php' is not found in PATH. The script will fail when trying to run 'php artisan sail:install'."
fi
# -----------------------------------------------------------------------------
# Helper function to insert container_name: lines via a sed script
# -----------------------------------------------------------------------------
insert_container_name() {
local db="$1"
local name="$2" # projectNameKebabCase
local file="$3"
local sedScript='/^\s*REPLACE_DB:\s/,/^[^[:space:]]/ {
/^\s*image:\s.*/a\
container_name: REPLACE_NAME-REPLACE_DB
}'
sedScript="${sedScript//REPLACE_DB/$db}"
sedScript="${sedScript//REPLACE_NAME/$name}"
sed -i -e "$sedScript" "$file"
}
# -----------------------------------------------------------------------------
# 1) Ask for project name
# -----------------------------------------------------------------------------
read -rp "Enter Project Name (e.g., My Project): " projectName
if [[ -z "$projectName" ]]; then
echo "Project name cannot be empty."
exit 1
fi
# -----------------------------------------------------------------------------
# 2) Derive default project code from project name (camelCase) and prompt
# -----------------------------------------------------------------------------
camelCased="$(echo "$projectName" \
| sed -E 's/(^| )([a-z])/\U\2/g; s/ //g;' \
| sed -E 's/^([A-Z])/\L\1/g')"
read -rp "Enter Project Code [default: $camelCased]: " projectCode
projectCode="${projectCode:-$camelCased}"
# Make a kebab-case version
projectCodeKebabCase="$(echo "$projectCode" \
| sed 's/\([A-Z]\)/-\L\1/g' \
| sed 's/^-//')"
# -----------------------------------------------------------------------------
# 3) Ask user for project root
# -----------------------------------------------------------------------------
defaultRoot="${HOME}/code/sail/${projectCodeKebabCase}"
read -rp "Enter Project Root [default: $defaultRoot]: " projectRoot
projectRoot="${projectRoot:-$defaultRoot}"
devcontainerJsonFile="${projectRoot}/.devcontainer/devcontainer.json"
dockerComposeFile="${projectRoot}/docker-compose.yml"
envFile="${projectRoot}/.env"
# -----------------------------------------------------------------------------
# STEP A: Move into the project root, install Sail devcontainer
# -----------------------------------------------------------------------------
echo "Changing directory to: $projectRoot"
cd "$projectRoot" || {
echo "ERROR: Could not cd into $projectRoot"
exit 1
}
echo "Installing Laravel Sail and devcontainer..."
composer require laravel/sail --dev
php artisan sail:install --devcontainer
echo "Sail devcontainer installation complete."
# Quick checks in case sail:install changed anything
if [[ ! -f "$devcontainerJsonFile" ]]; then
echo "ERROR: devcontainer.json not found at: $devcontainerJsonFile"
exit 1
fi
if [[ ! -f "$dockerComposeFile" ]]; then
echo "ERROR: docker-compose.yml not found at: $dockerComposeFile"
exit 1
fi
if [[ ! -f "$envFile" ]]; then
echo "ERROR: .env file not found at: $envFile"
exit 1
fi
# -----------------------------------------------------------------------------
# STEP B: Extract default ports from docker-compose.yml
# -----------------------------------------------------------------------------
echo "Extracting default ports from docker-compose.yml..."
defaultAppPort="$(grep -oP '\${APP_PORT:-\K[0-9]+' "$dockerComposeFile" | head -n1)"
[ -z "$defaultAppPort" ] && defaultAppPort=80
defaultDbPort="$(grep -oP '\${FORWARD_DB_PORT:-\K[0-9]+' "$dockerComposeFile" | head -n1)"
[ -z "$defaultDbPort" ] && defaultDbPort=5432
defaultVitePort="$(grep -oP '\${VITE_PORT:-\K[0-9]+' "$dockerComposeFile" | head -n1)"
[ -z "$defaultVitePort" ] && defaultVitePort=5173
defaultRedisPort="$(grep -oP '\${FORWARD_REDIS_PORT:-\K[0-9]+' "$dockerComposeFile" | head -n1)"
[ -z "$defaultRedisPort" ] && defaultRedisPort=6379
defaultMailpitDashboardPort="$(grep -oP '\${FORWARD_MAILPIT_DASHBOARD_PORT:-\K[0-9]+' "$dockerComposeFile" | head -n1)"
[ -z "$defaultMailpitDashboardPort" ] && defaultMailpitDashboardPort=8025
defaultMailpitPort="$(grep -oP '\${FORWARD_MAILPIT_PORT:-\K[0-9]+' "$dockerComposeFile" | head -n1)"
[ -z "$defaultMailpitPort" ] && defaultMailpitPort=1025
echo "Defaults from docker-compose.yml (or fallback):"
echo " APP_PORT => $defaultAppPort"
echo " FORWARD_DB_PORT => $defaultDbPort"
echo " VITE_PORT => $defaultVitePort"
echo " FORWARD_REDIS_PORT => $defaultRedisPort"
echo " FORWARD_MAILPIT_DASHBOARD_PORT => $defaultMailpitDashboardPort"
echo " FORWARD_MAILPIT_PORT => $defaultMailpitPort"
# -----------------------------------------------------------------------------
# STEP C: Scan ~/code/sail/ for .env files to find maximum usage
# -----------------------------------------------------------------------------
echo "Scanning ~/code/sail/ for .env files to find maximum used ports..."
maxAppPort=0
maxDbPort=0
maxVitePort=0
maxRedisPort=0
maxMailpitDashboardPort=0
maxMailpitPort=0
while IFS= read -r envPath; do
valApp="$(grep -E '^APP_PORT=' "$envPath" 2>/dev/null | cut -d= -f2 || true)"
if [[ "$valApp" =~ ^[0-9]+$ ]]; then
(( valApp > maxAppPort )) && maxAppPort="$valApp"
fi
valDb="$(grep -E '^FORWARD_DB_PORT=' "$envPath" 2>/dev/null | cut -d= -f2 || true)"
if [[ "$valDb" =~ ^[0-9]+$ ]]; then
(( valDb > maxDbPort )) && maxDbPort="$valDb"
fi
valVite="$(grep -E '^VITE_PORT=' "$envPath" 2>/dev/null | cut -d= -f2 || true)"
if [[ "$valVite" =~ ^[0-9]+$ ]]; then
(( valVite > maxVitePort )) && maxVitePort="$valVite"
fi
valRedis="$(grep -E '^FORWARD_REDIS_PORT=' "$envPath" 2>/dev/null | cut -d= -f2 || true)"
if [[ "$valRedis" =~ ^[0-9]+$ ]]; then
(( valRedis > maxRedisPort )) && maxRedisPort="$valRedis"
fi
valMailpitDashboard="$(grep -E '^FORWARD_MAILPIT_DASHBOARD_PORT=' "$envPath" 2>/dev/null | cut -d= -f2 || true)"
if [[ "$valMailpitDashboard" =~ ^[0-9]+$ ]]; then
(( valMailpitDashboard > maxMailpitDashboardPort )) && maxMailpitDashboardPort="$valMailpitDashboard"
fi
valMailpitPort="$(grep -E '^FORWARD_MAILPIT_PORT=' "$envPath" 2>/dev/null | cut -d= -f2 || true)"
if [[ "$valMailpitPort" =~ ^[0-9]+$ ]]; then
(( valMailpitPort > maxMailpitPort )) && maxMailpitPort="$valMailpitPort"
fi
done < <(find "${HOME}/code/sail" -type f -name ".env")
echo "Max existing usage across .env files found:"
echo " APP_PORT=$maxAppPort"
echo " FORWARD_DB_PORT=$maxDbPort"
echo " VITE_PORT=$maxVitePort"
echo " FORWARD_REDIS_PORT=$maxRedisPort"
echo " FORWARD_MAILPIT_DASHBOARD_PORT=$maxMailpitDashboardPort"
echo " FORWARD_MAILPIT_PORT=$maxMailpitPort"
# If no usage found (==0), default to the docker-compose default. Otherwise, use (max + 1).
if (( maxAppPort == 0 )); then
newAppPort="$defaultAppPort"
else
newAppPort=$((maxAppPort + 1))
fi
if (( maxDbPort == 0 )); then
newDbPort="$defaultDbPort"
else
newDbPort=$((maxDbPort + 1))
fi
if (( maxVitePort == 0 )); then
newVitePort="$defaultVitePort"
else
newVitePort=$((maxVitePort + 1))
fi
if (( maxRedisPort == 0 )); then
newRedisPort="$defaultRedisPort"
else
newRedisPort=$((maxRedisPort + 1))
fi
if (( maxMailpitDashboardPort == 0 )); then
newMailpitDashboardPort="$defaultMailpitDashboardPort"
else
newMailpitDashboardPort=$((maxMailpitDashboardPort + 1))
fi
if (( maxMailpitPort == 0 )); then
newMailpitPort="$defaultMailpitPort"
else
newMailpitPort=$((maxMailpitPort + 1))
fi
echo "Chosen ports for this project:"
echo " APP_PORT => $newAppPort"
echo " FORWARD_DB_PORT => $newDbPort"
echo " VITE_PORT => $newVitePort"
echo " FORWARD_REDIS_PORT => $newRedisPort"
echo " FORWARD_MAILPIT_DASHBOARD_PORT => $newMailpitDashboardPort"
echo " FORWARD_MAILPIT_PORT => $newMailpitPort"
# -----------------------------------------------------------------------------
# 4) Modify devcontainer.json
# -----------------------------------------------------------------------------
echo "Updating devcontainer.json ..."
tempDevcontainer="${devcontainerJsonFile}.processed"
tail -n +2 "$devcontainerJsonFile" | sed 's|//.*||' > "${tempDevcontainer}"
jq --arg projectName "$projectName" \
--arg projectCode "$projectCode" \
'
del(.forwardPorts, .runServices, .shutdownAction)
| .name = $projectName
| .service = $projectCode
| .customizations.vscode.extensions = [
"shufo.vscode-blade-formatter",
"onecentlin.laravel-blade",
"bmewburn.vscode-intelephense-client",
"bradlc.vscode-tailwindcss",
"mikestead.dotenv",
"amiralizadeh9480.laravel-extra-intellisense",
"ryannaddy.laravel-artisan",
"onecentlin.laravel5-snippets"
]
' "${tempDevcontainer}" > "${tempDevcontainer}.tmp"
{
echo "// https://aka.ms/devcontainer.json"
cat "${tempDevcontainer}.tmp"
} > "${tempDevcontainer}"
mv "${tempDevcontainer}" "$devcontainerJsonFile"
rm -f "${tempDevcontainer}.tmp"
# -----------------------------------------------------------------------------
# 5) Modify docker-compose.yml
# -----------------------------------------------------------------------------
echo "Modifying docker-compose.yml..."
# 5a) Rename "laravel.test" => "<projectCode>"
sed -i "s/^\(\s*\)laravel\.test:/\1${projectCode}:/g" "$dockerComposeFile"
# 5b) Add labels and 'proxy' network to <projectCode>:
# 1) Create a here-doc with literal lines, using a placeholder "PROJECT_CODE"
labelsAdd=$(cat <<'EOF'
.services."PROJECT_CODE".labels += [
"traefik.enable=true",
"traefik.docker.network=proxy",
"traefik.http.routers.PROJECT_CODE-http.rule=Host(`PROJECT_CODE.docker.localhost`)",
"traefik.http.routers.PROJECT_CODE-http.entrypoints=http",
"traefik.http.routers.PROJECT_CODE-https.rule=Host(`PROJECT_CODE.docker.localhost`)",
"traefik.http.routers.PROJECT_CODE-https.entrypoints=https",
"traefik.http.routers.PROJECT_CODE-https.tls=true"
]
EOF
)
# 2) Replace the placeholder PROJECT_CODE with your actual $projectCode
labelsAdd="${labelsAdd//PROJECT_CODE/$projectCode}"
# 3) Apply the filter with yq, then overwrite docker-compose.yml
yq -y "$labelsAdd" "$dockerComposeFile" > "$dockerComposeFile.tmp"
mv "$dockerComposeFile.tmp" "$dockerComposeFile"
networksAdd=".services.\"${projectCode}\".networks += [\"proxy\"]"
yq -y "$networksAdd" "$dockerComposeFile" > "$dockerComposeFile.tmp"
mv "$dockerComposeFile.tmp" "$dockerComposeFile"
# 5d) Insert container_name lines after "image:" in pgsql, mysql, redis if present
echo "Inserting container_name lines for pgsql, mysql, redis..."
projectNameKebabCase="$(echo "$projectName" | tr '[:upper:]' '[:lower:]' | sed 's/ /-/g')"
for dbService in pgsql mysql redis; do
if yq ".services.\"${dbService}\"" "$dockerComposeFile" >/dev/null 2>&1; then
echo " Found '$dbService' block, adding container_name after 'image:'"
insert_container_name "$dbService" "$projectNameKebabCase" "$dockerComposeFile"
fi
done
# 5e) networks.proxy.external = true
setProxyExternal='.networks.proxy.external = true'
yq -y "$setProxyExternal" "$dockerComposeFile" > "$dockerComposeFile.tmp"
mv "$dockerComposeFile.tmp" "$dockerComposeFile"
# -----------------------------------------------------------------------------
# 6) Update .env with the new dynamic ports
# -----------------------------------------------------------------------------
echo "Updating .env in $envFile with new ports..."
# APP_URL
if grep -q '^APP_URL=' "$envFile"; then
sed -i "s|^APP_URL=.*|APP_URL=http://${projectCode}.docker.localhost|" "$envFile"
else
echo "APP_URL=http://${projectCode}.docker.localhost" >> "$envFile"
fi
# APP_NAME
if grep -q '^APP_NAME=' "$envFile"; then
sed -i "s|^APP_NAME=.*|APP_NAME=\"${projectName}\"|" "$envFile"
else
echo "APP_NAME=\"${projectName}\"" >> "$envFile"
fi
# APP_PORT
if grep -q '^APP_PORT=' "$envFile"; then
sed -i "s|^APP_PORT=.*|APP_PORT=${newAppPort}|" "$envFile"
else
if grep -q '^APP_URL=' "$envFile"; then
sed -i "/^APP_URL=.*/a APP_PORT=${newAppPort}" "$envFile"
else
echo "APP_PORT=${newAppPort}" >> "$envFile"
fi
fi
# FORWARD_DB_PORT
if grep -q '^FORWARD_DB_PORT=' "$envFile"; then
sed -i "s|^FORWARD_DB_PORT=.*|FORWARD_DB_PORT=${newDbPort}|" "$envFile"
else
if grep -q '^DB_PASSWORD=' "$envFile"; then
sed -i "/^DB_PASSWORD=.*/a FORWARD_DB_PORT=${newDbPort}" "$envFile"
else
echo "FORWARD_DB_PORT=${newDbPort}" >> "$envFile"
fi
fi
# VITE_PORT
if grep -q '^VITE_PORT=' "$envFile"; then
sed -i "s|^VITE_PORT=.*|VITE_PORT=${newVitePort}|" "$envFile"
else
if grep -q '^VITE_APP_NAME=' "$envFile"; then
sed -i "/^VITE_APP_NAME=.*/a VITE_PORT=${newVitePort}" "$envFile"
else
# If VITE_APP_NAME not found, just append
echo "VITE_PORT=${newVitePort}" >> "$envFile"
fi
fi
# FORWARD_REDIS_PORT
if grep -q '^FORWARD_REDIS_PORT=' "$envFile"; then
sed -i "s|^FORWARD_REDIS_PORT=.*|FORWARD_REDIS_PORT=${newRedisPort}|" "$envFile"
else
# We'll just append after VITE_PORT if found, else at the end
if grep -q '^VITE_PORT=' "$envFile"; then
sed -i "/^VITE_PORT=.*/a FORWARD_REDIS_PORT=${newRedisPort}" "$envFile"
else
echo "FORWARD_REDIS_PORT=${newRedisPort}" >> "$envFile"
fi
fi
# FORWARD_MAILPIT_DASHBOARD_PORT
if grep -q '^FORWARD_MAILPIT_DASHBOARD_PORT=' "$envFile"; then
sed -i "s|^FORWARD_MAILPIT_DASHBOARD_PORT=.*|FORWARD_MAILPIT_DASHBOARD_PORT=${newMailpitDashboardPort}|" "$envFile"
else
echo "FORWARD_MAILPIT_DASHBOARD_PORT=${newMailpitDashboardPort}" >> "$envFile"
fi
# FORWARD_MAILPIT_PORT
if grep -q '^FORWARD_MAILPIT_PORT=' "$envFile"; then
sed -i "s|^FORWARD_MAILPIT_PORT=.*|FORWARD_MAILPIT_PORT=${newMailpitPort}|" "$envFile"
else
echo "FORWARD_MAILPIT_PORT=${newMailpitPort}" >> "$envFile"
fi
# -----------------------------------------------------------------------------
# 7) Print summary
# -----------------------------------------------------------------------------
echo
echo "=================================================="
echo "Changes Made:"
echo "=================================================="
echo "1) Ran 'composer require laravel/sail --dev' and 'php artisan sail:install --devcontainer'"
echo
echo "2) Extracted default ports from docker-compose.yml (or fallback):"
echo " APP_PORT => $defaultAppPort"
echo " FORWARD_DB_PORT => $defaultDbPort"
echo " VITE_PORT => $defaultVitePort"
echo " FORWARD_REDIS_PORT => $defaultRedisPort"
echo " FORWARD_MAILPIT_DASHBOARD_PORT => $defaultMailpitDashboardPort"
echo " FORWARD_MAILPIT_PORT => $defaultMailpitPort"
echo
echo "3) Scanned ~/code/sail/ for .env files to find maximum usage of each port."
echo " If usage found, used (max + 1). Otherwise, used the docker-compose default."
echo " => APP_PORT=$newAppPort"
echo " => FORWARD_DB_PORT=$newDbPort"
echo " => VITE_PORT=$newVitePort"
echo " => FORWARD_REDIS_PORT=$newRedisPort"
echo " => FORWARD_MAILPIT_DASHBOARD_PORT=$newMailpitDashboardPort"
echo " => FORWARD_MAILPIT_PORT=$newMailpitPort"
echo
echo "4) devcontainer.json:"
echo " - Removed line 1 and all // comment lines"
echo " - Removed forwardPorts, runServices, shutdownAction"
echo " - Set name/service to '$projectName'/'$projectCode'"
echo " - Reinserted '// https://aka.ms/devcontainer.json' at top"
echo
echo "5) docker-compose.yml:"
echo " - Renamed 'laravel.test' => '${projectCode}'"
echo " - Added Traefik labels and 'proxy' network to '${projectCode}'"
echo " - Inserted container_name in 'pgsql', 'mysql', 'redis' (if present)"
echo " - networks.proxy.external = true"
echo
echo "6) .env updated with new ports and other lines."
echo
echo "Done!"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment