Fix .gitignore: track cmd/ directories, add mdsync + server source

This commit is contained in:
2026-05-22 23:26:08 +02:00
parent 62ab0fb796
commit 35bf1164ee
3 changed files with 362 additions and 2 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
data/ data/
frontend/node_modules/ frontend/node_modules/
server /server
mdsync /mdsync
*.exe *.exe
+287
View File
@@ -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 <command>
Commands:
login <server-url> <email> <password> 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 <path> Flag a file for sync
unflag <path> Remove sync flag`)
}
func cmdLogin() {
if len(os.Args) < 5 {
fmt.Println("Usage: mdsync login <server-url> <email> <password>")
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 <path>")
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 <path>")
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 <server> <email> <password>")
os.Exit(1)
}
tokenBytes, err := os.ReadFile(tokenFile)
if err != nil {
fmt.Println("Not logged in. Run: mdsync login <server> <email> <password>")
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
}
+73
View File
@@ -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
}