# GitM Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a cross-platform Gitea repository sync tool with a single-binary Go backend and embedded Vue 3 frontend **Architecture:** Go (Gin) backend with SQLite storage, Vue 3 + Element Plus frontend embedded via Go embed, JWT authentication, Gitea API integration for repo discovery and git-based mirroring **Tech Stack:** Go 1.26+, Gin, SQLite (mattn/go-sqlite3), Vue 3, Element Plus, Pinia, Vite, golang-jwt/jwt, bcrypt --- ## Phase 1: Project Initialization and Core Infrastructure ### Task 1: Initialize Go Module and Project Structure **Files:** - Create: `go.mod` - Create: `go.sum` (auto-generated) - Create: `Makefile` - Create: `main.go` - Create: `README.md` - [ ] **Step 1: Initialize Go module** ```bash cd /c/electron/gitm go mod init gitm ``` - [ ] **Step 2: Create go.mod with dependencies** Create `go.mod`: ```go module gitm go 1.26 require ( github.com/gin-gonic/gin v1.10.0 github.com/mattn/go-sqlite3 v1.14.22 github.com/golang-jwt/jwt/v5 v5.2.1 golang.org/x/crypto v0.25.0 github.com/robfig/cron/v3 v3.0.1 ) ``` - [ ] **Step 3: Download dependencies** ```bash go mod tidy ``` Expected: `go.sum` created, dependencies downloaded - [ ] **Step 4: Create Makefile** Create `Makefile`: ```makefile .PHONY: all build frontend clean test run # Build frontend first, then build the binary all: frontend build # Build Vue frontend frontend: cd web && npm install && npm run build # Build Go binary build: go build -o bin/gitm . # Clean build artifacts clean: rm -rf bin/ rm -rf web/dist/ rm -rf web/node_modules/ # Run tests test: go test -v ./... # Run the application run: go run main.go # Cross-compile for Linux build-linux: GOOS=linux GOARCH=amd64 go build -o bin/gitm-linux . # Cross-compile for Windows build-windows: GOOS=windows GOARCH=amd64 go build -o bin/gitm.exe . ``` - [ ] **Step 5: Create main.go skeleton** Create `main.go`: ```go package main import ( "flag" "fmt" "log" ) var ( flagAddr = flag.String("addr", ":9000", "Listen address") flagDataDir = flag.String("data", "./data", "Data directory") flagInit = flag.Bool("init", false, "Initialize database and set password") ) func main() { flag.Parse() fmt.Printf("GitM - Gitea Repository Sync Tool\n") fmt.Printf("Listen: %s\n", *flagAddr) fmt.Printf("Data: %s\n", *flagDataDir) if *flagInit { fmt.Println("Initialize mode: TODO") return } log.Fatal(runServer()) } func runServer() error { // TODO: implement server return fmt.Errorf("not implemented") } ``` - [ ] **Step 6: Create README.md** Create `README.md`: ```markdown # GitM - Gitea Repository Sync Tool Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage. ## Features - Single binary deployment - Web UI for management (Vue 3 + Element Plus) - SQLite database - JWT authentication - Scheduled and manual sync - Cross-platform (Windows, Linux) ## Build ```bash make all ``` ## Run ```bash # First run - initialize ./bin/gitm --init # Normal run ./bin/gitm # Custom port ./bin/gitm --addr :9090 ``` ## Development ```bash # Install frontend deps cd web && npm install # Run frontend dev server cd web && npm run dev # Run backend (requires frontend built first) go run main.go ``` ``` - [ ] **Step 7: Commit** ```bash git add go.mod go.sum Makefile main.go README.md git commit -m "feat: initialize project structure and dependencies" ``` --- ### Task 2: Create Internal Package Structure **Files:** - Create: `internal/config/config.go` - Create: `internal/models/models.go` - [ ] **Step 1: Create config package** Create `internal/config/config.go`: ```go package config import ( "os" "path/filepath" "sync" ) type Config struct { ListenAddr string DataDir string DBPath string ReposDir string once sync.Once } var ( instance *Config initOnce sync.Once ) func Get() *Config { initOnce.Do(func() { instance = &Config{ ListenAddr: ":9000", DataDir: "./data", } }) return instance } func (c *Config) SetDataDir(dir string) { c.DataDir = dir c.DBPath = filepath.Join(dir, "gitm.db") c.ReposDir = filepath.Join(dir, "repos") } func (c *Config) EnsureDirs() error { if err := os.MkdirAll(c.DataDir, 0755); err != nil { return err } return os.MkdirAll(c.ReposDir, 0755) } ``` - [ ] **Step 2: Create models package** Create `internal/models/models.go`: ```go package models import "time" // GiteaServer represents a Gitea server configuration type GiteaServer struct { ID int64 `json:"id" db:"id"` Name string `json:"name" db:"name"` URL string `json:"url" db:"url"` Token string `json:"-" db:"token"` // Never expose token in JSON SyncInterval int `json:"sync_interval" db:"sync_interval"` // 0 = manual only LastSyncAt *time.Time `json:"last_sync_at" db:"last_sync_at"` Status string `json:"status" db:"status"` // active, disabled CreatedAt time.Time `json:"created_at" db:"created_at"` } // Repo represents a discovered repository type Repo struct { ID int64 `json:"id" db:"id"` ServerID int64 `json:"server_id" db:"server_id"` Name string `json:"name" db:"name"` FullName string `json:"full_name" db:"full_name"` CloneURL string `json:"clone_url" db:"clone_url"` LocalPath string `json:"local_path" db:"local_path"` Size int64 `json:"size" db:"size"` LastSyncAt *time.Time `json:"last_sync_at" db:"last_sync_at"` SyncStatus string `json:"sync_status" db:"sync_status"` // syncing, success, failed, pending CreatedAt time.Time `json:"created_at" db:"created_at"` } // SyncLog represents a sync operation log type SyncLog struct { ID int64 `json:"id" db:"id"` ServerID int64 `json:"server_id" db:"server_id"` RepoID *int64 `json:"repo_id" db:"repo_id"` Status string `json:"status" db:"status"` Message string `json:"message" db:"message"` StartedAt time.Time `json:"started_at" db:"started_at"` FinishedAt *time.Time `json:"finished_at" db:"finished_at"` } // Setting represents a key-value configuration type Setting struct { Key string `json:"key" db:"key"` Value string `json:"value" db:"value"` } // SyncStatus represents the current sync state type SyncStatus struct { ServerID int64 `json:"server_id"` Status string `json:"status"` // idle, syncing, failed TaskID string `json:"task_id,omitempty"` StartedAt string `json:"started_at,omitempty"` } ``` - [ ] **Step 3: Commit** ```bash git add internal/config/config.go internal/models/models.go git commit -m "feat: add config and models packages" ``` --- ### Task 3: Implement Database Layer **Files:** - Create: `internal/database/database.go` - [ ] **Step 1: Create database package** Create `internal/database/database.go`: ```go package database import ( "database/sql" "fmt" "path/filepath" _ "github.com/mattn/go-sqlite3" "gitm/internal/config" "gitm/internal/models" ) var DB *sql.DB // Initialize opens the database connection and creates tables func Initialize(dbPath string) error { var err error DB, err = sql.Open("sqlite3", dbPath+"?_foreign_keys=on") if err != nil { return fmt.Errorf("failed to open database: %w", err) } if err = DB.Ping(); err != nil { return fmt.Errorf("failed to ping database: %w", err) } if err = createTables(); err != nil { return fmt.Errorf("failed to create tables: %w", err) } return nil } func createTables() error { queries := []string{ `CREATE TABLE IF NOT EXISTS gitea_servers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL, token TEXT NOT NULL, sync_interval INTEGER DEFAULT 0, last_sync_at DATETIME, status TEXT DEFAULT 'active', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS repos ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL, name TEXT, full_name TEXT, clone_url TEXT, local_path TEXT, size INTEGER DEFAULT 0, last_sync_at DATETIME, sync_status TEXT DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (server_id) REFERENCES gitea_servers(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS sync_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL, repo_id INTEGER, status TEXT NOT NULL, message TEXT, started_at DATETIME DEFAULT CURRENT_TIMESTAMP, finished_at DATETIME, FOREIGN KEY (server_id) REFERENCES gitea_servers(id) ON DELETE CASCADE, FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE SET NULL )`, `CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT )`, } for _, q := range queries { if _, err := DB.Exec(q); err != nil { return fmt.Errorf("failed to create table: %w", err) } } return nil } // Close closes the database connection func Close() error { if DB != nil { return DB.Close() } return nil } // Settings operations func GetSetting(key string) (string, error) { var value string err := DB.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) if err == sql.ErrNoRows { return "", nil } return value, err } func SetSetting(key, value string) error { _, err := DB.Exec(` INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `, key, value) return err } // Server operations func CreateServer(server *models.GiteaServer) error { result, err := DB.Exec(` INSERT INTO gitea_servers (name, url, token, sync_interval, status) VALUES (?, ?, ?, ?, ?) `, server.Name, server.URL, server.Token, server.SyncInterval, server.Status) if err != nil { return err } id, err := result.LastInsertId() if err != nil { return err } server.ID = id return nil } func GetServers() ([]models.GiteaServer, error) { rows, err := DB.Query("SELECT id, name, url, token, sync_interval, last_sync_at, status, created_at FROM gitea_servers ORDER BY id") if err != nil { return nil, err } defer rows.Close() var servers []models.GiteaServer for rows.Next() { var s models.GiteaServer err := rows.Scan(&s.ID, &s.Name, &s.URL, &s.Token, &s.SyncInterval, &s.LastSyncAt, &s.Status, &s.CreatedAt) if err != nil { return nil, err } servers = append(servers, s) } return servers, nil } func GetServer(id int64) (*models.GiteaServer, error) { var s models.GiteaServer err := DB.QueryRow(` SELECT id, name, url, token, sync_interval, last_sync_at, status, created_at FROM gitea_servers WHERE id = ? `, id).Scan(&s.ID, &s.Name, &s.URL, &s.Token, &s.SyncInterval, &s.LastSyncAt, &s.Status, &s.CreatedAt) if err == sql.ErrNoRows { return nil, nil } return &s, err } func UpdateServer(server *models.GiteaServer) error { _, err := DB.Exec(` UPDATE gitea_servers SET name = ?, url = ?, token = ?, sync_interval = ?, status = ? WHERE id = ? `, server.Name, server.URL, server.Token, server.SyncInterval, server.Status, server.ID) return err } func DeleteServer(id int64) error { _, err := DB.Exec("DELETE FROM gitea_servers WHERE id = ?", id) return err } func UpdateServerLastSync(id int64, t time.Time) error { _, err := DB.Exec("UPDATE gitea_servers SET last_sync_at = ? WHERE id = ?", t, id) return err } // Repo operations func CreateOrUpdateRepo(repo *models.Repo) error { result, err := DB.Exec(` INSERT INTO repos (server_id, name, full_name, clone_url, local_path, sync_status) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING `, repo.ServerID, repo.Name, repo.FullName, repo.CloneURL, repo.LocalPath, repo.SyncStatus) if err != nil { return err } id, err := result.LastInsertId() if err != nil { return err } repo.ID = id return nil } func GetReposByServer(serverID int64) ([]models.Repo, error) { rows, err := DB.Query(` SELECT id, server_id, name, full_name, clone_url, local_path, size, last_sync_at, sync_status, created_at FROM repos WHERE server_id = ? ORDER BY full_name `, serverID) if err != nil { return nil, err } defer rows.Close() var repos []models.Repo for rows.Next() { var r models.Repo err := rows.Scan(&r.ID, &r.ServerID, &r.Name, &r.FullName, &r.CloneURL, &r.LocalPath, &r.Size, &r.LastSyncAt, &r.SyncStatus, &r.CreatedAt) if err != nil { return nil, err } repos = append(repos, r) } return repos, nil } func UpdateRepoSyncStatus(id int64, status string) error { _, err := DB.Exec("UPDATE repos SET sync_status = ?, last_sync_at = CURRENT_TIMESTAMP WHERE id = ?", status, id) return err } func GetRepoByFullName(serverID int64, fullName string) (*models.Repo, error) { var r models.Repo err := DB.QueryRow(` SELECT id, server_id, name, full_name, clone_url, local_path, size, last_sync_at, sync_status, created_at FROM repos WHERE server_id = ? AND full_name = ? `, serverID, fullName).Scan(&r.ID, &r.ServerID, &r.Name, &r.FullName, &r.CloneURL, &r.LocalPath, &r.Size, &r.LastSyncAt, &r.SyncStatus, &r.CreatedAt) if err == sql.ErrNoRows { return nil, nil } return &r, err } // Sync log operations func CreateSyncLog(log *models.SyncLog) error { result, err := DB.Exec(` INSERT INTO sync_logs (server_id, repo_id, status, message, started_at) VALUES (?, ?, ?, ?, ?) `, log.ServerID, log.RepoID, log.Status, log.Message, log.StartedAt) if err != nil { return err } id, err := result.LastInsertId() if err != nil { return err } log.ID = id return nil } func UpdateSyncLog(id int64, status, message string, finishedAt *time.Time) error { _, err := DB.Exec(` UPDATE sync_logs SET status = ?, message = ?, finished_at = ? WHERE id = ? `, status, message, finishedAt, id) return err } func GetSyncLogs(serverID int64, limit, offset int) ([]models.SyncLog, error) { query := ` SELECT id, server_id, repo_id, status, message, started_at, finished_at FROM sync_logs ` args := []interface{}{} if serverID > 0 { query += " WHERE server_id = ?" args = append(args, serverID) } query += " ORDER BY started_at DESC LIMIT ? OFFSET ?" args = append(args, limit, offset) rows, err := DB.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var logs []models.SyncLog for rows.Next() { var l models.SyncLog err := rows.Scan(&l.ID, &l.ServerID, &l.RepoID, &l.Status, &l.Message, &l.StartedAt, &l.FinishedAt) if err != nil { return nil, err } logs = append(logs, l) } return logs, nil } ``` - [ ] **Step 2: Add missing import** Edit `internal/database/database.go`, add `"time"` to imports: ```go import ( "database/sql" "fmt" "path/filepath" "time" _ "github.com/mattn/go-sqlite3" "gitm/internal/config" "gitm/internal/models" ) ``` - [ ] **Step 3: Test compilation** ```bash go build -o /tmp/gitm-test . ``` Expected: Success, binary created - [ ] **Step 4: Commit** ```bash git add internal/database/ git commit -m "feat: implement SQLite database layer" ``` --- ### Task 4: Implement JWT Authentication **Files:** - Create: `internal/middleware/auth.go` - Create: `internal/handler/auth.go` - [ ] **Step 1: Create auth middleware** Create `internal/middleware/auth.go`: ```go package middleware import ( "net/http" "strings" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) type Claims struct { UserID string `json:"user_id"` jwt.RegisteredClaims } var jwtSecret = []byte("your-secret-key-change-this") func SetJWTSecret(secret string) { jwtSecret = []byte(secret) } // GenerateToken creates a new JWT token func GenerateToken(userID string) (string, error) { claims := Claims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(jwtSecret) } // ValidateToken parses and validates a JWT token func ValidateToken(tokenString string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { return jwtSecret, nil }) if err != nil { return nil, err } if claims, ok := token.Claims.(*Claims); ok && token.Valid { return claims, nil } return nil, jwt.ErrSignatureInvalid } // AuthMiddleware validates JWT tokens func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) c.Abort() return } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization format"}) c.Abort() return } claims, err := ValidateToken(parts[1]) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) c.Abort() return } c.Set("user_id", claims.UserID) c.Next() } } ``` - [ ] **Step 2: Add missing import** Edit `internal/middleware/auth.go`, add `"time"` to imports: ```go import ( "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) ``` - [ ] **Step 3: Create auth handler** Create `internal/handler/auth.go`: ```go package handler import ( "net/http" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "gitm/internal/database" "gitm/internal/middleware" ) type LoginRequest struct { Password string `json:"password" binding:"required"` } type LoginResponse struct { Token string `json:"token"` } type SettingsResponse struct { AdminPassword string `json:"admin_password,omitempty"` ListenAddr string `json:"listen_addr"` ReposDir string `json:"repos_dir"` MaxConcurrent int `json:"max_concurrent"` } type UpdateSettingsRequest struct { AdminPassword string `json:"admin_password,omitempty"` ListenAddr string `json:"listen_addr,omitempty"` ReposDir string `json:"repos_dir,omitempty"` MaxConcurrent *int `json:"max_concurrent,omitempty"` } // HandleLogin handles password authentication func HandleLogin(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Password required"}) return } // Get stored password hash hashedPassword, err := database.GetSetting("admin_password") if err != nil || hashedPassword == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "Server not initialized"}) return } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"}) return } // Generate token token, err := middleware.GenerateToken("admin") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) return } c.JSON(http.StatusOK, LoginResponse{Token: token}) } // HandleGetSettings returns current settings (with password masked) func HandleGetSettings(c *gin.Context) { listenAddr, _ := database.GetSetting("listen_addr") if listenAddr == "" { listenAddr = ":9000" } reposDir, _ := database.GetSetting("repos_dir") maxConcurrent, _ := database.GetSetting("max_concurrent") maxCon := 3 if maxConcurrent != "" { fmt.Sscanf(maxConcurrent, "%d", &maxCon) } c.JSON(http.StatusOK, SettingsResponse{ AdminPassword: "********", // Masked ListenAddr: listenAddr, ReposDir: reposDir, MaxConcurrent: maxCon, }) } // HandleUpdateSettings updates server settings func HandleUpdateSettings(c *gin.Context) { var req UpdateSettingsRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Update password if provided if req.AdminPassword != "" && req.AdminPassword != "********" { hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return } if err := database.SetSetting("admin_password", string(hash)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save password"}) return } } // Update other settings if req.ListenAddr != "" { database.SetSetting("listen_addr", req.ListenAddr) } if req.ReposDir != "" { database.SetSetting("repos_dir", req.ReposDir) } if req.MaxConcurrent != nil { database.SetSetting("max_concurrent", fmt.Sprintf("%d", *req.MaxConcurrent)) } c.JSON(http.StatusOK, gin.H{"message": "Settings updated"}) } // HandleInit initializes the admin password func HandleInit(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Password required"}) return } // Check if already initialized hashedPassword, _ := database.GetSetting("admin_password") if hashedPassword != "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Already initialized"}) return } // Hash and store password hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) return } if err := database.SetSetting("admin_password", string(hash)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save password"}) return } // Set default values database.SetSetting("listen_addr", ":9000") database.SetSetting("max_concurrent", "3") c.JSON(http.StatusOK, gin.H{"message": "Initialized successfully"}) } ``` - [ ] **Step 4: Add missing imports** Edit `internal/handler/auth.go`, add imports: ```go import ( "fmt" "net/http" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "gitm/internal/database" "gitm/internal/middleware" ) ``` - [ ] **Step 5: Test compilation** ```bash go build -o /tmp/gitm-test . ``` Expected: Success - [ ] **Step 6: Commit** ```bash git add internal/middleware/ internal/handler/ git commit -m "feat: implement JWT authentication middleware and handlers" ``` --- ## Phase 2: Gitea API Integration ### Task 5: Implement Gitea API Client **Files:** - Create: `internal/gitea/client.go` - Create: `internal/gitea/types.go` - [ ] **Step 1: Create Gitea types** Create `internal/gitea/types.go`: ```go package gitea // GiteaUser represents a Gitea user type GiteaUser struct { ID int64 `json:"id"` Login string `json:"login"` FullName string `json:"full_name"` Email string `json:"email"` } // GiteaRepo represents a Gitea repository type GiteaRepo struct { ID int64 `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` CloneURL string `json:"clone_url"` SSHURL string `json:"ssh_url"` Private bool `json:"private"` Size int64 `json:"size"` Updated string `json:"updated_at"` Owner *GiteaUser `json:"owner"` } // GiteaSearchResponse represents the search API response type GiteaSearchResponse struct { OK bool `json:"ok"` Data []GiteaRepo `json:"data"` } ``` - [ ] **Step 2: Create Gitea client** Create `internal/gitea/client.go`: ```go package gitea import ( "encoding/json" "fmt" "io" "net/http" "net/url" "time" ) // Client represents a Gitea API client type Client struct { baseURL *url.URL httpClient *http.Client token string } // NewClient creates a new Gitea API client func NewClient(serverURL, token string) (*Client, error) { u, err := url.Parse(serverURL) if err != nil { return nil, fmt.Errorf("invalid server URL: %w", err) } return &Client{ baseURL: u, httpClient: &http.Client{ Timeout: 30 * time.Second, }, token: token, }, nil } // do performs an HTTP request with authentication func (c *Client) do(path string) (*http.Response, error) { // Build URL with token as query parameter apiURL := c.baseURL.Join(path) apiURL.RawQuery = url.Values{"token": {c.token}}.Encode() resp, err := c.httpClient.Get(apiURL.String()) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } return resp, nil } // ValidateToken checks if the current token is valid func (c *Client) ValidateToken() (*GiteaUser, error) { resp, err := c.do("/api/v1/user") if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("authentication failed: %s - %s", resp.Status, string(body)) } var user GiteaUser if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } return &user, nil } // SearchRepos searches for repositories, paginated func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) { resp, err := c.do(fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, limit)) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("search failed: %s - %s", resp.Status, string(body)) } var searchResp GiteaSearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } return searchResp.Data, nil } // GetAllRepos fetches all repositories by paginating through results func (c *Client) GetAllRepos() ([]GiteaRepo, error) { var allRepos []GiteaRepo page := 1 limit := 50 for { repos, err := c.SearchRepos(page, limit) if err != nil { return nil, err } if len(repos) == 0 { break } allRepos = append(allRepos, repos...) if len(repos) < limit { break } page++ } return allRepos, nil } ``` - [ ] **Step 3: Test compilation** ```bash go build -o /tmp/gitm-test . ``` Expected: Success - [ ] **Step 4: Commit** ```bash git add internal/gitea/ git commit -m "feat: implement Gitea API client" ``` --- ### Task 6: Implement Sync Engine **Files:** - Create: `internal/sync/engine.go` - Create: `internal/sync/scheduler.go` - [ ] **Step 1: Create sync engine** Create `internal/sync/engine.go`: ```go package sync import ( "fmt" "os" "os/exec" "path/filepath" "sync" "time" "gitm/internal/database" "gitm/internal/gitea" ) // Engine handles repository synchronization type Engine struct { maxConcurrent int mu sync.Mutex activeTasks map[int64]string // serverID -> taskID } // NewEngine creates a new sync engine func NewEngine(maxConcurrent int) *Engine { return &Engine{ maxConcurrent: maxConcurrent, activeTasks: make(map[int64]string), } } // SyncServer syncs all repositories from a Gitea server func (e *Engine) SyncServer(serverID int64) error { e.mu.Lock() if _, active := e.activeTasks[serverID]; active { e.mu.Unlock() return fmt.Errorf("sync already in progress for server %d", serverID) } taskID := fmt.Sprintf("%d-%d", serverID, time.Now().Unix()) e.activeTasks[serverID] = taskID e.mu.Unlock() defer func() { e.mu.Lock() delete(e.activeTasks, serverID) e.mu.Unlock() }() // Get server config server, err := database.GetServer(serverID) if err != nil { return fmt.Errorf("failed to get server: %w", err) } // Create sync log log := &database.SyncLog{ ServerID: serverID, Status: "in_progress", Message: "Starting sync", StartedAt: time.Now(), } if err := database.CreateSyncLog(log); err != nil { return fmt.Errorf("failed to create sync log: %w", err) } // Create Gitea client client, err := gitea.NewClient(server.URL, server.Token) if err != nil { e.finishLog(log, "failed", err.Error()) return fmt.Errorf("failed to create client: %w", err) } // Validate token if _, err := client.ValidateToken(); err != nil { e.finishLog(log, "failed", fmt.Sprintf("Authentication failed: %v", err)) return fmt.Errorf("token validation failed: %w", err) } // Get all repos giteaRepos, err := client.GetAllRepos() if err != nil { e.finishLog(log, "failed", fmt.Sprintf("Failed to fetch repos: %v", err)) return fmt.Errorf("failed to get repos: %w", err) } // Get repos directory reposDir, _ := database.GetSetting("repos_dir") if reposDir == "" { reposDir = "./data/repos" } serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name)) if err := os.MkdirAll(serverDir, 0755); err != nil { e.finishLog(log, "failed", fmt.Sprintf("Failed to create directory: %v", err)) return fmt.Errorf("failed to create server directory: %w", err) } // Sync each repo successCount := 0 failedCount := 0 sem := make(chan struct{}, e.maxConcurrent) var wg sync.WaitGroup var resultsMu sync.Mutex for _, gr := range giteaRepos { wg.Add(1) go func(giteaRepo gitea.GiteaRepo) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() // Check if repo exists existingRepo, _ := database.GetRepoByFullName(serverID, giteaRepo.FullName) // Determine local path ownerName := giteaRepo.FullName if giteaRepo.Owner != nil { ownerName = filepath.Join(giteaRepo.Owner.Login, giteaRepo.Name) } localPath := filepath.Join(serverDir, giteaRepo.FullName+".git") // Clone or fetch var err error if existingRepo == nil { err = e.cloneMirror(giteaRepo.CloneURL, localPath) if err == nil { // Create repo record repo := &database.Repo{ ServerID: serverID, Name: giteaRepo.Name, FullName: giteaRepo.FullName, CloneURL: giteaRepo.CloneURL, LocalPath: localPath, SyncStatus: "success", } database.CreateOrUpdateRepo(repo) } } else { err = e.fetchMirror(localPath) if err == nil { database.UpdateRepoSyncStatus(existingRepo.ID, "success") } } resultsMu.Lock() if err != nil { failedCount++ // Create failure log failLog := &database.SyncLog{ ServerID: serverID, RepoID: getRepoID(serverID, giteaRepo.FullName), Status: "failed", Message: err.Error(), StartedAt: time.Now(), } db.CreateSyncLog(failLog) } else { successCount++ } resultsMu.Unlock() }(gr) } wg.Wait() // Update server last sync now := time.Now() database.UpdateServerLastSync(serverID, now) // Finish log message := fmt.Sprintf("Sync completed: %d succeeded, %d failed", successCount, failedCount) e.finishLog(log, "success", message) return nil } // cloneMirror performs a git clone --mirror func (e *Engine) cloneMirror(url, path string) error { if _, err := os.Stat(path); err == nil { // Directory exists, skip return nil } cmd := exec.Command("git", "clone", "--mirror", url, path) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("clone failed: %w - %s", err, string(output)) } return nil } // fetchMirror performs a git fetch --all --prune in a mirror repo func (e *Engine) fetchMirror(path string) error { cmd := exec.Command("git", "--git-dir", path, "fetch", "--all", "--prune") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("fetch failed: %w - %s", err, string(output)) } return nil } // finishLog updates a sync log entry func (e *Engine) finishLog(log *database.SyncLog, status, message string) { now := time.Now() log.Status = status log.Message = message log.FinishedAt = &now database.UpdateSyncLog(log.ID, status, message, log.FinishedAt) } // IsSyncing checks if a server is currently syncing func (e *Engine) IsSyncing(serverID int64) bool { e.mu.Lock() defer e.mu.Unlock() _, active := e.activeTasks[serverID] return active } // helper to get repo ID for logging func getRepoID(serverID int64, fullName string) *int64 { repo, _ := database.GetRepoByFullName(serverID, fullName) if repo != nil { return &repo.ID } return nil } ``` - [ ] **Step 2: Fix imports and issues** Edit `internal/sync/engine.go`: ```go package sync import ( "fmt" "os" "os/exec" "path/filepath" "sync" "time" "gitm/internal/database" db "gitm/internal/database" "gitm/internal/gitea" ) ``` - [ ] **Step 3: Create scheduler** Create `internal/sync/scheduler.go`: ```go package sync import ( "log" "time" "github.com/robfig/cron/v3" "gitm/internal/database" ) // Scheduler manages scheduled sync tasks type Scheduler struct { cron *cron.Cron engine *Engine serverJobs map[int64]cron.EntryID } // NewScheduler creates a new scheduler func NewScheduler(engine *Engine) *Scheduler { return &Scheduler{ cron: cron.New(), engine: engine, serverJobs: make(map[int64]cron.EntryID), } } // Start starts the scheduler func (s *Scheduler) Start() { s.cron.Start() log.Println("Scheduler started") } // Stop stops the scheduler func (s *Scheduler) Stop() { s.cron.Stop() log.Println("Scheduler stopped") } // UpdateServer updates or removes a scheduled job for a server func (s *Scheduler) UpdateServer(server *database.GiteaServer) { // Remove existing job if entryID, exists := s.serverJobs[server.ID]; exists { s.cron.Remove(entryID) delete(s.serverJobs, server.ID) } // Skip if manual sync only or disabled if server.SyncInterval <= 0 || server.Status != "active" { return } // Add new job - run every N minutes schedule := fmt.Sprintf("*/%d * * * *", server.SyncInterval) entryID, err := s.cron.AddFunc(schedule, func() { log.Printf("Scheduled sync triggered for server %d (%s)", server.ID, server.Name) if err := s.engine.SyncServer(server.ID); err != nil { log.Printf("Scheduled sync failed for server %d: %v", server.ID, err) } }) if err != nil { log.Printf("Failed to schedule job for server %d: %v", server.ID, err) return } s.serverJobs[server.ID] = entryID log.Printf("Scheduled sync every %d minutes for server %d (%s)", server.SyncInterval, server.ID, server.Name) } // RemoveServer removes a scheduled job for a server func (s *Scheduler) RemoveServer(serverID int64) { if entryID, exists := s.serverJobs[serverID]; exists { s.cron.Remove(entryID) delete(s.serverJobs, serverID) log.Printf("Removed scheduled job for server %d", serverID) } } // ReloadAll reloads all server schedules from database func (s *Scheduler) ReloadAll() error { servers, err := database.GetServers() if err != nil { return err } for _, server := range servers { s.UpdateServer(&server) } return nil } ``` - [ ] **Step 4: Add missing import to scheduler** Edit `internal/sync/scheduler.go`: ```go import ( "fmt" "log" "time" "github.com/robfig/cron/v3" "gitm/internal/database" ) ``` - [ ] **Step 5: Test compilation** ```bash go build -o /tmp/gitm-test . ``` Expected: Success - [ ] **Step 6: Commit** ```bash git add internal/sync/ git commit -m "feat: implement sync engine and scheduler" ``` --- ## Phase 3: HTTP Handlers ### Task 7: Implement Server Management Handlers **Files:** - Create: `internal/handler/server.go` - [ ] **Step 1: Create server handler** Create `internal/handler/server.go`: ```go package handler import ( "net/http" "strconv" "github.com/gin-gonic/gin" "gitm/internal/database" "gitm/internal/gitea" "gitm/internal/sync" ) // HandleListServers returns all configured Gitea servers func HandleListServers(c *gin.Context) { servers, err := database.GetServers() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, servers) } // CreateServerRequest defines the request to create a server type CreateServerRequest struct { Name string `json:"name" binding:"required"` URL string `json:"url" binding:"required"` Token string `json:"token" binding:"required"` SyncInterval int `json:"sync_interval"` } // HandleCreateServer adds a new Gitea server func HandleCreateServer(c *gin.Context) { var req CreateServerRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } server := &database.GiteaServer{ Name: req.Name, URL: req.URL, Token: req.Token, SyncInterval: req.SyncInterval, Status: "active", } if err := database.CreateServer(server); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, server) } // UpdateServerRequest defines the request to update a server type UpdateServerRequest struct { Name *string `json:"name"` URL *string `json:"url"` Token *string `json:"token"` SyncInterval *int `json:"sync_interval"` Status *string `json:"status"` } // HandleUpdateServer updates a Gitea server configuration func HandleUpdateServer(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) return } server, err := database.GetServer(id) if err != nil || server == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"}) return } var req UpdateServerRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Name != nil { server.Name = *req.Name } if req.URL != nil { server.URL = *req.URL } if req.Token != nil { server.Token = *req.Token } if req.SyncInterval != nil { server.SyncInterval = *req.SyncInterval } if req.Status != nil { server.Status = *req.Status } if err := database.UpdateServer(server); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, server) } // HandleDeleteServer removes a Gitea server func HandleDeleteServer(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) return } if err := database.DeleteServer(id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Server deleted"}) } // TestConnectionRequest defines the request to test a server connection type TestConnectionRequest struct { URL string `json:"url" binding:"required"` Token string `json:"token" binding:"required"` } // HandleTestConnection tests connection to a Gitea server func HandleTestConnection(c *gin.Context) { var req TestConnectionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } client, err := gitea.NewClient(req.URL, req.Token) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } user, err := client.ValidateToken() if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) return } c.JSON(http.StatusOK, gin.H{ "message": "Connection successful", "user": user.Login, }) } // HandleListRepos returns repositories for a server func HandleListRepos(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) return } repos, err := database.GetReposByServer(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, repos) } // HandleDiscoverRepos triggers repo discovery from Gitea API func HandleDiscoverRepos(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) return } server, err := database.GetServer(id) if err != nil || server == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"}) return } client, err := gitea.NewClient(server.URL, server.Token) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } giteaRepos, err := client.GetAllRepos() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Get repos directory reposDir, _ := database.GetSetting("repos_dir") if reposDir == "" { reposDir = "./data/repos" } serverDir := reposDir + "/" + strconv.FormatInt(id, 10) + "_" + server.Name discovered := 0 for _, gr := range giteaRepos { // Check if already exists existing, _ := database.GetRepoByFullName(id, gr.FullName) if existing == nil { repo := &database.Repo{ ServerID: id, Name: gr.Name, FullName: gr.FullName, CloneURL: gr.CloneURL, LocalPath: serverDir + "/" + gr.FullName + ".git", SyncStatus: "pending", } database.CreateOrUpdateRepo(repo) discovered++ } } c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("Discovered %d new repositories", discovered), "total_repos": len(giteaRepos), "new_repos": discovered, }) } // HandleSyncServer triggers a sync for a specific server func HandleSyncServer(c *gin.Context, engine *sync.Engine) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) return } if engine.IsSyncing(id) { c.JSON(http.StatusConflict, gin.H{"error": "Sync already in progress"}) return } go engine.SyncServer(id) c.JSON(http.StatusAccepted, gin.H{ "message": "Sync started", "server_id": id, }) } } // HandleSyncAll triggers sync for all active servers func HandleSyncAll(c *gin.Context, engine *sync.Engine) gin.HandlerFunc { return func(c *gin.Context) { servers, err := database.GetServers() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } started := 0 for _, server := range servers { if server.Status == "active" && !engine.IsSyncing(server.ID) { go engine.SyncServer(server.ID) started++ } } c.JSON(http.StatusAccepted, gin.H{ "message": fmt.Sprintf("Started sync for %d servers", started), }) } } // HandleGetSyncStatus returns sync status for a server func HandleGetSyncStatus(c *gin.Context, engine *sync.Engine) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) return } if engine.IsSyncing(id) { c.JSON(http.StatusOK, gin.H{ "server_id": id, "status": "syncing", }) } else { c.JSON(http.StatusOK, gin.H{ "server_id": id, "status": "idle", }) } } } ``` - [ ] **Step 2: Add missing import** Edit `internal/handler/server.go`, add `"fmt"` to imports: ```go import ( "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" "gitm/internal/database" "gitm/internal/gitea" "gitm/internal/sync" ) ``` - [ ] **Step 3: Test compilation** ```bash go build -o /tmp/gitm-test . ``` Expected: Success - [ ] **Step 4: Commit** ```bash git add internal/handler/server.go git commit -m "feat: implement server management handlers" ``` --- ### Task 8: Implement Log and Stats Handlers **Files:** - Create: `internal/handler/log.go` - [ ] **Step 1: Create log handler** Create `internal/handler/log.go`: ```go package handler import ( "net/http" "strconv" "github.com/gin-gonic/gin" "gitm/internal/database" ) // HandleGetSyncLogs returns sync logs with pagination func HandleGetSyncLogs(c *gin.Context) { serverIDStr := c.Query("server_id") pageStr := c.DefaultQuery("page", "1") limitStr := c.DefaultQuery("limit", "50") page, _ := strconv.Atoi(pageStr) limit, _ := strconv.Atoi(limitStr) if limit > 100 { limit = 100 } var serverID int64 = 0 if serverIDStr != "" { serverID, _ = strconv.ParseInt(serverIDStr, 10, 64) } offset := (page - 1) * limit logs, err := database.GetSyncLogs(serverID, limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "data": logs, "page": page, "limit": limit, "count": len(logs), }) } // HandleGetStats returns dashboard statistics func HandleGetStats(c *gin.Context) { servers, err := database.GetServers() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } serverCount := len(servers) repoCount := 0 totalSize := int64(0) activeServers := 0 for _, server := range servers { if server.Status == "active" { activeServers++ } repos, err := database.GetReposByServer(server.ID) if err == nil { repoCount += len(repos) for _, repo := range repos { totalSize += repo.Size } } } c.JSON(http.StatusOK, gin.H{ "server_count": serverCount, "active_servers": activeServers, "repo_count": repoCount, "total_size": totalSize, }) } ``` - [ ] **Step 2: Test compilation** ```bash go build -o /tmp/gitm-test . ``` Expected: Success - [ ] **Step 3: Commit** ```bash git add internal/handler/log.go git commit -m "feat: implement sync log and stats handlers" ``` --- ### Task 9: Wire Up Main Server **Files:** - Modify: `main.go` - [ ] **Step 1: Update main.go with full server implementation** Replace `main.go` with: ```go package main import ( "flag" "fmt" "log" "os" "github.com/gin-gonic/gin" "gitm/internal/config" "gitm/internal/database" "gitm/internal/handler" "gitm/internal/middleware" "gitm/internal/sync" ) var ( flagAddr = flag.String("addr", "", "Listen address") flagDataDir = flag.String("data", "", "Data directory") flagInit = flag.Bool("init", false, "Initialize database and set password") ) func main() { flag.Parse() cfg := config.Get() if *flagDataDir != "" { cfg.SetDataDir(*flagDataDir) } if *flagAddr != "" { cfg.ListenAddr = *flagAddr } // Ensure directories exist if err := cfg.EnsureDirs(); err != nil { log.Fatalf("Failed to create directories: %v", err) } // Initialize database if err := database.Initialize(cfg.DBPath); err != nil { log.Fatalf("Failed to initialize database: %v", err) } defer database.Close() // Load settings to config if listenAddr, err := database.GetSetting("listen_addr"); err == nil && listenAddr != "" { cfg.ListenAddr = listenAddr } // Get max concurrent setting maxConcurrent := 3 if maxStr, err := database.GetSetting("max_concurrent"); err == nil && maxStr != "" { fmt.Sscanf(maxStr, "%d", &maxConcurrent) } // Initialize components engine := sync.NewEngine(maxConcurrent) scheduler := sync.NewScheduler(engine) // Init mode if *flagInit { runInitMode() return } // Check if initialized if _, err := database.GetSetting("admin_password"); err != nil { fmt.Println("Not initialized. Please run with --init flag first.") return } // Set JWT secret from a fixed key (in production, derive from password) middleware.SetJWTSecret("gitm-default-secret-change-in-production") // Reload scheduler with existing servers scheduler.ReloadAll() scheduler.Start() defer scheduler.Stop() // Start server log.Printf("GitM starting on %s", cfg.ListenAddr) log.Fatal(runServer(cfg, engine, scheduler)) } func runInitMode() { var password string fmt.Print("Enter admin password: ") fmt.Scanln(&password) if password == "" { log.Fatal("Password cannot be empty") } // Hash password manually for init hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { log.Fatalf("Failed to hash password: %v", err) } if err := database.SetSetting("admin_password", string(hash)); err != nil { log.Fatalf("Failed to save password: %v", err) } // Set defaults database.SetSetting("listen_addr", ":9000") database.SetSetting("max_concurrent", "3") fmt.Println("Initialized successfully!") } func runServer(cfg *config.Config, engine *sync.Engine, scheduler *sync.Scheduler) error { gin.SetMode(gin.ReleaseMode) r := gin.Default() // API routes api := r.Group("/api") { // Public routes api.POST("/login", handler.HandleLogin) api.POST("/init", handler.HandleInit) // Protected routes protected := api.Group("") protected.Use(middleware.AuthMiddleware()) { // Settings protected.GET("/settings", handler.HandleGetSettings) protected.PUT("/settings", handler.HandleUpdateSettings) // Servers protected.GET("/servers", handler.HandleListServers) protected.POST("/servers", handler.HandleCreateServer) protected.PUT("/servers/:id", handler.HandleUpdateServer) protected.DELETE("/servers/:id", handler.HandleDeleteServer) protected.POST("/servers/:id/test", handler.HandleTestConnection) protected.GET("/servers/:id/repos", handler.HandleListRepos) protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos) protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine)) protected.GET("/servers/:id/sync/status", handler.HandleGetSyncStatus(engine)) // Sync protected.POST("/sync/all", handler.HandleSyncAll(engine)) // Logs and stats protected.GET("/sync/logs", handler.HandleGetSyncLogs) protected.GET("/sync/stats", handler.HandleGetStats) } } // Serve static files (frontend) - will be added when frontend is built // r.Static("/", "./web/dist") return r.Run(cfg.ListenAddr) } ``` - [ ] **Step 2: Add missing imports** Edit `main.go`, add `"golang.org/x/crypto/bcrypt"` to imports: ```go import ( "flag" "fmt" "log" "os" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "gitm/internal/config" "gitm/internal/database" "gitm/internal/handler" "gitm/internal/middleware" "gitm/internal/sync" ) ``` - [ ] **Step 3: Test compilation** ```bash go build -o /tmp/gitm-test . ``` Expected: Success - [ ] **Step 4: Test basic run** ```bash /tmp/gitm-test --init ``` Expected: Prompts for password (type one, then Enter) - [ ] **Step 5: Commit** ```bash git add main.go git commit -m "feat: wire up main server with all routes" ``` --- ## Phase 4: Frontend Implementation ### Task 10: Initialize Vue 3 Frontend **Files:** - Create: `web/package.json` - Create: `web/vite.config.ts` - Create: `web/tsconfig.json` - Create: `web/index.html` - Create: `web/src/main.ts` - Create: `web/src/App.vue` - [ ] **Step 1: Create web/package.json** Create `web/package.json`: ```json { "name": "gitm-web", "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", "build": "vue-tsc && vite build", "preview": "vite preview" }, "dependencies": { "vue": "^3.4.0", "vue-router": "^4.2.5", "pinia": "^2.1.7", "element-plus": "^2.5.0", "@element-plus/icons-vue": "^2.3.1", "axios": "^1.6.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", "typescript": "^5.3.0", "vue-tsc": "^1.8.27", "vite": "^5.0.0" } } ``` - [ ] **Step 2: Create web/vite.config.ts** Create `web/vite.config.ts`: ```typescript import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': resolve(__dirname, 'src') } }, server: { port: 5173, proxy: { '/api': { target: 'http://localhost:9000', changeOrigin: true } } }, build: { outDir: 'dist', assetsDir: 'assets' } }) ``` - [ ] **Step 3: Create web/tsconfig.json** Create `web/tsconfig.json`: ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "preserve", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }] } ``` - [ ] **Step 4: Create web/tsconfig.node.json** Create `web/tsconfig.node.json`: ```json { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ``` - [ ] **Step 5: Create web/index.html** Create `web/index.html`: ```html
Gitea Repository Sync Tool