A bash-native, netcat-served parody of Rails. Build web apps with shell scripts!
"Rails-like DX, bash-scale features."
- Rails-like routing DSL (
get,post,resources) - Controllers with actions,
before_action, and helpers - View templates with layouts, partials, and interpolation
- Flat-file models (CSV) with validations and hooks
- Generators for scaffolds, controllers, and models
- Tailwind CSS via CDN for styling
- HTMX for modern, JavaScript-light interactivity
- Built-in test runner
- Zero extra runtimes — pure bash + standard Unix tools
- macOS (tested) or Linux
- Bash 3.2+
- BSD/GNU netcat (
nc) - Standard Unix utilities:
sed,awk,date,mktemp,cut,tr
# Clone the repo
git clone https://github.com/HerringtonDarkholme/balls.git
cd balls
# Create a new app
./balls new myapp
cd myapp
# Start the server
../balls server
# Visit http://localhost:14514balls new <app> # Create new application
balls server [path] [port] # Start development server (default: port 14514)
balls routes [path] # Display route table
balls generate controller <name> [actions...] # Generate controller
balls generate model <name> [fields...] # Generate model
balls generate scaffold <name> [fields...] # Generate full CRUD scaffold
balls test [path] # Run test suite
balls help # Show help
balls version # Show version# Create a new app
./balls new blog
# Generate a scaffold with fields
./balls generate scaffold post title:string body:text
# Generate just a controller with specific actions
./balls generate controller comments index show create
# Generate just a model with fields
./balls generate model comment body:string post_id:integer
# Start server on custom port
./balls server ./myapp 8080
# View routes
./balls routes ./myapp
# Run all tests
./balls test
# Run specific test file
./balls test test/unit/routing_test.sh
# Run only unit tests
./balls test test/unitmyapp/
├── app/
│ ├── controllers/ # Controller scripts (*_controller.sh)
│ ├── models/ # Model definitions (*.sh)
│ └── views/ # View templates (*.sh.html)
│ └── layouts/ # Layout templates
├── config/
│ └── routes.sh # Route definitions
├── db/ # Data storage (*.csv, *.counter)
├── log/ # Log files (access.log)
├── public/ # Static files (served directly)
├── tmp/cache/ # Fragment cache
└── .env # Environment configuration
Define routes in config/routes.sh:
# Basic routes
get "/" "home#index"
get "/about" "pages#about"
post "/contact" "pages#submit"
# Route with parameter
get "/posts/:id" "posts#show"
get "/posts/:id/edit" "posts#edit"
# RESTful resources (creates all 7 CRUD routes + 1 PATCH)
resources "posts"
# Generates:
# GET /posts -> posts#index
# GET /posts/new -> posts#new
# POST /posts -> posts#create
# GET /posts/:id -> posts#show
# GET /posts/:id/edit -> posts#edit
# PUT /posts/:id -> posts#update
# PATCH /posts/:id -> posts#update
# DELETE /posts/:id -> posts#destroy
# Root route helper
root "home#index" # Same as: get "/" "home#index"Controllers live in app/controllers/ and define *_action functions:
#!/usr/bin/env bash
# app/controllers/posts_controller.sh
# Check if request is from HTMX
is_htmx_request() {
[[ "$HX_REQUEST" == "true" ]]
}
# Before actions (optional)
before_action() {
# Run before every action
# Return non-zero to halt request
}
index_action() {
posts=$(model_all "posts")
render "posts/index"
}
show_action() {
post=$(model_find "posts" "$id")
if [[ -z "$post" ]]; then
set_flash error "Post not found"
redirect_to "/posts"
return
fi
render "posts/show"
}
create_action() {
if model_create "posts"; then
set_flash notice "Post created!"
redirect_to "/posts"
else
set_flash error "Failed to create post"
render "posts/new"
fi
}
destroy_action() {
model_destroy "posts" "$id"
if is_htmx_request; then
# For HTMX: redirect via header
header "HX-Redirect" "/posts"
render_html ""
else
redirect_to "/posts"
fi
}render "view/path" # Render a view template
redirect_to "/path" # HTTP redirect (302)
redirect_to "/path" 301 # Redirect with custom status
set_flash notice "message" # Set flash message for next request
set_flash error "message"
param "name" # Get request parameter
param "name" "default" # With default value
params_expect "title" "body" # Validate required params (returns error if missing)
status 404 # Set response status
header "X-Custom" "value" # Set response header
render_text "plain text" # Render plain text
render_json '{"key":"val"}' # Render JSON
render_html "<h1>Hi</h1>" # Render HTML directly
# HTMX detection
is_htmx_request # Returns true if HX-Request header is present
$HX_REQUEST # "true" if HTMX request
$HX_TARGET # Target element ID
$HX_TRIGGER # Triggering element IDViews are .sh.html templates in app/views/:
<!-- app/views/posts/show.sh.html -->
<h1>{{title}}</h1>
<p>{{body}}</p>
<p>Posted on: {{# date -r "$created_at" "+%Y-%m-%d" }}</p>
<a href="/posts/{{id}}/edit">Edit</a>
<a href="/posts">Back to list</a>{{variable}} <!-- Variable interpolation (HTML-escaped) -->
{{{variable}}} <!-- Raw output (no escaping - use with caution!) -->
{{# shell_command }} <!-- Execute shell and insert output -->
{{#if variable}} <!-- Conditional (shows if variable is non-empty) -->
<p>Variable is set!</p>
{{/if}}
{{#each posts}} <!-- Loop over CSV records -->
<li>{{id}}: {{title}}</li>
{{/each}}
{{> partials/header}} <!-- Include partial from app/views/partials/header.sh.html -->The {{#each}} tag iterates over CSV model data, automatically parsing fields:
<!-- In controller: posts=$(model_all "posts") -->
{{#each posts}}
<article id="post-{{id}}">
<h2>{{title}}</h2>
<p>{{body}}</p>
</article>
{{/each}}Field variables ({{id}}, {{title}}, etc.) are set from the CSV header for each record.
All {{variable}} output is HTML-escaped by default to prevent XSS attacks:
<!-- If title contains "<script>alert('xss')</script>" -->
{{title}}
<!-- Outputs: <script>alert('xss')</script> -->Use triple braces {{{variable}}} for raw HTML output (only for trusted content):
<!-- For trusted HTML content only -->
{{{trusted_html}}}Layouts wrap views and use {{yield}} for content. Tailwind CSS and HTMX are included by default:
<!-- app/views/layouts/application.sh.html -->
<!DOCTYPE html>
<html>
<head>
<title>{{title}} - My App</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style type="text/tailwindcss">
@theme {
--color-primary: #667eea;
--color-primary-hover: #5a6fd6;
--color-danger: #e74c3c;
--color-danger-hover: #c0392b;
}
</style>
</head>
<body hx-boost="true" class="min-h-screen bg-gray-100">
{{#if flash_notice}}
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{flash_notice}}
</div>
{{/if}}
{{#if flash_error}}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{flash_error}}
</div>
{{/if}}
{{yield}}
</body>
</html># In templates, use these helpers:
{{# link_to "Click me" "/path" }}
{{# link_to "Edit" "/posts/$id/edit" "class=btn" }}
{{# form_with "/posts" "post" }}
{{# form_with "/posts/$id" "put" }}
{{# text_field "title" "$title" }}
{{# text_area "body" "$body" }}
{{# submit_button "Save" }}
# Note: {{variable}} is auto-escaped. Use {{{variable}}} for raw HTML.
# The h() function is available for manual escaping in shell blocks:
{{# h "$user_input" }}Bash on Balls uses HTMX as its built-in JavaScript framework for modern, interactive UIs without writing JavaScript.
- hx-boost: Enabled on
<body>by default — all links and forms use AJAX automatically - HTMX headers: The server parses
HX-Request,HX-Target,HX-Triggerheaders
<!-- Delete button with confirmation -->
<button hx-delete="/posts/{{id}}"
hx-target="#post-{{id}}"
hx-swap="outerHTML"
hx-confirm="Are you sure?">
Delete
</button>
<!-- Form with HTMX -->
<form hx-post="/posts"
hx-target="body"
hx-push-url="true">
<input type="text" name="title">
<button type="submit">Create</button>
</form>
<!-- Inline editing -->
<div hx-get="/posts/{{id}}/edit"
hx-trigger="click"
hx-swap="outerHTML">
Click to edit
</div>Controllers can set HTMX response headers:
destroy_action() {
model_destroy "posts" "$id"
if is_htmx_request; then
# Redirect after delete
header "HX-Redirect" "/posts"
render_html ""
else
redirect_to "/posts"
fi
}Available HTMX headers:
HX-Redirect— Client-side redirectHX-Refresh— Full page refreshHX-Trigger— Trigger client-side events
Bash on Balls uses Tailwind CSS via CDN for styling. No build step required!
Define custom colors in your layout using @theme:
<style type="text/tailwindcss">
@theme {
--color-primary: #667eea;
--color-primary-hover: #5a6fd6;
--color-danger: #e74c3c;
--color-danger-hover: #c0392b;
}
</style>Then use them in your views:
<button class="bg-primary hover:bg-primary-hover text-white px-4 py-2 rounded-lg">
Submit
</button>
<button class="bg-danger hover:bg-danger-hover text-white px-4 py-2 rounded-lg">
Delete
</button><!-- Card -->
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold mb-2">Title</h2>
<p class="text-gray-600">Content goes here</p>
</div>
<!-- Form input -->
<input type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg
focus:ring-2 focus:ring-primary focus:border-transparent
outline-none transition">
<!-- Alert -->
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
Success message
</div>Models are defined in app/models/ and use CSV storage:
#!/usr/bin/env bash
# app/models/post.sh
MODEL_NAME="post"
MODEL_FIELDS=("id" "title" "body" "created_at")
# Validations (optional)
validate_post() {
local errors=()
[[ -z "$title" ]] && errors+=("Title can't be blank")
[[ ${#title} -lt 3 ]] && errors+=("Title must be at least 3 characters")
printf '%s\n' "${errors[@]}"
}
# Callbacks (optional)
before_save_post() {
# Set timestamp on create
[[ -z "$created_at" ]] && created_at=$(date +%s)
}
after_save_post() {
# Run after successful save
:
}model_all "posts" # Get all records (newline-separated)
model_find "posts" "$id" # Find by ID
model_create "posts" # Create from params
model_update "posts" "$id" # Update from params
model_destroy "posts" "$id" # Delete record
model_count "posts" # Count records
model_where "posts" "field" "value" # Find by field value
# Parse a record into variables
parse_record "posts" "$record"
# Now $id, $title, $body, etc. are availableData is stored in CSV format in db/:
db/posts.csv # Data file
db/posts.counter # Auto-increment counter
Files in public/ are served directly:
public/style.css -> GET /style.css
public/js/app.js -> GET /js/app.js
public/favicon.ico -> GET /favicon.ico
Create tests in test/ directory:
#!/usr/bin/env bash
# test/unit/my_test.sh
source "$(dirname "$0")/../test_helper.sh"
echo "=== My Tests ==="
test_start "something works"
result=$(some_function)
if assert_equal "expected" "$result"; then
test_pass
else
test_fail
fi
test_summarytest_start "description" # Start a test
test_pass # Mark as passed
test_fail "message" # Mark as failed
assert_equal "expected" "actual" "message"
assert_contains "haystack" "needle" "message"
assert_not_empty "$value" "message"
assert_file_exists "/path/to/file"
assert_dir_exists "/path/to/dir"
assert_success # Check last exit code was 0
assert_failure $? # Check last exit code was non-zero
setup_temp_dir # Create temp directory ($TEMP_DIR)
cleanup_temp_dir # Remove temp directoryRun tests:
./balls test # Run all tests
./balls test test/unit # Run unit tests only
./balls test test/e2e # Run E2E tests only
./balls test path/to/test.sh # Run specific test fileCreate .env in your app directory:
PORT=14514
HOST=127.0.0.1
BALLS_ENV=development
DB_BACKEND=csvThis framework is designed for macOS with these considerations:
- BSD netcat: macOS
nclacks the-k(keep-alive) flag. The server uses a FIFO-based accept loop to handle multiple requests. - Bash 3.2: macOS ships with Bash 3.2, which lacks associative arrays. The framework uses indexed arrays and
PARAM_*variable naming conventions instead. - BSD sed: Uses
sed -Efor extended regex. In-place editing usessed -i ''. - No external dependencies: Works with standard macOS utilities.
balls/
├── balls # Main CLI executable
├── lib/
│ ├── routing.sh # Routing DSL
│ ├── controller.sh # Controller runtime
│ ├── view.sh # View renderer
│ ├── model.sh # Model/ORM layer
│ ├── server.sh # HTTP server (nc-based)
│ └── test.sh # Test runner
├── example/ # Example application
└── test/ # Test suite
├── unit/ # Unit tests
├── e2e/ # End-to-end tests
└── test_helper.sh # Test utilities
cd balls
./balls server example/ 14514
# Visit http://localhost:14514
# Try http://localhost:14514/posts for CRUD operationsMIT