From f80dbe45c010ca8622bb8ff987329d3d6f777806 Mon Sep 17 00:00:00 2001 From: adminharis Date: Tue, 9 Jun 2026 21:38:35 +0000 Subject: [PATCH] Initial commit --- Devlab-after-install.sh | 192 ++++++++++++ Install-devlab.sh | 672 ++++++++++++++++++++++++++++++++++++++++ README | 9 + 3 files changed, 873 insertions(+) create mode 100644 Devlab-after-install.sh create mode 100644 Install-devlab.sh create mode 100644 README diff --git a/Devlab-after-install.sh b/Devlab-after-install.sh new file mode 100644 index 0000000..5b9a0e3 --- /dev/null +++ b/Devlab-after-install.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# ============================================================================= +# devlab — DevLab Management CLI +# Usage: devlab [args] +# +# Install to PATH: sudo cp devlab.sh /usr/local/bin/devlab && chmod +x /usr/local/bin/devlab +# ============================================================================= + +set -euo pipefail + +CONFIG_FILE="/opt/lab/.devlab-config" +[[ -f "$CONFIG_FILE" ]] && source "$CONFIG_FILE" +LAB_ROOT="${LAB_ROOT:-/opt/lab}" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' + +SERVICES=(caddy code-server gitea gitea-runner ollama open-webui portainer watchtower) + +cmd_help() { +cat < Tail logs for a service + update Pull latest images and recreate containers + backup Run a manual backup now + models List downloaded Ollama models + pull Pull an Ollama model (e.g. codellama:34b) + remove Remove an Ollama model + urls Print all service URLs + compose up Start a test environment from a compose file + compose down Stop a test environment + compose ls List running compose stacks + +${BOLD}SERVICES${RESET} + ${SERVICES[*]} + +${BOLD}EXAMPLES${RESET} + devlab status + devlab logs gitea + devlab pull mistral:latest + devlab compose up /opt/lab/compose/examples/lamp-stack.yml + devlab restart code-server +EOF +} + +cmd_status() { + echo -e "\n${BOLD}DevLab Service Status${RESET}\n" + printf "%-20s %-12s %s\n" "CONTAINER" "STATUS" "IMAGE" + printf '%.0s─' {1..60}; echo + for svc in "${SERVICES[@]}"; do + local info + info=$(docker inspect "$svc" --format '{{.State.Status}} {{.Config.Image}}' 2>/dev/null) || { + printf "%-20s ${RED}%-12s${RESET} %s\n" "$svc" "not found" "-" + continue + } + local status="${info%% *}" + local image="${info#* }" + local color="$RED" + [[ "$status" == "running" ]] && color="$GREEN" + printf "%-20s ${color}%-12s${RESET} %s\n" "$svc" "$status" "$image" + done + echo "" +} + +cmd_start() { + for svc in "${SERVICES[@]}"; do + docker start "$svc" 2>/dev/null && \ + echo -e " ${GREEN}✓${RESET} ${svc}" || \ + echo -e " ${RED}✗${RESET} ${svc} (not found)" + done +} + +cmd_stop() { + read -rp "Stop all DevLab services? [y/N]: " confirm + [[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; } + for svc in "${SERVICES[@]}"; do + docker stop "$svc" 2>/dev/null && \ + echo -e " ${YELLOW}■${RESET} ${svc} stopped" || true + done +} + +cmd_restart() { + local target="${1:-all}" + if [[ "$target" == "all" ]]; then + for svc in "${SERVICES[@]}"; do + docker restart "$svc" 2>/dev/null && \ + echo -e " ${GREEN}↺${RESET} ${svc}" || true + done + else + docker restart "$target" && echo -e " ${GREEN}↺${RESET} ${target} restarted" + fi +} + +cmd_logs() { + local svc="${1:-}" + [[ -z "$svc" ]] && { echo "Usage: devlab logs "; exit 1; } + docker logs -f --tail=100 "$svc" +} + +cmd_update() { + echo -e "${CYAN}Pulling latest images...${RESET}" + for svc in "${SERVICES[@]}"; do + local image + image=$(docker inspect "$svc" --format '{{.Config.Image}}' 2>/dev/null) || continue + echo -e " Updating ${image}..." + docker pull "$image" || true + docker restart "$svc" || true + done + echo -e "${GREEN}Update complete.${RESET}" +} + +cmd_backup() { + [[ -x "${LAB_ROOT}/backup.sh" ]] || { echo "Backup script not found."; exit 1; } + "${LAB_ROOT}/backup.sh" +} + +cmd_models() { + docker exec ollama ollama list +} + +cmd_pull() { + local model="${1:-}" + [[ -z "$model" ]] && { echo "Usage: devlab pull "; exit 1; } + docker exec ollama ollama pull "$model" +} + +cmd_remove_model() { + local model="${1:-}" + [[ -z "$model" ]] && { echo "Usage: devlab remove "; exit 1; } + docker exec ollama ollama rm "$model" +} + +cmd_urls() { + echo "" + echo -e "${BOLD}DevLab Service URLs${RESET}" + echo -e " Web IDE → ${GREEN}https://${CODE_HOST:-code.yourdomain.com}${RESET}" + echo -e " Git → ${GREEN}https://${GIT_HOST:-git.yourdomain.com}${RESET}" + echo -e " AI Chat → ${GREEN}https://${AI_HOST:-ai.yourdomain.com}${RESET}" + echo -e " Portainer → ${GREEN}https://${PM_HOST:-portainer.yourdomain.com}${RESET}" + echo "" +} + +cmd_compose() { + local action="${1:-}" + shift + case "$action" in + up) + local file="${1:-}" + [[ -z "$file" ]] && { echo "Usage: devlab compose up "; exit 1; } + docker compose -f "$file" up -d + echo -e "${GREEN}Stack started.${RESET}" + ;; + down) + local file="${1:-}" + [[ -z "$file" ]] && { echo "Usage: devlab compose down "; exit 1; } + docker compose -f "$file" down + ;; + ls) + docker compose ls + ;; + *) + echo "Usage: devlab compose {up|down|ls} [file]" + ;; + esac +} + +# ─── Router ────────────────────────────────────────────────────────────────── +COMMAND="${1:-help}" +shift 2>/dev/null || true + +case "$COMMAND" in + status) cmd_status ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart "$@" ;; + logs) cmd_logs "$@" ;; + update) cmd_update ;; + backup) cmd_backup ;; + models) cmd_models ;; + pull) cmd_pull "$@" ;; + remove) cmd_remove_model "$@" ;; + urls) cmd_urls ;; + compose) cmd_compose "$@" ;; + help|--help|-h) cmd_help ;; + *) echo "Unknown command: ${COMMAND}. Run: devlab help" && exit 1 ;; +esac \ No newline at end of file diff --git a/Install-devlab.sh b/Install-devlab.sh new file mode 100644 index 0000000..ce8f586 --- /dev/null +++ b/Install-devlab.sh @@ -0,0 +1,672 @@ +#!/usr/bin/env bash +# ============================================================================= +# DevLab Installer — Rocky Linux 10 / Hetzner Dedicated +# Installs: Docker, Caddy, code-server, Gitea, Gitea Runner, +# Ollama, Open WebUI, Portainer, Watchtower +# +# Usage: +# chmod +x install-devlab.sh +# sudo ./install-devlab.sh +# +# Environment variables (override defaults): +# DOMAIN Base domain (e.g. example.com) +# EMAIL Let's Encrypt email +# LAB_ROOT Install root (default /opt/lab) +# CODE_PASSWORD code-server password +# GITEA_ADMIN Gitea admin username +# GITEA_PASS Gitea admin password +# GITEA_EMAIL Gitea admin email +# PULL_MODELS Space-separated Ollama model list +# e.g. "codellama:34b mistral:latest" +# ============================================================================= + +set -euo pipefail + +# ─── Colour helpers ────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' +info() { echo -e "${CYAN}[INFO]${RESET} $*"; } +success() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +error() { echo -e "${RED}[ERROR]${RESET} $*"; exit 1; } +header() { echo -e "\n${BOLD}${CYAN}══════════════════════════════════════════════${RESET}"; \ + echo -e "${BOLD}${CYAN} $*${RESET}"; \ + echo -e "${BOLD}${CYAN}══════════════════════════════════════════════${RESET}"; } + +# ─── Root check ────────────────────────────────────────────────────────────── +[[ $EUID -ne 0 ]] && error "Run this script as root: sudo ./install-devlab.sh" + +# ─── Interactive configuration ─────────────────────────────────────────────── +header "DevLab Setup — Interactive Configuration" + +prompt_val() { + local var="$1" prompt="$2" default="$3" secret="${4:-}" + if [[ -n "${!var:-}" ]]; then + info "$var already set (${!var:0:6}…)" + return + fi + if [[ "$secret" == "secret" ]]; then + read -rsp "${prompt} [default: ${default}]: " val; echo + else + read -rp "${prompt} [default: ${default}]: " val + fi + printf -v "$var" '%s' "${val:-$default}" +} + +prompt_val DOMAIN "Base domain (e.g. yourdomain.com)" "lab.example.com" +prompt_val EMAIL "Let's Encrypt / admin email" "admin@${DOMAIN}" +prompt_val LAB_ROOT "Install root directory" "/opt/lab" +prompt_val CODE_PASSWORD "code-server password" "ChangeMe123!" secret +prompt_val GITEA_ADMIN "Gitea admin username" "labadmin" +prompt_val GITEA_PASS "Gitea admin password" "ChangeMe456!" secret +prompt_val GITEA_EMAIL "Gitea admin email" "${EMAIL}" +prompt_val PULL_MODELS "Ollama models to pull (space-separated, leave blank to skip)" \ + "codellama:13b" + +echo "" +echo -e "${BOLD}Configuration summary:${RESET}" +echo -e " Domain : ${CYAN}${DOMAIN}${RESET}" +echo -e " Email : ${CYAN}${EMAIL}${RESET}" +echo -e " Lab root : ${CYAN}${LAB_ROOT}${RESET}" +echo -e " Gitea admin: ${CYAN}${GITEA_ADMIN}${RESET}" +echo -e " Models : ${CYAN}${PULL_MODELS:-none}${RESET}" +echo "" +read -rp "Proceed with installation? [y/N]: " confirm +[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } + +# ─── Subdomains ────────────────────────────────────────────────────────────── +CODE_HOST="code.${DOMAIN}" +GIT_HOST="git.${DOMAIN}" +AI_HOST="ai.${DOMAIN}" +PM_HOST="portainer.${DOMAIN}" +RUNNER_TOKEN="" # filled later + +# ─── Directory layout ──────────────────────────────────────────────────────── +header "Creating directory structure" +DIRS=( + "${LAB_ROOT}/caddy/config" + "${LAB_ROOT}/caddy/data" + "${LAB_ROOT}/workspace" + "${LAB_ROOT}/gitea" + "${LAB_ROOT}/gitea-runner" + "${LAB_ROOT}/ollama" + "${LAB_ROOT}/portainer" + "${LAB_ROOT}/open-webui" + "${LAB_ROOT}/compose" + "${LAB_ROOT}/backups" +) +for d in "${DIRS[@]}"; do mkdir -p "$d"; done +success "Directories created under ${LAB_ROOT}" + +# ─── System packages ───────────────────────────────────────────────────────── +header "Installing system packages" +dnf -y update + +# Enable EPEL — required for htop, btop, and other tools not in Rocky 10 base repos +info "Enabling EPEL repository..." +dnf -y install epel-release 2>/dev/null || \ + dnf -y install "https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm" 2>/dev/null || \ + warn "EPEL install failed — htop/btop may not be available" +dnf -y makecache 2>/dev/null || true + +# Core packages — all available in base repos (must succeed) +dnf -y install \ + curl wget git vim unzip tar \ + firewalld bash-completion \ + ca-certificates gnupg2 \ + python3 python3-pip \ + net-tools lsof + +# Optional monitoring tools — from EPEL, non-fatal if unavailable +dnf -y install htop 2>/dev/null && success "htop installed" || \ + dnf -y install btop 2>/dev/null && success "btop installed (htop unavailable)" || \ + warn "htop/btop not available — skipping (optional)" + +success "System packages installed" + +# ─── Firewall ───────────────────────────────────────────────────────────────── +header "Configuring firewall" +systemctl enable --now firewalld +firewall-cmd --permanent --add-service=http +firewall-cmd --permanent --add-service=https +firewall-cmd --permanent --add-service=ssh +# Remove default open ports that Hetzner sometimes pre-opens +firewall-cmd --permanent --remove-service=cockpit 2>/dev/null || true +firewall-cmd --reload +success "Firewall configured (HTTP, HTTPS, SSH)" + +# ─── Docker ────────────────────────────────────────────────────────────────── +header "Installing Docker CE" +if command -v docker &>/dev/null; then + warn "Docker already installed — skipping" +else + dnf -y install dnf-plugins-core + dnf config-manager --add-repo \ + https://download.docker.com/linux/rhel/docker-ce.repo + dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin + systemctl enable --now docker + success "Docker CE installed" +fi + +# Ensure docker group exists and add current SUDO_USER if set +if [[ -n "${SUDO_USER:-}" ]]; then + usermod -aG docker "$SUDO_USER" + info "Added ${SUDO_USER} to docker group" +fi + +# ─── Docker network ─────────────────────────────────────────────────────────── +docker network create devlab 2>/dev/null || true +success "Docker network 'devlab' ready" + +# ─── Caddy (reverse proxy + auto-SSL) ──────────────────────────────────────── +header "Deploying Caddy (reverse proxy + Let's Encrypt)" + +cat > "${LAB_ROOT}/caddy/config/Caddyfile" < /tmp/code-server.Dockerfile <<'DOCKERFILE' +FROM codercom/code-server:latest +USER root + +# Node.js (via NodeSource) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs + +# Python extras +RUN apt-get install -y python3-pip python3-venv python3-full + +# Go +RUN curl -fsSL https://go.dev/dl/go1.23.0.linux-amd64.tar.gz \ + | tar -C /usr/local -xz +ENV PATH="/usr/local/go/bin:${PATH}" + +# Rust +RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --no-modify-path +ENV PATH="/root/.cargo/bin:${PATH}" + +# PHP, Ruby, Java, C/C++ tools +RUN apt-get install -y \ + php-cli php-curl php-mbstring \ + ruby-full \ + default-jdk-headless \ + gcc g++ cmake make \ + jq tree ripgrep fd-find fzf + +# Cleanup +RUN apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* + +USER coder +# Pre-install popular VS Code extensions +RUN code-server --install-extension ms-python.python \ + --install-extension golang.go \ + --install-extension rust-lang.rust-analyzer \ + --install-extension dbaeumer.vscode-eslint \ + --install-extension esbenp.prettier-vscode \ + --install-extension eamodio.gitlens \ + --install-extension ms-azuretools.vscode-docker \ + --install-extension PKief.material-icon-theme \ + 2>/dev/null || true +DOCKERFILE + +info "Building code-server image with multi-language runtimes (this takes a few minutes)..." +docker build -t devlab/code-server:latest -f /tmp/code-server.Dockerfile /tmp/ + +docker run -d \ + --name code-server \ + --restart unless-stopped \ + --network devlab \ + -e PASSWORD="${CODE_PASSWORD}" \ + -e DOCKER_HOST=unix:///var/run/docker.sock \ + -v "${LAB_ROOT}/workspace:/home/coder/workspace" \ + -v /var/run/docker.sock:/var/run/docker.sock \ + devlab/code-server:latest + +success "code-server running → https://${CODE_HOST}" + +# ─── Gitea ──────────────────────────────────────────────────────────────────── +header "Deploying Gitea (self-hosted Git)" + +docker run -d \ + --name gitea \ + --restart unless-stopped \ + --network devlab \ + -e USER_UID=1000 \ + -e USER_GID=1000 \ + -e GITEA__database__DB_TYPE=sqlite3 \ + -e GITEA__server__DOMAIN="${GIT_HOST}" \ + -e GITEA__server__ROOT_URL="https://${GIT_HOST}" \ + -e GITEA__server__SSH_PORT=2222 \ + -e GITEA__server__HTTP_PORT=3000 \ + -e GITEA__service__DISABLE_REGISTRATION=true \ + -e GITEA__service__REQUIRE_SIGNIN_VIEW=false \ + -e GITEA__log__LEVEL=Info \ + -e GITEA__actions__ENABLED=true \ + -v "${LAB_ROOT}/gitea:/data" \ + -p 2222:22 \ + gitea/gitea:latest + +# Wait for Gitea to boot +info "Waiting for Gitea to initialise..." +sleep 15 + +# Create admin user via gitea CLI inside the container +docker exec -u git gitea \ + gitea admin user create \ + --username "${GITEA_ADMIN}" \ + --password "${GITEA_PASS}" \ + --email "${GITEA_EMAIL}" \ + --admin \ + --must-change-password=false 2>/dev/null || \ + warn "Gitea admin user may already exist — skipping" + +success "Gitea running → https://${GIT_HOST}" + +# ─── Gitea Actions Runner ───────────────────────────────────────────────────── +header "Deploying Gitea Actions Runner (CI/CD)" + +# Generate a runner token via Gitea API +info "Generating runner registration token..." +sleep 5 +RUNNER_TOKEN=$(docker exec -u git gitea \ + gitea actions generate-runner-token 2>/dev/null | tail -1) || true + +if [[ -z "${RUNNER_TOKEN}" ]]; then + warn "Could not auto-generate runner token — you will need to register manually." + warn "See documentation section 7 for instructions." + RUNNER_TOKEN="NEEDS_MANUAL_REGISTRATION" +fi + +# Write runner config +cat > "${LAB_ROOT}/gitea-runner/config.yml" < "${LAB_ROOT}/compose/examples/lamp-stack.yml" <<'YAML' +# Example test environment: LAMP stack +# Usage: docker compose -f lamp-stack.yml up -d +# Access: http://localhost:8888 +services: + web: + image: php:8.3-apache + ports: ["8888:80"] + volumes: ["./app:/var/www/html"] + networks: [test] + db: + image: mariadb:11 + environment: + MYSQL_ROOT_PASSWORD: testpass + MYSQL_DATABASE: testdb + volumes: ["dbdata:/var/lib/mysql"] + networks: [test] + phpmyadmin: + image: phpmyadmin:latest + ports: ["8889:80"] + environment: + PMA_HOST: db + networks: [test] +volumes: + dbdata: +networks: + test: +YAML + +# Example: Node.js + PostgreSQL +cat > "${LAB_ROOT}/compose/examples/node-postgres.yml" <<'YAML' +# Example test environment: Node.js + PostgreSQL + Redis +# Usage: docker compose -f node-postgres.yml up -d +services: + app: + image: node:22-alpine + working_dir: /app + volumes: ["./app:/app"] + command: sh -c "npm install && npm start" + ports: ["3000:3000"] + environment: + DATABASE_URL: postgres://user:pass@db/appdb + REDIS_URL: redis://cache:6379 + networks: [test] + depends_on: [db, cache] + db: + image: postgres:16 + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + POSTGRES_DB: appdb + volumes: ["pgdata:/var/lib/postgresql/data"] + networks: [test] + cache: + image: redis:7-alpine + networks: [test] +volumes: + pgdata: +networks: + test: +YAML + +# Example: Django + MySQL +cat > "${LAB_ROOT}/compose/examples/django-mysql.yml" <<'YAML' +# Example test environment: Django + MySQL +services: + web: + image: python:3.12-slim + working_dir: /app + volumes: ["./app:/app"] + command: sh -c "pip install -r requirements.txt && python manage.py runserver 0.0.0.0:8000" + ports: ["8000:8000"] + environment: + DATABASE_URL: mysql://user:pass@db/appdb + networks: [test] + depends_on: [db] + db: + image: mysql:8.3 + environment: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_DATABASE: appdb + MYSQL_USER: user + MYSQL_PASSWORD: pass + volumes: ["mysqldata:/var/lib/mysql"] + networks: [test] +volumes: + mysqldata: +networks: + test: +YAML + +success "Example compose stacks written to ${LAB_ROOT}/compose/examples/" + +# ─── Gitea workflow example ─────────────────────────────────────────────────── +mkdir -p "${LAB_ROOT}/workspace/.gitea/workflows" +cat > "${LAB_ROOT}/workspace/.gitea/workflows/ci.yml" <<'YAML' +# Example Gitea Actions CI workflow +# Place this in your repo at: .gitea/workflows/ci.yml +name: CI Pipeline + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: pytest --tb=short + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Lint with ruff + run: pip install ruff && ruff check . + + docker-build: + runs-on: ubuntu-latest + needs: [test, lint] + steps: + - uses: actions/checkout@v4 + - name: Build Docker image + run: docker build -t myapp:${{ github.sha }} . +YAML + +success "Example CI workflow written" + +# ─── Backup script ──────────────────────────────────────────────────────────── +header "Creating backup script" + +cat > "${LAB_ROOT}/backup.sh" </dev/null + +rsync -a --exclude='ollama' "${LAB_ROOT}/" "\$BACKUP_DIR/" \ + --exclude='backups' --exclude='*.log' + +# Restart if stopped +# docker start gitea code-server open-webui portainer 2>/dev/null + +# Prune backups older than 14 days +find "${LAB_ROOT}/backups" -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true + +echo "[BACKUP] Done → \$BACKUP_DIR" +BACKUP + +chmod +x "${LAB_ROOT}/backup.sh" + +# Install cron job for daily backup at 03:00 +(crontab -l 2>/dev/null | grep -v backup.sh; echo "0 3 * * * ${LAB_ROOT}/backup.sh >> ${LAB_ROOT}/backups/backup.log 2>&1") | crontab - +success "Backup script installed (runs daily at 03:00)" + +# ─── System tuning ──────────────────────────────────────────────────────────── +header "Applying system optimisations" + +# Increase file descriptor limits for Docker +cat > /etc/security/limits.d/99-devlab.conf < /etc/sysctl.d/99-devlab.conf </dev/null +success "System limits tuned" + +# ─── Save config file ───────────────────────────────────────────────────────── +cat > "${LAB_ROOT}/.devlab-config" <" +echo -e " A git.${DOMAIN} → " +echo -e " A ai.${DOMAIN} → " +echo -e " A portainer.${DOMAIN} → " +echo "" +echo -e "${BOLD}Useful commands${RESET}" +echo -e " docker ps -a # list all containers" +echo -e " docker logs -f # follow container logs" +echo -e " docker exec ollama ollama list # list downloaded AI models" +echo -e " docker exec ollama ollama pull # pull new AI model" +echo -e " ${LAB_ROOT}/backup.sh # run manual backup" +echo "" +echo -e "${YELLOW}⚠ Point your DNS A records before SSL certs can be issued.${RESET}" +echo -e "${YELLOW}⚠ If you were added to the docker group, log out and back in.${RESET}" +echo "" +success "DevLab is ready! Enjoy your lab, Harry." \ No newline at end of file diff --git a/README b/README new file mode 100644 index 0000000..f42e708 --- /dev/null +++ b/README @@ -0,0 +1,9 @@ +# Upload the new install-devlab.sh to the server, then: + +chmod +x install-devlab.sh +sudo ./install-devlab.sh + +# After install: + +sudo cp devlab.sh /usr/local/bin/devlab +devlab status \ No newline at end of file