Project: Workshop Bootstrap
Version: 1.1
Date: 2025-02-10
Requirements: SRS.md
This document describes how the system is built. For what it must do, see the SRS.
- Zero-prerequisite setup -- only Git and a supported OS required
- DRY -- OS entry points are thin wrappers; all logic is shared
- Repo-agnostic container -- one image supports Python, Node.js, and hybrid stacks
- Extensibility -- new repos require only files, no core code changes (FR-008)
- Idempotency -- every script converges on the desired state (FR-003)
- Graceful degradation -- if Cursor install fails, container setup continues
┌───────────────────────────────────────────────────────────────────┐
│ HOST │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ OS Entry Points │ │
│ │ mac/bootstrap | linux/bootstrap | windows/bootstrap │ │
│ │ mac/dev | linux/dev | windows/dev │ │
│ └────────┬───────────────┬──────────────────┬────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Shared Scripts (scripts/) │ │
│ │ bootstrap-common.sh dev-common.sh utils.sh utils.ps1 │ │
│ │ install-docker-*.sh install-cursor-*.sh │ │
│ │ install-docker-*.ps1 install-cursor-*.ps1 │ │
│ └────────┬───────────────────────────────────────────────────┘ │
│ │ docker compose build/up │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Docker Container (workshop-dev) │ │
│ │ │ │
│ │ /workspace/ (bind mount from host) │ │
│ │ ├── scripts/start-repo.sh (generic repo manager) │ │
│ │ ├── repos/demo-site/ (Flask on :8080) │ │
│ │ ├── repos/cyvl-geoguesser/ (Vite :5173, API :8000) │ │
│ │ └── logs/ (PID + log files) │ │
│ │ │ │
│ │ Exposed ports: 8080, 5173, 8000, 3000 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ Cursor IDE ←──bind mount──→ Container │
│ Host browser ←──port map──→ Container services │
└───────────────────────────────────────────────────────────────────┘
| Component | Files | Fulfills |
|---|---|---|
| OS Entry Points | mac/{bootstrap,dev}, linux/{bootstrap,dev}, windows/{bootstrap.ps1,dev.ps1} |
FR-001, FR-050 |
| Bootstrap Engine | scripts/bootstrap-common.sh |
FR-002, FR-003 |
| Dev Helper | scripts/dev-common.sh |
FR-050, FR-051 |
| Utilities | scripts/utils.sh, scripts/utils.ps1 |
FR-010, FR-034--036 |
| Docker Installers | scripts/install-docker-{macos,linux,windows}.* |
FR-020--023 |
| Cursor Installers | scripts/install-cursor-{macos,linux,windows}.* |
FR-030--033 |
| Repo Manager | scripts/start-repo.sh |
FR-004--008 |
| Per-Repo Scripts | repos/*/scripts/start.sh |
FR-005 |
| Container Image | Dockerfile |
FR-040 |
| Compose Config | docker-compose.yml |
FR-040, FR-041 |
| Repo Metadata | repos/*/repo.json |
FR-004 |
| Devcontainer | .devcontainer/devcontainer.json |
FR-042 |
workshop-bootstrap/
├── mac/ # macOS entry points
│ ├── bootstrap
│ ├── dev
│ └── README.md
├── linux/ # Linux entry points
│ ├── bootstrap
│ ├── dev
│ └── README.md
├── windows/ # Windows entry points
│ ├── bootstrap.ps1
│ ├── dev.ps1
│ └── README.md
├── repos/ # Application repositories
│ ├── demo-site/
│ │ ├── code/ # Flask app (app.py, requirements.txt, templates/)
│ │ ├── scripts/start.sh
│ │ └── repo.json
│ └── cyvl-geoguesser/
│ ├── code/ # React frontend (src/, vite.config.ts) + FastAPI backend (api/)
│ ├── scripts/start.sh
│ └── repo.json
├── scripts/ # Shared logic
│ ├── bootstrap-common.sh
│ ├── dev-common.sh
│ ├── start-repo.sh
│ ├── utils.sh / utils.ps1
│ ├── install-docker-{macos,linux}.sh
│ ├── install-docker-windows.ps1
│ ├── install-cursor-{macos,linux}.sh
│ └── install-cursor-windows.ps1
├── data/ # Public datasets (documentation)
├── .cursor/ # Shared Cursor IDE config (rules, commands)
├── .devcontainer/devcontainer.json
├── .github/workflows/{test,lint}.yml
├── Dockerfile
├── docker-compose.yml
├── .gitignore
├── README.md
├── SRS.md
└── SDD.md
Each OS folder has two scripts (~5 lines each) that:
- Set
$OS(macosorlinux) or implicitly run as Windows - Compute
$SCRIPT_DIRas repo root (one level up) - Source the shared common script
- Call
run_bootstrap "$@"orrun_dev_command "$@"
bootstrap-common.sh uses $OS to select OS-specific function references:
case "$OS" in
macos)
source "scripts/install-docker-macos.sh"
source "scripts/install-cursor-macos.sh"
START_DOCKER="start_docker_macos"
OPEN_CURSOR="open_cursor_macos"
DETECT_CURSOR="detect_cursor_macos"
;;
linux) # same pattern, linux functions
esacFunctions stored in variables and invoked indirectly ($START_DOCKER "$TIMEOUT"), keeping the orchestration logic OS-agnostic. Windows uses inline if/else in PowerShell since it doesn't source .sh files.
Flow (implements FR-002)
run_bootstrap()
├── print_banner()
├── parse_bootstrap_args() # --repo, --force-ports, --no-open, --timeout, etc.
├── select_repo_interactive() # if --repo not given: read repos/*/repo.json, numbered menu
│ or validate_selected_repo() # if --repo given: check dir + repo.json + start.sh exist
├── setup_docker() # detect → install if missing (or --reinstall-docker)
├── ensure_docker_running() # docker info → $START_DOCKER → poll with timeout
│ └── compose_available()
├── setup_cursor() # $DETECT_CURSOR → install if missing (or --reinstall-cursor)
├── check_required_ports() # jq .ports from repo.json → check_and_free_ports()
├── setup_containers() # docker compose build dev && docker compose up -d dev
├── start_repo_services()
│ ├── exec: start-repo.sh install <repo> # deps
│ ├── exec -d: start-repo.sh <repo> start # services (backgrounded)
│ └── poll healthcheck URL (up to 60s, 2s interval)
├── open_editor() # $OPEN_CURSOR to repos/<repo>/code
└── print_summary() # repo name, URLs from repo.json, useful commands
| Variable | Default | CLI Override |
|---|---|---|
TIMEOUT |
120s | --timeout |
REINSTALL_DOCKER |
false | --reinstall-docker |
REINSTALL_CURSOR |
false | --reinstall-cursor |
NO_OPEN |
false | --no-open |
FORCE_PORTS |
false | --force-ports |
SELECTED_REPO |
(interactive) | --repo NAME |
DEFAULT_PORTS |
8080 5173 8000 3000 | from repo.json |
A plain-text file at repo root persists the active repo name (implements FR-007). Written by bootstrap and ./dev select. Read by all ./dev commands as default. Deleted by ./dev clean.
Command Dispatch (implements FR-050)
run_dev_command($cmd)
├── up → docker compose up -d dev
├── down → docker compose down
├── shell → docker compose exec dev bash
├── start → exec: start-repo.sh <repo> start
├── stop → exec: start-repo.sh <repo> stop
├── restart → exec: start-repo.sh <repo> restart
├── logs → exec: start-repo.sh <repo> logs
├── status → docker compose ps + exec: start-repo.sh <repo> status
├── install → exec: start-repo.sh install <repo>
├── select → interactive picker → write .selected-repo
├── list → iterate repos/*/repo.json, show names + descriptions
├── build → docker compose build --no-cache dev
└── clean → docker compose down --volumes --rmi local + rm .selected-repo
All commands that need the container call ensure_container_running() first (implements FR-051), which runs cmd_up() if the container is not running.
This script bridges the generic dev helper and per-repo lifecycle scripts.
| Invocation | Action |
|---|---|
start-repo.sh install <repo> |
Validate repo, sync Cursor config (FR-080), install deps by stack (FR-006) |
start-repo.sh <repo> start |
Delegate to repos/<repo>/scripts/start.sh start |
start-repo.sh <repo> stop|restart|status|logs |
Delegate to same |
start-repo.sh list |
Show all repos from repos/*/repo.json |
start-repo.sh info <repo> |
Show name, description, stack, URLs |
Dependency Installation by Stack (implements FR-006)
| Stack value | Steps |
|---|---|
python |
pip install -r code/requirements.txt |
node |
cd code && npm install |
node+python |
cd code && npm install then cd code/api && uv sync --all-packages (falls back to pip if no pyproject.toml) |
Cursor Config Syncing (implements FR-080)
Copies $WORKSPACE/.cursor/ → repos/<repo>/code/.cursor/ if both directories exist. The .gitignore excludes repos/*/code/.cursor/.
Interface Contract (implements FR-005)
./start.sh start # Start service(s) in background
./start.sh stop # Stop service(s) gracefully
./start.sh restart # Stop then start
./start.sh status # Show running state + URLs
./start.sh logs # Tail log file(s)Both bundled repos use:
- Start:
setsid <cmd> > $LOG_DIR/<name>.log 2>&1 &(new process group) - Record PID:
echo $! > $LOG_DIR/<name>.pid - Liveness check:
kill -0 $pid - Stop:
kill -TERM -$pid(negative PID kills entire process group) - Fallback:
pkill -f <pattern>for orphans
- Single process:
python app.py(Flask dev server, port 8080) FLASK_ENV=developmentenables auto-reload- Files:
demo-site.pid,demo-site.log
- Two processes:
- Backend:
uv run uvicorn geolocation_api.app:app --reload --host 0.0.0.0 --port 8000 - Frontend:
npm run dev -- --host 0.0.0.0(Vite on port 5173)
- Backend:
- Files:
backend.pid,backend.log,frontend.pid,frontend.log - On
start: callsstop_servicesfirst to prevent port conflicts
5.1 Dockerfile (implements FR-040)
Two stages, no production target:
base stage (FROM python:3.12-slim):
- System packages: curl, git, ca-certificates, gnupg
- Node.js 22 via NodeSource apt repo (GPG verified)
- npm upgraded to latest globally; update notifier disabled
- uv installed from
astral.sh/uv/install.sh - ENV:
PYTHONDONTWRITEBYTECODE=1,PYTHONUNBUFFERED=1,PIP_NO_CACHE_DIR=1 - PATH includes
/root/.local/bin(uv)
development stage (FROM base):
- Dev tools: vim, less, htop, procps, jq
- Build args:
BUILD_TIME,VERSION - CMD:
sleep infinity
5.2 Docker Compose (implements FR-040, FR-041)
services:
dev:
build: { context: ., target: development }
container_name: workshop-dev
volumes: [ ".:/workspace:cached" ]
ports:
- "${PORT_1:-8080}:8080"
- "${PORT_2:-5173}:5173"
- "${PORT_3:-8000}:8000"
- "${PORT_4:-3000}:3000"
environment: [ VERSION, SELECTED_REPO, FLASK_ENV=development ]
command: sleep infinity
restart: unless-stoppedDesign decisions:
:cached-- improves read perf on macOS Docker file sharingsleep infinity-- container stays alive; services start/stop independently inside itunless-stopped-- survives daemon restarts but respectsdocker compose down- Four ports -- covers common web stacks without per-repo compose overrides
5.3 Devcontainer (implements FR-042)
.devcontainer/devcontainer.json references the same compose file. Adds IDE extensions (Python, Pylance, Docker, Prettier, Tailwind CSS), editor settings, and port forwarding. Uses root as remote user for simplicity.
6.1 repo.json Schema (implements FR-004)
{
"name": "string",
"description": "string",
"stack": "python | node | node+python",
"ports": [8080, 5173],
"healthcheck": "http://localhost:8080/health",
"urls": { "Label": "http://localhost:8080" }
}| Variable | Default | Purpose |
|---|---|---|
BUILD_TIME |
development |
ISO 8601 build timestamp |
VERSION |
1.0.0 |
App version |
SELECTED_REPO |
(empty) | Active repo |
FLASK_ENV |
development |
Flask mode |
PORT_1-PORT_4 |
8080, 5173, 8000, 3000 | Host port overrides |
| File | Owner |
|---|---|
demo-site.{pid,log} |
demo-site start.sh |
backend.{pid,log} |
cyvl-geoguesser start.sh |
frontend.{pid,log} |
cyvl-geoguesser start.sh |
Layer 1 OS Entry Points Set $OS, delegate
Layer 2 Shared Logic OS-agnostic orchestration
Layer 3 OS Installers Native package managers per platform
Layer 4 Container Runtime Always Linux (inside container)
7.2 Docker Installation (implements FR-020--022)
| OS | Primary | Fallback | Daemon |
|---|---|---|---|
| macOS | brew install --cask docker |
DMG download | Docker Desktop |
| Windows | winget install Docker.DockerDesktop |
Direct download | Docker Desktop (WSL2) |
| Linux (Debian/Ubuntu) | Official Docker CE apt repo | get.docker.com |
Docker Engine |
| Linux (Fedora) | Official Docker CE dnf repo | get.docker.com |
Docker Engine |
| Linux (CentOS/RHEL) | Official Docker CE yum repo | get.docker.com |
Docker Engine |
| Linux (Arch/Manjaro) | pacman community packages | get.docker.com |
Docker Engine |
Linux also runs configure_docker_user() to add the user to the docker group.
7.3 Cursor Installation (implements FR-030--032)
| OS | Primary | Fallback | Post-Install |
|---|---|---|---|
| macOS | brew install --cask cursor |
DMG | setup_cursor_cli_macos (CLI symlink) |
| Windows | winget install Cursor.Cursor |
Direct download | PATH detection via Get-CursorPath |
| Linux | AppImage → ~/.local/bin/cursor.AppImage |
.deb package | Wrapper script, PATH update in .bashrc/.zshrc, .desktop entry |
7.4 Daemon Startup (implements FR-011)
| OS | Start command | Polling |
|---|---|---|
| macOS | open -a Docker |
docker info every 2s, up to $TIMEOUT |
| Windows | Start-Process "Docker Desktop" |
Same polling |
| Linux | systemctl start docker (fallback: service docker start) |
Same, plus sudo fallback |
7.5 Port Detection (implements FR-034--036)
| OS | Tool chain |
|---|---|
| macOS/Linux | lsof -nP -iTCP:$port → ss -tlnp → netstat -tlnp |
| Windows | Get-NetTCPConnection -LocalPort $port -State Listen |
Kill escalation: SIGTERM → sudo SIGTERM → SIGKILL → sudo SIGKILL (bash); Stop-Process -Force (PowerShell).
bash (utils.sh) |
PowerShell (utils.ps1) |
|---|---|
log_step() |
Write-Step |
log_info() |
Write-Info |
log_success() |
Write-Success |
log_warn() |
Write-Warn |
log_error() |
Write-ErrorMsg |
docker_installed() |
Test-DockerInstalled |
docker_running() |
Test-DockerRunning |
compose_available() |
Test-ComposeAvailable |
cursor_installed() |
Test-CursorInstalled |
port_in_use() |
Test-PortInUse |
get_port_process() |
Get-PortProcess |
kill_port_process() |
Stop-PortProcess |
check_and_free_ports() |
Test-AndFreePorts |
check_url() |
Test-UrlReachable |
wait_for() |
Wait-ForCondition |
All bash scripts use set -euo pipefail. PowerShell uses $ErrorActionPreference = "Stop".
8.2 Graceful Degradation (implements FR-070--072)
| Failure | Behavior |
|---|---|
| Docker install fails | Exit with error + manual instructions |
| Docker daemon won't start | Exit with retriable message |
| Cursor install fails | Log warning, continue container setup |
| Cursor won't open | Log warning, print manual path |
| Dependency install issues | Log warning, continue to service start |
| Port in use | Prompt or auto-kill (--force-ports) |
| Health check timeout (60s) | Log warning + "check logs" guidance, don't exit |
| Service start fails | Display log file path |
8.3 Idempotency (implements FR-003)
| Operation | Guard |
|---|---|
| Docker install | docker_installed() check first |
| Cursor install | detect_cursor_*() check first |
| Docker start | docker_running() check first |
| Container start | docker compose up -d is naturally idempotent |
| Port check | Re-scans each time |
| Deps install | Package managers handle already-installed |
| Service start | is_running() check or stop-before-start |
test.yml (push/PR to main)
├── test-linux (12 steps: full Docker build + run + endpoint tests)
├── test-macos (8 steps: scripts + actual Cursor install)
├── test-windows (8 steps: scripts + actual Cursor install)
└── test-summary (gate job: fails if any platform fails)
lint.yml (push/PR to main)
├── ShellCheck (all .sh, severity warning+, exclude SC1091/SC2034/SC2155)
├── PSScriptAnalyzer (all .ps1, severity warning+)
└── Hadolint (Dockerfile, failure threshold error, DL3008 inline-ignored)
- Verify Docker pre-installed
- Detection functions
- Port management functions
- Build container image
- Start container
- Bind mount verification
- Install demo-site deps
- Start demo-site service
- Health endpoint from host
- Main page from host
- API info endpoint from host
- Dev helper commands
- Stop service + cleanup
10.1 Adding a New Repository (implements FR-008)
- Create
repos/my-app/withcode/,scripts/start.sh, andrepo.json repo.jsonmust have:name,description,stack,ports,healthcheck,urlsstart.shmust handle:start,stop,restart,status,logs- Use the PID/log pattern from existing repos (setsid, process groups)
- No core script changes needed -- it appears automatically in selection menus
- Add a case to
install_deps()inscripts/start-repo.sh - If the container needs new tools, update the Dockerfile
- Create OS folder with
bootstrapanddeventry scripts - Create
scripts/install-docker-<os>.*andscripts/install-cursor-<os>.* - Add a case branch in
bootstrap-common.sh
- Single container -- all repos share one container; simultaneous multi-repo services may conflict on ports
- No production stage -- Dockerfile only has
baseanddevelopment - Linux-only container -- Windows containers not supported
- Root in container -- services run as root for simplicity
- PID-based process management -- no systemd/supervisord; relies on
setsid+ process groups - No persistent volumes -- npm/pip caches lost on rebuild
- Basic health polling -- fixed 2s interval, no backoff