feat: add production deployment and macOS dev scripts

Add dev.sh for running server + client in parallel on macOS, and prod.sh
for building and deploying in production. The Rust server now serves
static client files with SPA fallback, eliminating the need for Vite in
production. Also adds missing BRAVE_API_KEY to .env.example.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-08 07:27:06 -06:00
parent 39aaa96a99
commit 1c7d4d0510
5 changed files with 238 additions and 2 deletions

View File

@ -901,7 +901,6 @@
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }

71
dev.sh Executable file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env bash
# GroupChat2 Dev Script (macOS/Linux)
# Runs both server (Rust/Axum) and client (Vite) with unified console output.
# Ctrl+C stops both processes.
set -e
ROOT="$(cd "$(dirname "$0")" && pwd)"
# Colors
MAGENTA='\033[0;35m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
RED='\033[0;31m'
GRAY='\033[0;90m'
NC='\033[0m'
# Install client deps if needed
if [ ! -d "$ROOT/client/node_modules" ]; then
echo -e "${CYAN}[dev] Installing client dependencies...${NC}"
(cd "$ROOT/client" && npm install)
fi
echo ""
echo -e " ${MAGENTA}GroupChat2 Dev Server${NC}"
echo -e " ${YELLOW}Server: http://localhost:3001${NC}"
echo -e " ${CYAN}Client: http://localhost:3000${NC}"
echo -e " ${GRAY}Press Ctrl+C to stop both${NC}"
echo ""
# Track child PIDs for cleanup
SERVER_PID=""
CLIENT_PID=""
cleanup() {
echo ""
echo -e "${RED}[dev] Shutting down...${NC}"
# Kill background jobs
[ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null
[ -n "$CLIENT_PID" ] && kill "$CLIENT_PID" 2>/dev/null
# Kill any lingering processes on ports 3000/3001
lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
lsof -ti:3001 2>/dev/null | xargs kill -9 2>/dev/null || true
# Kill groupchat-server if still running
pkill -f "groupchat-server" 2>/dev/null || true
echo -e "${RED}[dev] Stopped.${NC}"
exit 0
}
trap cleanup INT TERM
# Start server (Rust/Axum)
(cd "$ROOT/server" && cargo run 2>&1 | while IFS= read -r line; do
[ -n "$line" ] && echo -e "${YELLOW}[server]${NC} $line"
done) &
SERVER_PID=$!
# Start client (Vite)
(cd "$ROOT/client" && npm run dev 2>&1 | while IFS= read -r line; do
[ -n "$line" ] && echo -e "${CYAN}[client]${NC} $line"
done) &
CLIENT_PID=$!
# Wait for either to exit
wait -n "$SERVER_PID" "$CLIENT_PID" 2>/dev/null
echo -e "${RED}[dev] A process exited unexpectedly.${NC}"
cleanup

150
prod.sh Executable file
View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
# GroupChat2 Production Build & Run Script
# Builds the client, compiles the server in release mode, and runs it.
# The Rust server serves the static client files directly — no Vite needed.
#
# Usage:
# ./prod.sh # Build + run
# ./prod.sh build # Build only (no run)
# ./prod.sh run # Run only (skip build, assumes already built)
set -e
ROOT="$(cd "$(dirname "$0")" && pwd)"
# Colors
MAGENTA='\033[0;35m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
RED='\033[0;31m'
GREEN='\033[0;32m'
GRAY='\033[0;90m'
NC='\033[0m'
MODE="${1:-all}" # all | build | run
# ─── Preflight checks ───────────────────────────────────────────────
check_env() {
if [ ! -f "$ROOT/server/.env" ]; then
echo -e "${RED}[prod] Missing server/.env file!${NC}"
echo -e "${GRAY} Copy server/.env.example and fill in your keys:${NC}"
echo -e "${GRAY} cp server/.env.example server/.env${NC}"
exit 1
fi
# Source .env to validate required vars
set -a
source "$ROOT/server/.env"
set +a
if [ -z "$OPENROUTER_API_KEY" ] || [ "$OPENROUTER_API_KEY" = "your-openrouter-api-key-here" ]; then
echo -e "${RED}[prod] OPENROUTER_API_KEY not set in server/.env${NC}"
exit 1
fi
if [ -z "$BRAVE_API_KEY" ] || [ "$BRAVE_API_KEY" = "your-brave-api-key-here" ]; then
echo -e "${RED}[prod] BRAVE_API_KEY not set in server/.env${NC}"
exit 1
fi
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "dev-secret-change-me" ]; then
echo -e "${YELLOW}[prod] WARNING: Using default JWT_SECRET — set a real secret for production!${NC}"
fi
}
# ─── Build ───────────────────────────────────────────────────────────
build() {
echo -e "${MAGENTA}[prod] Building GroupChat2 for production...${NC}"
echo ""
# 1. Install client deps if needed
if [ ! -d "$ROOT/client/node_modules" ]; then
echo -e "${CYAN}[prod] Installing client dependencies...${NC}"
(cd "$ROOT/client" && npm install)
fi
# 2. Build client (Vite → dist/)
echo -e "${CYAN}[prod] Building client...${NC}"
(cd "$ROOT/client" && npm run build)
echo -e "${GREEN}[prod] Client built → client/dist/${NC}"
# 3. Build server in release mode
echo -e "${YELLOW}[prod] Building server (release)...${NC}"
(cd "$ROOT/server" && cargo build --release)
echo -e "${GREEN}[prod] Server built → server/target/release/groupchat-server${NC}"
echo ""
echo -e "${GREEN}[prod] Build complete!${NC}"
}
# ─── Run ─────────────────────────────────────────────────────────────
run() {
check_env
# Verify build artifacts exist
if [ ! -f "$ROOT/server/target/release/groupchat-server" ]; then
echo -e "${RED}[prod] Server binary not found. Run './prod.sh build' first.${NC}"
exit 1
fi
if [ ! -d "$ROOT/client/dist" ]; then
echo -e "${RED}[prod] Client dist not found. Run './prod.sh build' first.${NC}"
exit 1
fi
BIND_ADDR="${BIND_ADDR:-0.0.0.0:3001}"
echo ""
echo -e " ${MAGENTA}GroupChat2 Production${NC}"
echo -e " ${GREEN}Domain: https://gchat.tud.ink${NC}"
echo -e " ${YELLOW}Bind: ${BIND_ADDR}${NC}"
echo -e " ${GRAY}Press Ctrl+C to stop${NC}"
echo ""
# Source .env so the server binary picks up the vars
set -a
source "$ROOT/server/.env"
set +a
export STATIC_DIR="$ROOT/client/dist"
export BIND_ADDR
export RUST_LOG="${RUST_LOG:-info}"
cleanup() {
echo ""
echo -e "${RED}[prod] Shutting down...${NC}"
kill "$SERVER_PID" 2>/dev/null || true
lsof -ti:3001 2>/dev/null | xargs kill -9 2>/dev/null || true
echo -e "${RED}[prod] Stopped.${NC}"
exit 0
}
trap cleanup INT TERM
# Run the release binary
"$ROOT/server/target/release/groupchat-server" &
SERVER_PID=$!
wait "$SERVER_PID"
}
# ─── Main ────────────────────────────────────────────────────────────
case "$MODE" in
build)
build
;;
run)
run
;;
all|"")
build
run
;;
*)
echo "Usage: $0 [build|run]"
echo " (no args) Build and run"
echo " build Build only"
echo " run Run only (must build first)"
exit 1
;;
esac

View File

@ -10,3 +10,9 @@ JWT_SECRET=change-me-to-a-random-secret
# OpenRouter API # OpenRouter API
OPENROUTER_API_KEY=sk-or-v1-your-key-here OPENROUTER_API_KEY=sk-or-v1-your-key-here
# Brave Search API
BRAVE_API_KEY=your-brave-api-key-here
# Production: path to built client files (default: ../client/dist)
# STATIC_DIR=../client/dist

View File

@ -11,6 +11,7 @@ use sqlx::sqlite::SqlitePoolOptions;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
pub struct AppState { pub struct AppState {
@ -97,7 +98,10 @@ async fn main() {
.allow_methods(Any) .allow_methods(Any)
.allow_headers(Any); .allow_headers(Any);
let app = Router::new() // Serve static files from client dist in production
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "../client/dist".into());
let api_routes = Router::new()
// Auth routes // Auth routes
.route("/api/auth/register", post(handlers::auth::register)) .route("/api/auth/register", post(handlers::auth::register))
.route("/api/auth/login", post(handlers::auth::login)) .route("/api/auth/login", post(handlers::auth::login))
@ -118,6 +122,12 @@ async fn main() {
.layer(cors) .layer(cors)
.with_state(state); .with_state(state);
// SPA fallback: serve static assets, fall back to index.html for client-side routing
let spa = ServeDir::new(&static_dir)
.not_found_service(ServeFile::new(format!("{}/index.html", static_dir)));
let app = api_routes.fallback_service(spa);
let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3001".into()); let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3001".into());
tracing::info!("Server starting on {}", addr); tracing::info!("Server starting on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();