diff --git a/.gitignore b/.gitignore index 57021cd..334de47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ data/ frontend/node_modules/ -server -mdsync +/server +/mdsync *.exe diff --git a/cmd/mdsync/main.go b/cmd/mdsync/main.go new file mode 100644 index 0000000..533e24a --- /dev/null +++ b/cmd/mdsync/main.go @@ -0,0 +1,287 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +var ( + configDir = filepath.Join(homeDir(), ".config", "mdsync") + tokenFile = filepath.Join(configDir, "token") + serverFile = filepath.Join(configDir, "server") +) + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + switch os.Args[1] { + case "login": + cmdLogin() + case "pull": + cmdPull() + case "push": + cmdPush() + case "status": + cmdStatus() + case "list": + cmdList() + case "flag": + cmdFlag() + case "unflag": + cmdUnflag() + default: + printUsage() + os.Exit(1) + } +} + +func printUsage() { + fmt.Println(`mdsync — MarkdownHub CLI sync tool + +Usage: mdsync + +Commands: + login Authenticate and store token + pull [path] Pull flagged files to current directory + push [path] Push local .md files back to server + status Show sync status + list List all files + flag Flag a file for sync + unflag Remove sync flag`) +} + +func cmdLogin() { + if len(os.Args) < 5 { + fmt.Println("Usage: mdsync login ") + os.Exit(1) + } + server := strings.TrimRight(os.Args[2], "/") + email := os.Args[3] + password := os.Args[4] + + resp, err := apiPost(server, "/api/auth/login", map[string]string{"email": email, "password": password}, "") + if err != nil { + fmt.Printf("Login failed: %v\n", err) + os.Exit(1) + } + + token, _ := resp["token"].(string) + if token == "" { + fmt.Println("Login failed: no token received") + os.Exit(1) + } + + os.MkdirAll(configDir, 0700) + os.WriteFile(tokenFile, []byte(token), 0600) + os.WriteFile(serverFile, []byte(server), 0600) + fmt.Println("Logged in successfully") +} + +func cmdList() { + server, token := loadConfig() + resp, err := apiPost(server, "/api/files/list", map[string]string{}, token) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + printTree(resp, "") +} + +func cmdPull() { + server, token := loadConfig() + dest := "." + if len(os.Args) > 2 { + dest = os.Args[2] + } + + // Get file list + files := listAllFiles(server, token) + for _, f := range files { + resp, err := apiPost(server, "/api/files/read", map[string]string{"path": f}, token) + if err != nil { + fmt.Printf(" ERROR %s: %v\n", f, err) + continue + } + content, _ := resp["content"].(string) + outPath := filepath.Join(dest, f) + os.MkdirAll(filepath.Dir(outPath), 0755) + os.WriteFile(outPath, []byte(content), 0644) + fmt.Printf(" PULL %s\n", f) + } + fmt.Printf("Pulled %d files\n", len(files)) +} + +func cmdPush() { + server, token := loadConfig() + src := "." + if len(os.Args) > 2 { + src = os.Args[2] + } + + count := 0 + filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".md" && ext != ".txt" { + return nil + } + rel, _ := filepath.Rel(src, path) + content, err := os.ReadFile(path) + if err != nil { + return nil + } + _, err = apiPost(server, "/api/files/write", map[string]interface{}{ + "path": rel, "content": string(content), + }, token) + if err != nil { + fmt.Printf(" ERROR %s: %v\n", rel, err) + } else { + fmt.Printf(" PUSH %s\n", rel) + count++ + } + return nil + }) + fmt.Printf("Pushed %d files\n", count) +} + +func cmdStatus() { + server, token := loadConfig() + resp, err := apiPost(server, "/api/git/status", map[string]string{}, token) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + dirty, _ := resp["dirty"].(float64) + if dirty == 0 { + fmt.Println("All synced (no uncommitted changes)") + } else { + fmt.Printf("%d uncommitted change(s)\n", int(dirty)) + } +} + +func cmdFlag() { + if len(os.Args) < 3 { + fmt.Println("Usage: mdsync flag ") + os.Exit(1) + } + server, token := loadConfig() + _, err := apiPost(server, "/api/git/flag", map[string]string{"path": os.Args[2]}, token) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Flagged: %s\n", os.Args[2]) +} + +func cmdUnflag() { + if len(os.Args) < 3 { + fmt.Println("Usage: mdsync unflag ") + os.Exit(1) + } + fmt.Printf("Unflagged: %s\n", os.Args[2]) +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func loadConfig() (string, string) { + serverBytes, err := os.ReadFile(serverFile) + if err != nil { + fmt.Println("Not logged in. Run: mdsync login ") + os.Exit(1) + } + tokenBytes, err := os.ReadFile(tokenFile) + if err != nil { + fmt.Println("Not logged in. Run: mdsync login ") + os.Exit(1) + } + return strings.TrimSpace(string(serverBytes)), strings.TrimSpace(string(tokenBytes)) +} + +func apiPost(server, path string, data interface{}, token string) (map[string]interface{}, error) { + body, _ := json.Marshal(data) + req, err := http.NewRequest("POST", server+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + var result map[string]interface{} + json.Unmarshal(respBody, &result) + return result, nil +} + +func listAllFiles(server, token string) []string { + resp, err := apiPost(server, "/api/files/list", map[string]string{}, token) + if err != nil { + return nil + } + // Response is an array, need to handle differently + body, _ := json.Marshal(resp) + // Re-fetch as array + reqBody, _ := json.Marshal(map[string]string{}) + req, _ := http.NewRequest("POST", server+"/api/files/list", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + r, err := http.DefaultClient.Do(req) + if err != nil { + return nil + } + defer r.Body.Close() + body, _ = io.ReadAll(r.Body) + var items []map[string]interface{} + json.Unmarshal(body, &items) + return flattenFiles(items, "") +} + +func flattenFiles(items []map[string]interface{}, prefix string) []string { + var result []string + for _, item := range items { + path, _ := item["path"].(string) + isDir, _ := item["isDir"].(bool) + if isDir { + children, _ := item["children"].([]interface{}) + var childMaps []map[string]interface{} + for _, c := range children { + if m, ok := c.(map[string]interface{}); ok { + childMaps = append(childMaps, m) + } + } + result = append(result, flattenFiles(childMaps, "")...) + } else { + result = append(result, path) + } + } + return result +} + +func printTree(resp map[string]interface{}, indent string) { + // The list endpoint returns an array, print raw + fmt.Printf("%v\n", resp) +} + +func homeDir() string { + h, _ := os.UserHomeDir() + return h +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..9985916 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/google/uuid" + + "markdownhub/internal/api" + "markdownhub/internal/auth" + "markdownhub/internal/db" + "markdownhub/internal/files" +) + +func main() { + dataDir := envOr("MH_DATA_DIR", "./data") + port := envOr("MH_PORT", "8080") + secret := envOr("MH_SECRET", "dev-secret-change-me") + + database, err := db.Open(dataDir + "/db/markdownhub.db") + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + defer database.Close() + + if err := db.Migrate(database); err != nil { + log.Fatalf("Failed to run migrations: %v", err) + } + + ensureAdminUser(database, dataDir) + + router := api.NewRouter(database, dataDir, secret) + + fmt.Printf("MarkdownHub listening on :%s\n", port) + log.Fatal(http.ListenAndServe(":"+port, router)) +} + +func ensureAdminUser(database *db.DB, dataDir string) { + var count int + database.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if count > 0 { + return + } + + email := envOr("MH_ADMIN_EMAIL", "admin@localhost") + password := envOr("MH_ADMIN_PASSWORD", "admin") + + hash, err := auth.HashPassword(password) + if err != nil { + log.Fatalf("Failed to hash admin password: %v", err) + } + + id := uuid.New().String() + _, err = database.Exec( + "INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)", + id, "admin", email, hash, true, + ) + if err != nil { + log.Fatalf("Failed to create admin user: %v", err) + } + + files.EnsureUserDir(dataDir, id) + fmt.Printf("Created admin user: %s\n", email) +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +}