more boilerplate and dev environment

This commit is contained in:
2026-07-16 16:04:48 -07:00
parent ea34be4e1b
commit a06f21a5c2
27 changed files with 670 additions and 27 deletions
+8 -2
View File
@@ -20,8 +20,14 @@ func InitDB() (*sql.DB, error) {
password = "secret" password = "secret"
} }
// Build the DSN. The host "db" matches the service name in docker-compose.yml. host := os.Getenv("DB_HOST")
dsn := fmt.Sprintf("oworganizer:%s@tcp(db:3306)/?parseTime=true", password) if host == "" {
// Default to the Docker Compose service name.
host = "db"
}
// Build the DSN. The default host "db" matches the service name in docker-compose.yml.
dsn := fmt.Sprintf("oworganizer:%s@tcp(%s:3306)/?parseTime=true", password, host)
db, err := sql.Open("mysql", dsn) db, err := sql.Open("mysql", dsn)
if err != nil { if err != nil {
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect github.com/cloudwego/base64x v0.1.7 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/cors v1.7.7 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
+2
View File
@@ -13,6 +13,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+17
View File
@@ -3,7 +3,10 @@ package main
import ( import (
"log" "log"
"net/http" "net/http"
"os"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -16,6 +19,20 @@ func main() {
router := gin.Default() router := gin.Default()
// Configure CORS. In production, set CORS_ALLOWED_ORIGINS to your public domain.
allowedOrigins := []string{"http://localhost:5173"}
if envOrigins := os.Getenv("CORS_ALLOWED_ORIGINS"); envOrigins != "" {
allowedOrigins = strings.Split(envOrigins, ",")
}
router.Use(cors.New(cors.Config{
AllowOrigins: allowedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * 60 * 60,
}))
router.GET("/ping", func(c *gin.Context) { router.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"message": "pong", "message": "pong",
+9 -2
View File
@@ -1,12 +1,19 @@
services: services:
frontend:
build: ./frontend
container_name: ${SERVICE_NAME}-oworganizer-frontend
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped
backend: backend:
build: ./backend build: ./backend
container_name: ${SERVICE_NAME}-oworganizer-backend container_name: ${SERVICE_NAME}-oworganizer-backend
environment: environment:
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
# volumes: # volumes:
ports:
- "8080:8080"
restart: unless-stopped restart: unless-stopped
db: db:
+6
View File
@@ -0,0 +1,6 @@
# Development API base URL.
# In production, the app uses the relative /api path and nginx proxies it to the backend.
VITE_API_BASE_URL=http://localhost:8080
# Vite dev server port. The dev script expects the frontend at http://localhost:5173.
# PORT=5173
+27
View File
@@ -0,0 +1,27 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
# Copy dependency files first for better layer caching
COPY package*.json ./
RUN npm ci
# Copy source code and build the static SPA
COPY . .
RUN npm run build
# Runtime stage
FROM nginx:alpine
# Copy the built static files to nginx's default document root
COPY --from=builder /app/build /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose the port nginx listens on
EXPOSE 80
# Run nginx in the foreground
CMD ["nginx", "-g", "daemon off;"]
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression for static assets
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Proxy API requests to the backend container
location /api/ {
proxy_pass http://backend:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Serve the SPA and fall back to index.html for client-side routes
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
+5 -5
View File
@@ -8,7 +8,7 @@
"name": "frontend", "name": "frontend",
"version": "0.0.1", "version": "0.0.1",
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "^5.56.1", "svelte": "^5.56.1",
@@ -434,10 +434,10 @@
"acorn": "^8.9.0" "acorn": "^8.9.0"
} }
}, },
"node_modules/@sveltejs/adapter-auto": { "node_modules/@sveltejs/adapter-static": {
"version": "7.0.1", "version": "3.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz",
"integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", "integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
+1 -1
View File
@@ -10,7 +10,7 @@
"prepare": "svelte-kit sync || echo ''" "prepare": "svelte-kit sync || echo ''"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "^5.56.1", "svelte": "^5.56.1",
@@ -0,0 +1,5 @@
<script>
let { message = 'Loading...' } = $props();
</script>
<p>{message}</p>
+19
View File
@@ -0,0 +1,19 @@
/**
* @typedef {Object} Note
* @property {string} objectId
* @property {'note'} type
* @property {string} text
* @property {string[]} childIds
*/
/**
* Create a Note object.
*
* @param {string} objectId
* @param {string} text
* @param {string[]} [childIds=[]]
* @returns {Note}
*/
export function createNoteModel(objectId, text, childIds = []) {
return { objectId, type: 'note', text, childIds };
}
+18
View File
@@ -0,0 +1,18 @@
/**
* @typedef {Object} ObjectRecord
* @property {string} objectId
* @property {string} userId
* @property {string} objectData
*/
/**
* Create an ObjectRecord object.
*
* @param {string} objectId
* @param {string} userId
* @param {string} objectData
* @returns {ObjectRecord}
*/
export function createObjectModel(objectId, userId, objectData) {
return { objectId, userId, objectData };
}
+19
View File
@@ -0,0 +1,19 @@
/**
* @typedef {Object} Project
* @property {string} objectId
* @property {'project'} type
* @property {string} name
* @property {string[]} childIds
*/
/**
* Create a Project object.
*
* @param {string} objectId
* @param {string} name
* @param {string[]} [childIds=[]]
* @returns {Project}
*/
export function createProjectModel(objectId, name, childIds = []) {
return { objectId, type: 'project', name, childIds };
}
+17
View File
@@ -0,0 +1,17 @@
/**
* @typedef {Object} Root
* @property {string} objectId
* @property {'root'} type
* @property {string[]} childIds
*/
/**
* Create a Root object.
*
* @param {string} objectId
* @param {string[]} [childIds=[]]
* @returns {Root}
*/
export function createRootModel(objectId, childIds = []) {
return { objectId, type: 'root', childIds };
}
+25
View File
@@ -0,0 +1,25 @@
/**
* @typedef {'not-started' | 'in-progress' | 'completed' | 'cancelled'} TaskStatus
*/
/**
* @typedef {Object} Task
* @property {string} objectId
* @property {'task'} type
* @property {string} text
* @property {TaskStatus} status
* @property {string[]} childIds
*/
/**
* Create a Task object.
*
* @param {string} objectId
* @param {string} text
* @param {TaskStatus} [status='not-started']
* @param {string[]} [childIds=[]]
* @returns {Task}
*/
export function createTaskModel(objectId, text, status = 'not-started', childIds = []) {
return { objectId, type: 'task', text, status, childIds };
}
+16
View File
@@ -0,0 +1,16 @@
/**
* @typedef {Object} User
* @property {string} userId
* @property {string} rootObjectId
*/
/**
* Create a User object.
*
* @param {string} userId
* @param {string} rootObjectId
* @returns {User}
*/
export function createUserModel(userId, rootObjectId) {
return { userId, rootObjectId };
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Base URL for API requests.
* In development, point to the Go backend directly (e.g., http://localhost:8080).
* In production, use the relative /api path so nginx proxies to the backend.
*/
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
/**
* Wrapper around fetch for calling the backend API.
*
* @param {string} path - API path, e.g., `/users` or `/objects/123`.
* @param {RequestInit} [options={}] - fetch options.
* @returns {Promise<any>} Parsed JSON response.
* @throws {Error} On non-OK responses.
*/
export async function apiFetch(path, options = {}) {
const url = `${API_BASE}${path}`;
const config = {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
};
if (config.body && typeof config.body === 'object' && !(config.body instanceof FormData)) {
config.body = JSON.stringify(config.body);
}
const response = await fetch(url, config);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
return response.json();
}
+69
View File
@@ -0,0 +1,69 @@
import { apiFetch } from './api.js';
/**
* @typedef {Object} ObjectRecord
* @property {string} objectId
* @property {string} userId
* @property {string} objectData
*/
/**
* Create a new object.
*
* @param {string} objectId
* @param {string} userId
* @param {string} objectData
* @returns {Promise<{status: string}>}
*/
export function createObject(objectId, userId, objectData) {
return apiFetch('/objects', {
method: 'POST',
body: { objectId, userId, objectData }
});
}
/**
* Fetch an object by ID.
*
* @param {string} objectId
* @returns {Promise<ObjectRecord>}
*/
export function getObject(objectId) {
return apiFetch(`/objects/${objectId}`);
}
/**
* Fetch all objects belonging to a user.
*
* @param {string} userId
* @returns {Promise<ObjectRecord[]>}
*/
export function getObjectsByUser(userId) {
return apiFetch(`/users/${userId}/objects`);
}
/**
* Update an object's data.
*
* @param {string} objectId
* @param {string} objectData
* @returns {Promise<{status: string}>}
*/
export function updateObject(objectId, objectData) {
return apiFetch(`/objects/${objectId}`, {
method: 'PUT',
body: { objectData }
});
}
/**
* Delete an object.
*
* @param {string} objectId
* @returns {Promise<{status: string}>}
*/
export function deleteObject(objectId) {
return apiFetch(`/objects/${objectId}`, {
method: 'DELETE'
});
}
+57
View File
@@ -0,0 +1,57 @@
import { apiFetch } from './api.js';
/**
* @typedef {Object} User
* @property {string} userId
* @property {string} rootObjectId
*/
/**
* Create a new user.
*
* @param {string} userId
* @param {string} rootObjectId
* @returns {Promise<{status: string}>}
*/
export function createUser(userId, rootObjectId) {
return apiFetch('/users', {
method: 'POST',
body: { userId, rootObjectId }
});
}
/**
* Fetch a user by ID.
*
* @param {string} userId
* @returns {Promise<User>}
*/
export function getUser(userId) {
return apiFetch(`/users/${userId}`);
}
/**
* Update a user's root object.
*
* @param {string} userId
* @param {string} rootObjectId
* @returns {Promise<{status: string}>}
*/
export function updateUser(userId, rootObjectId) {
return apiFetch(`/users/${userId}`, {
method: 'PUT',
body: { rootObjectId }
});
}
/**
* Delete a user.
*
* @param {string} userId
* @returns {Promise<{status: string}>}
*/
export function deleteUser(userId) {
return apiFetch(`/users/${userId}`, {
method: 'DELETE'
});
}
+16
View File
@@ -0,0 +1,16 @@
import { writable } from 'svelte/store';
/**
* Simple auth store. Replace with real authentication logic as needed.
*/
function createAuthStore() {
const { subscribe, set } = writable(null);
return {
subscribe,
login: (/** @type {string} */ userId) => set(userId),
logout: () => set(null)
};
}
export const auth = createAuthStore();
+2
View File
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;
+25 -2
View File
@@ -1,2 +1,25 @@
<h1>Welcome to SvelteKit</h1> <script>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js';
let userId = $state('');
function enterApp() {
if (userId.trim()) {
auth.login(userId.trim());
goto('/dashboard');
}
}
</script>
<h1>OwOrganizer</h1>
<p>Enter your user ID to open the app.</p>
<form onsubmit={(e) => { e.preventDefault(); enterApp(); }}>
<label>
User ID
<input type="text" bind:value={userId} placeholder="e.g., alice" />
</label>
<button type="submit">Open App</button>
</form>
@@ -0,0 +1,49 @@
<script>
import { auth } from '$lib/stores/auth.js';
import { getObjectsByUser } from '$lib/services/objects.js';
import Status from '$lib/components/Status.svelte';
let userId = $derived($auth);
let objects = $state([]);
let loading = $state(true);
let error = $state(null);
$effect(() => {
if (!userId) return;
loading = true;
getObjectsByUser(userId)
.then((data) => {
objects = data || [];
})
.catch((err) => {
error = err.message;
})
.finally(() => {
loading = false;
});
});
</script>
{#if !userId}
<p>You are not logged in. <a href="/">Go home</a></p>
{:else}
<h1>Dashboard</h1>
<p>Welcome, {userId}.</p>
<h2>Your Objects</h2>
{#if loading}
<Status message="Loading objects..." />
{:else if error}
<p style="color: red">Error: {error}</p>
{:else if objects.length === 0}
<p>No objects found.</p>
{:else}
<ul>
{#each objects as obj (obj.objectId)}
<li>
<strong>{obj.objectId}</strong>: {obj.objectData}
</li>
{/each}
</ul>
{/if}
{/if}
+25
View File
@@ -0,0 +1,25 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
kit: {
adapter: adapter({
// Output static files to the build directory.
// This is the default, but we make it explicit.
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
};
export default config;
+1 -15
View File
@@ -1,20 +1,6 @@
import adapter from '@sveltejs/adapter-auto';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [sveltekit()]
sveltekit({
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
})
]
}); });
Executable
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
set -euo pipefail
# Dev startup script for OwOrganizer.
# Runs the database in Docker and the backend/frontend natively.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DB_CONTAINER_NAME="oworganizer-dev-db"
DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-top-secret}"
DB_PASSWORD="${DB_PASSWORD:-secret}"
DB_USER="oworganizer"
DB_NAME="oworganizer"
DB_PORT="${DB_PORT:-3306}"
# Colors for output
RESET='\033[0m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RED='\033[0;31m'
log() {
echo -e "${GREEN}[dev]${RESET} $1"
}
warn() {
echo -e "${YELLOW}[dev]${RESET} $1"
}
cleanup() {
echo
warn "Shutting down dev environment..."
if [[ -n "${BACKEND_PID:-}" ]] && kill -0 "$BACKEND_PID" 2>/dev/null; then
log "Stopping backend (PID $BACKEND_PID)..."
kill "$BACKEND_PID" 2>/dev/null || true
wait "$BACKEND_PID" 2>/dev/null || true
fi
if [[ -n "${FRONTEND_PID:-}" ]] && kill -0 "$FRONTEND_PID" 2>/dev/null; then
log "Stopping frontend (PID $FRONTEND_PID)..."
kill "$FRONTEND_PID" 2>/dev/null || true
wait "$FRONTEND_PID" 2>/dev/null || true
fi
if [[ "${STOP_DB_ON_EXIT:-true}" == "true" ]]; then
log "Stopping database container..."
docker stop "$DB_CONTAINER_NAME" >/dev/null 2>&1 || true
else
warn "Leaving database container '$DB_CONTAINER_NAME' running."
fi
log "Dev environment stopped."
}
trap cleanup EXIT INT TERM
# Ensure required tools are available
command -v docker >/dev/null 2>&1 || { echo -e "${RED}docker is required but not installed.${RESET}"; exit 1; }
command -v go >/dev/null 2>&1 || { echo -e "${RED}go is required but not installed.${RESET}"; exit 1; }
command -v npm >/dev/null 2>&1 || { echo -e "${RED}npm is required but not installed.${RESET}"; exit 1; }
# Start the database container if it's not already running
if docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER_NAME}$"; then
warn "Database container '$DB_CONTAINER_NAME' is already running. Reusing it."
elif docker ps -a --format '{{.Names}}' | grep -q "^${DB_CONTAINER_NAME}$"; then
log "Starting existing database container '$DB_CONTAINER_NAME'..."
docker start "$DB_CONTAINER_NAME" >/dev/null
else
log "Creating and starting database container '$DB_CONTAINER_NAME'..."
docker run -d \
--name "$DB_CONTAINER_NAME" \
-e MYSQL_ROOT_PASSWORD="$DB_ROOT_PASSWORD" \
-e MYSQL_USER="$DB_USER" \
-e MYSQL_PASSWORD="$DB_PASSWORD" \
-e MYSQL_DATABASE="$DB_NAME" \
-p "${DB_PORT}:3306" \
mariadb:10 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_unicode_ci \
>/dev/null
fi
# Wait for MariaDB to be ready
log "Waiting for database to be ready..."
for i in {1..60}; do
if docker exec "$DB_CONTAINER_NAME" mysqladmin ping \
-h localhost -u "$DB_USER" --password="$DB_PASSWORD" \
>/dev/null 2>&1; then
# mysqladmin ping can return true before the server accepts SQL connections,
# so wait a little longer for the server to finish initializing.
sleep 2
log "Database is ready."
break
fi
if [[ "$i" -eq 60 ]]; then
echo -e "${RED}Database did not become ready in time.${RESET}"
exit 1
fi
sleep 1
done
# Start the backend (with retries in case the DB isn't fully accepting connections yet)
log "Starting backend..."
cd "$PROJECT_DIR/backend"
BACKEND_START_ATTEMPTS="${BACKEND_START_ATTEMPTS:-3}"
for attempt in $(seq 1 "$BACKEND_START_ATTEMPTS"); do
DB_HOST=localhost DB_PASSWORD="$DB_PASSWORD" go run . &
BACKEND_PID=$!
# Wait for backend to be ready
log "Waiting for backend to be ready (attempt $attempt/$BACKEND_START_ATTEMPTS)..."
ready=false
for i in {1..30}; do
if curl -s http://localhost:8080/ping >/dev/null 2>&1; then
log "Backend is ready at http://localhost:8080"
ready=true
break
fi
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
warn "Backend exited early, possibly waiting for DB."
break
fi
sleep 1
done
if [[ "$ready" == "true" ]]; then
break
fi
if [[ "$attempt" -eq "$BACKEND_START_ATTEMPTS" ]]; then
echo -e "${RED}Backend did not become ready after $BACKEND_START_ATTEMPTS attempts.${RESET}"
exit 1
fi
warn "Retrying backend startup..."
sleep 2
done
# Start the frontend
log "Starting frontend..."
cd "$PROJECT_DIR/frontend"
if [[ ! -f .env ]]; then
warn "No .env file found in frontend/. Creating from .env.example..."
cp .env.example .env
fi
npm run dev &
FRONTEND_PID=$!
log "Frontend starting. It will be available at http://localhost:5173"
log "${BLUE}Press Ctrl-C to stop all services.${RESET}"
# Wait for either process to exit
wait -n "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null || true