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 <noreply@anthropic.com>
This commit is contained in:
parent
6dfc61fa86
commit
b5b0605e9a
5 changed files with 350 additions and 4 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -21,6 +21,11 @@ coverage
|
|||
# Go
|
||||
server/bin/
|
||||
server/tmp/
|
||||
server/migrate
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
apps/web/test-results/
|
||||
|
||||
# context (agent workspace)
|
||||
.context
|
||||
|
|
|
|||
13
CLAUDE.md
13
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
|
||||
|
|
|
|||
43
Makefile
43
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:
|
||||
|
|
|
|||
131
server/cmd/migrate/main.go
Normal file
131
server/cmd/migrate/main.go
Normal file
|
|
@ -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 <up|down>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
direction := os.Args[1]
|
||||
if direction != "up" && direction != "down" {
|
||||
fmt.Println("Usage: go run ./cmd/migrate <up|down>")
|
||||
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
|
||||
}
|
||||
162
server/cmd/seed/main.go
Normal file
162
server/cmd/seed/main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue