Fix .gitignore: track cmd/ directories, add mdsync + server source
This commit is contained in:
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
data/
|
data/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
server
|
/server
|
||||||
mdsync
|
/mdsync
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user