From b5b0605e9a11b3ead3bc78a3109703d195f65f09 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Sun, 22 Mar 2026 11:50:33 +0800 Subject: [PATCH] chore: add one-click setup/start/stop scripts, migration CLI, and seed tool - Add idempotent seed tool with duplicate detection for agents/issues/comments - Add migration CLI supporting up/down with schema_migrations tracking - Add Makefile targets: make setup (first-time), make start, make stop - Update .gitignore for test artifacts and compiled binaries Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 ++ CLAUDE.md | 13 ++- Makefile | 43 +++++++++- server/cmd/migrate/main.go | 131 ++++++++++++++++++++++++++++++ server/cmd/seed/main.go | 162 +++++++++++++++++++++++++++++++++++++ 5 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 server/cmd/migrate/main.go create mode 100644 server/cmd/seed/main.go diff --git a/.gitignore b/.gitignore index 1c03660d..34311237 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,11 @@ coverage # Go server/bin/ server/tmp/ +server/migrate + +# Test artifacts +test-results/ +apps/web/test-results/ # context (agent workspace) .context diff --git a/CLAUDE.md b/CLAUDE.md index f6e7cd23..f105d3d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,23 +21,30 @@ Multica is an AI-native task management platform — like Linear, but with AI ag ## 3. Core Workflow Commands ```bash +# One-click setup & run +make setup # First-time: install deps, start DB, migrate, seed +make start # Start backend + frontend together +make stop # Stop everything + # Frontend pnpm install -pnpm dev:web # Next.js dev server +pnpm dev:web # Next.js dev server (port 3000) pnpm build # Build all TS packages pnpm typecheck # TypeScript check -pnpm test # TS tests +pnpm test # TS tests (Vitest) # Backend (Go) -make dev # Run Go server with hot-reload +make dev # Run Go server (port 8080) make daemon # Run local daemon make test # Go tests make sqlc # Regenerate sqlc code make migrate-up # Run database migrations make migrate-down # Rollback migrations +make seed # Seed sample data # Infrastructure docker compose up -d # Start PostgreSQL +docker compose down # Stop PostgreSQL ``` ## 4. Coding Rules diff --git a/Makefile b/Makefile index e30aef09..1b3ba01c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,45 @@ -.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean +.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop + +# ---------- One-click commands ---------- + +# First-time setup: install deps, start DB, run migrations, seed data +setup: + @echo "==> Installing dependencies..." + pnpm install + @echo "==> Starting PostgreSQL..." + docker compose up -d + @echo "==> Waiting for PostgreSQL to be ready..." + @until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ + sleep 1; \ + done + @echo "==> Running migrations..." + cd server && go run ./cmd/migrate up + @echo "==> Seeding data..." + cd server && go run ./cmd/seed + @echo "" + @echo "✓ Setup complete! Run 'make start' to launch the app." + +# Start all services (backend + frontend) +start: + @docker compose up -d + @until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ + sleep 1; \ + done + @echo "Starting backend and frontend..." + @trap 'kill 0' EXIT; \ + (cd server && go run ./cmd/server) & \ + pnpm dev:web & \ + wait + +# Stop all services +stop: + @echo "Stopping services..." + @-lsof -ti:8080 | xargs kill -9 2>/dev/null + @-lsof -ti:3000 | xargs kill -9 2>/dev/null + docker compose down + @echo "✓ All services stopped." + +# ---------- Individual commands ---------- # Go server dev: diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go new file mode 100644 index 00000000..ab190318 --- /dev/null +++ b/server/cmd/migrate/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: go run ./cmd/migrate ") + os.Exit(1) + } + + direction := os.Args[1] + if direction != "up" && direction != "down" { + fmt.Println("Usage: go run ./cmd/migrate ") + os.Exit(1) + } + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable" + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + log.Fatalf("Unable to connect to database: %v", err) + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + log.Fatalf("Unable to ping database: %v", err) + } + + // Create migrations tracking table + _, err = pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `) + if err != nil { + log.Fatalf("Failed to create migrations table: %v", err) + } + + // Find migration files + migrationsDir := "migrations" + if _, err := os.Stat(migrationsDir); os.IsNotExist(err) { + // Try from server/ directory + migrationsDir = "server/migrations" + } + + suffix := "." + direction + ".sql" + files, err := filepath.Glob(filepath.Join(migrationsDir, "*"+suffix)) + if err != nil { + log.Fatalf("Failed to find migration files: %v", err) + } + + if direction == "up" { + sort.Strings(files) + } else { + sort.Sort(sort.Reverse(sort.StringSlice(files))) + } + + for _, file := range files { + version := extractVersion(file) + + if direction == "up" { + // Check if already applied + var exists bool + err := pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists) + if err != nil { + log.Fatalf("Failed to check migration status: %v", err) + } + if exists { + fmt.Printf(" skip %s (already applied)\n", version) + continue + } + } else { + // Check if applied (only rollback applied ones) + var exists bool + err := pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists) + if err != nil { + log.Fatalf("Failed to check migration status: %v", err) + } + if !exists { + fmt.Printf(" skip %s (not applied)\n", version) + continue + } + } + + sql, err := os.ReadFile(file) + if err != nil { + log.Fatalf("Failed to read %s: %v", file, err) + } + + _, err = pool.Exec(ctx, string(sql)) + if err != nil { + log.Fatalf("Failed to run %s: %v", file, err) + } + + if direction == "up" { + _, err = pool.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version) + } else { + _, err = pool.Exec(ctx, "DELETE FROM schema_migrations WHERE version = $1", version) + } + if err != nil { + log.Fatalf("Failed to record migration %s: %v", version, err) + } + + fmt.Printf(" %s %s\n", direction, version) + } + + fmt.Println("Done.") +} + +func extractVersion(filename string) string { + base := filepath.Base(filename) + // Remove .up.sql or .down.sql + base = strings.TrimSuffix(base, ".up.sql") + base = strings.TrimSuffix(base, ".down.sql") + return base +} diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go new file mode 100644 index 00000000..27ecb74b --- /dev/null +++ b/server/cmd/seed/main.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable" + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + log.Fatalf("Unable to connect to database: %v", err) + } + defer pool.Close() + + // Create seed user + var userID string + err = pool.QueryRow(ctx, ` + INSERT INTO "user" (name, email, avatar_url) + VALUES ('Jiayuan Zhang', 'jiayuan@multica.ai', NULL) + ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name + RETURNING id + `).Scan(&userID) + if err != nil { + log.Fatalf("Failed to create user: %v", err) + } + fmt.Printf("User created: %s\n", userID) + + // Create seed workspace + var workspaceID string + err = pool.QueryRow(ctx, ` + INSERT INTO workspace (name, slug, description) + VALUES ('Multica', 'multica', 'AI-native task management') + ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name + RETURNING id + `).Scan(&workspaceID) + if err != nil { + log.Fatalf("Failed to create workspace: %v", err) + } + fmt.Printf("Workspace created: %s\n", workspaceID) + + // Add user as owner + _, err = pool.Exec(ctx, ` + INSERT INTO member (workspace_id, user_id, role) + VALUES ($1, $2, 'owner') + ON CONFLICT (workspace_id, user_id) DO NOTHING + `, workspaceID, userID) + if err != nil { + log.Fatalf("Failed to create member: %v", err) + } + fmt.Println("Member created") + + // Create some agents + agents := []struct { + name string + runtimeMode string + status string + }{ + {"Claude-1", "cloud", "idle"}, + {"Claude-2", "cloud", "working"}, + {"Local Agent", "local", "offline"}, + {"Code Review Bot", "cloud", "idle"}, + } + + for _, a := range agents { + var agentID string + // Check if agent already exists + err = pool.QueryRow(ctx, ` + SELECT id FROM agent WHERE workspace_id = $1 AND name = $2 + `, workspaceID, a.name).Scan(&agentID) + if err == nil { + fmt.Printf("Agent exists: %s (%s)\n", a.name, agentID) + continue + } + err = pool.QueryRow(ctx, ` + INSERT INTO agent (workspace_id, name, runtime_mode, status, owner_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, workspaceID, a.name, a.runtimeMode, a.status, userID).Scan(&agentID) + if err != nil { + log.Printf("Failed to create agent %s: %v", a.name, err) + continue + } + fmt.Printf("Agent created: %s (%s)\n", a.name, agentID) + } + + // Create seed issues + issues := []struct { + title string + description string + status string + priority string + position float64 + }{ + {"Add multi-workspace support", "Users should be able to create and switch between multiple workspaces.", "backlog", "medium", 1}, + {"Agent long-term memory persistence", "Agents need persistent memory across sessions for better context.", "backlog", "low", 2}, + {"Design the agent config UI", "Create a configuration interface for agent settings and capabilities.", "todo", "high", 3}, + {"Implement issue list API endpoint", "Build the REST API for listing, filtering, and paginating issues.", "in_progress", "urgent", 4}, + {"Implement OAuth login flow", "Set up OAuth 2.0 with Google for user authentication.", "in_progress", "high", 5}, + {"Add WebSocket reconnection logic", "Handle disconnections gracefully with exponential backoff.", "in_review", "medium", 6}, + {"Set up CI/CD pipeline", "Configure GitHub Actions for automated testing and deployment.", "done", "high", 7}, + {"Design database schema", "Create the initial PostgreSQL schema for all entities.", "done", "urgent", 8}, + {"Implement real-time notifications", "Push notifications to users via WebSocket when issues change.", "todo", "medium", 9}, + {"Agent task queue management", "Build the task dispatching and queue management system for agents.", "todo", "high", 10}, + } + + for _, iss := range issues { + var issueID string + // Check if issue already exists + err = pool.QueryRow(ctx, ` + SELECT id FROM issue WHERE workspace_id = $1 AND title = $2 + `, workspaceID, iss.title).Scan(&issueID) + if err == nil { + fmt.Printf("Issue exists: %s (%s)\n", iss.title, issueID) + continue + } + err = pool.QueryRow(ctx, ` + INSERT INTO issue (workspace_id, title, description, status, priority, creator_type, creator_id, position) + VALUES ($1, $2, $3, $4, $5, 'member', $6, $7) + RETURNING id + `, workspaceID, iss.title, iss.description, iss.status, iss.priority, userID, iss.position).Scan(&issueID) + if err != nil { + log.Printf("Failed to create issue %s: %v", iss.title, err) + continue + } + fmt.Printf("Issue created: %s (%s)\n", iss.title, issueID) + } + + // Create seed comment (only if not already present) + var commentExists bool + _ = pool.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM comment c + JOIN issue i ON c.issue_id = i.id + WHERE i.workspace_id = $1 AND i.title = 'Implement issue list API endpoint' + AND c.content = 'This is a high priority item for Q2.' + ) + `, workspaceID).Scan(&commentExists) + if !commentExists { + _, err = pool.Exec(ctx, ` + INSERT INTO comment (issue_id, author_type, author_id, content, type) + SELECT i.id, 'member', $2, 'This is a high priority item for Q2.', 'comment' + FROM issue i WHERE i.workspace_id = $1 AND i.title = 'Implement issue list API endpoint' + `, workspaceID, userID) + if err != nil { + log.Printf("Failed to create comment: %v", err) + } + } + + fmt.Println("\nSeed data created successfully!") + fmt.Printf("\nUser ID: %s\n", userID) + fmt.Printf("Workspace ID: %s\n", workspaceID) +}