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 }