From 4c76470ecac29bf3ee5462f2191fb3885bcf84ac Mon Sep 17 00:00:00 2001 From: Wraient Date: Fri, 1 Nov 2024 00:58:59 +0530 Subject: [PATCH] first commit --- cmd/myd/main.go | 632 +++++++++++++++++++++++++++++++++++++++ go.mod | 33 ++ go.sum | 66 ++++ internal/config.go | 226 ++++++++++++++ internal/delete_model.go | 148 +++++++++ internal/myd.go | 52 ++++ internal/structs.go | 6 + 7 files changed, 1163 insertions(+) create mode 100644 cmd/myd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config.go create mode 100644 internal/delete_model.go create mode 100644 internal/myd.go create mode 100644 internal/structs.go diff --git a/cmd/myd/main.go b/cmd/myd/main.go new file mode 100644 index 0000000..54249ad --- /dev/null +++ b/cmd/myd/main.go @@ -0,0 +1,632 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "context" + "strings" + "time" + "os/exec" + + tea "github.com/charmbracelet/bubbletea" + "github.com/google/go-github/v60/github" + "golang.org/x/oauth2" + "github.com/wraient/myd/internal" +) + +func main() { + // Load config from default location + config, err := internal.LoadConfig("$HOME/.config/myd/config") + if err != nil { + internal.Exit("Failed to load config", err) + } + internal.SetGlobalConfig(&config) + + if len(os.Args) < 2 { + printUsage() + return + } + + // Create user struct + user := &internal.User{} + + switch os.Args[1] { + case "init": + internal.ChangeToken(&config, user) + case "add": + if len(os.Args) < 3 { + internal.Exit("Error: Path required for add command", nil) + } + handleAdd(os.Args[2], &config) + case "upload": + handleUpload(&config, user) + case "ignore": + if len(os.Args) < 3 { + internal.Exit("Error: Path required for ignore command", nil) + } + handleIgnore(os.Args[2], &config) + case "list": + handleList(&config) + case "delete": + handleDelete(&config) + case "install": + if len(os.Args) < 3 { + internal.Exit("Error: GitHub repository URL required", nil) + } + handleInstall(os.Args[2], &config) + case "-e": + editConfig(&config) + default: + printUsage() + } +} + +func editConfig(config *internal.MydConfig) { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vim" // fallback to vim if EDITOR is not set + } + + configPath := os.ExpandEnv("$HOME/.config/myd/config") + cmd := exec.Command(editor, configPath) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + internal.Exit("Failed to edit config", err) + } +} + +func printUsage() { + fmt.Println("Usage:") + fmt.Println(" myd init - Initialize with GitHub token") + fmt.Println(" myd add - Add path to upload list") + fmt.Println(" myd upload - Upload files to GitHub") + fmt.Println(" myd ignore - Add path to .gitignore") + fmt.Println(" myd list - List tracked paths") + fmt.Println(" myd delete - Delete paths from tracking") + fmt.Println(" myd install - Install dotfiles from a GitHub repository") + fmt.Println(" myd -e - Edit config file") +} + +func handleAdd(path string, config *internal.MydConfig) { + absPath, err := filepath.Abs(path) + if err != nil { + internal.Exit("Error getting absolute path", err) + } + + // Check if path exists + if _, err := os.Stat(absPath); os.IsNotExist(err) { + internal.Exit("Error: Path does not exist", nil) + } + + // Create storage directory if it doesn't exist + uploadDir := filepath.Join(os.ExpandEnv(config.StoragePath), config.UpstreamName) + if err := os.MkdirAll(uploadDir, 0755); err != nil { + internal.Exit("Error creating upload directory", err) + } + + // Read existing paths + uploadListPath := filepath.Join(os.ExpandEnv(config.StoragePath), "toupload.txt") + existingPaths := make(map[string]bool) + + if data, err := os.ReadFile(uploadListPath); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if line = strings.TrimSpace(line); line != "" { + existingPaths[line] = true + } + } + } + + // Check if path already exists + if existingPaths[absPath] { + fmt.Printf("Path %s is already in upload list\n", absPath) + return + } + + // Add new path to toupload.txt + f, err := os.OpenFile(uploadListPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + internal.Exit("Error opening toupload.txt", err) + } + defer f.Close() + + // Write the absolute path to toupload.txt + if _, err := f.WriteString(absPath + "\n"); err != nil { + internal.Exit("Error writing to toupload.txt", err) + } + + fmt.Printf("Added %s to upload list\n", absPath) +} + +func copyFile(src, dst string) error { + input, err := os.ReadFile(src) + if err != nil { + return err + } + + info, err := os.Stat(src) + if err != nil { + return err + } + + return os.WriteFile(dst, input, info.Mode()) +} + +func copyDir(src, dst string, skipOriginalPath bool) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip .original_path files only during install + if skipOriginalPath && info.Name() == ".original_path" { + return nil + } + + // Get relative path from source directory + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + destPath := filepath.Join(dst, rel) + + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + + return copyFile(path, destPath) + }) +} + +func copyFilesToRepo(config *internal.MydConfig, repoPath string) error { + // Read toupload.txt + uploadListPath := filepath.Join(os.ExpandEnv(config.StoragePath), "toupload.txt") + paths, err := os.ReadFile(uploadListPath) + if err != nil { + return fmt.Errorf("failed to read toupload.txt: %v", err) + } + + // Process each path + for _, path := range strings.Split(string(paths), "\n") { + path = strings.TrimSpace(path) + if path == "" { + continue + } + + fmt.Printf("Processing path: %s\n", path) + + // Get file info + info, err := os.Stat(path) + if err != nil { + fmt.Printf("Warning: Skipping %s: %v\n", path, err) + continue + } + + // Replace $HOME and username with environment variables + home := os.Getenv("HOME") + username := os.Getenv("USER") + originalPath := path + if home != "" { + originalPath = strings.ReplaceAll(originalPath, home, "$HOME") + } + if username != "" { + originalPath = strings.ReplaceAll(originalPath, username, "$USER") + } + + // Get destination path + destPath := filepath.Join(repoPath, filepath.Base(path)) + fmt.Printf("Copying to: %s\n", destPath) + + if info.IsDir() { + // For directories, copy the entire directory + if err := copyDir(path, destPath, false); err != nil { + return fmt.Errorf("failed to copy directory %s: %v", path, err) + } + + // Create .original_path inside the copied directory + originalPathFile := filepath.Join(destPath, ".original_path") + if err := os.WriteFile(originalPathFile, []byte(originalPath), 0644); err != nil { + return fmt.Errorf("failed to create .original_path in directory %s: %v", path, err) + } + } else { + // For files, copy to destination + if err := copyFile(path, destPath); err != nil { + return fmt.Errorf("failed to copy file %s: %v", path, err) + } + + // Update root .original_path file for single files + originalPathFile := filepath.Join(repoPath, ".original_path") + f, err := os.OpenFile(originalPathFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open .original_path: %v", err) + } + defer f.Close() + + if _, err := f.WriteString(originalPath + "\n"); err != nil { + return fmt.Errorf("failed to write to .original_path: %v", err) + } + } + + fmt.Printf("Successfully processed: %s\n", path) + } + + return nil +} + +func handleUpload(config *internal.MydConfig, user *internal.User) { + // Setup GitHub client + tokenPath := filepath.Join(os.ExpandEnv(config.StoragePath), "token") + tokenBytes, err := os.ReadFile(tokenPath) + if err != nil { + internal.Exit("Failed to read GitHub token. Run 'myd init' first", err) + } + user.Token = string(tokenBytes) + + ctx := context.Background() + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: user.Token}) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + // Get authenticated user + authenticatedUser, _, err := client.Users.Get(ctx, "") + if err != nil { + internal.Exit("Failed to get authenticated user", err) + } + user.Username = *authenticatedUser.Login + + repoPath := filepath.Join(os.ExpandEnv(config.StoragePath), config.UpstreamName) + fmt.Printf("Repository path: %s\n", repoPath) + + // Check if repository exists on GitHub + _, resp, err := client.Repositories.Get(ctx, user.Username, config.UpstreamName) + repoExists := err == nil || resp.StatusCode != 404 + fmt.Printf("Repository exists: %v\n", repoExists) + + // Initialize local repository + if _, err := os.Stat(repoPath); err == nil { + fmt.Println("Using existing repository directory") + } else { + if repoExists { + fmt.Println("Cloning existing repository") + cmd := exec.Command("git", "clone", fmt.Sprintf("https://%s@github.com/%s/%s.git", user.Token, user.Username, config.UpstreamName), repoPath) + output, err := cmd.CombinedOutput() + if err != nil { + internal.Exit(string(output), err) + } + } else { + fmt.Println("Initializing new repository") + if err := os.MkdirAll(repoPath, 0755); err != nil { + internal.Exit("Failed to create repository directory", err) + } + cmd := exec.Command("git", "init") + cmd.Dir = repoPath + output, err := cmd.CombinedOutput() + if err != nil { + internal.Exit(string(output), err) + } + } + } + + // Remove everything except .git from the repo + entries, err := os.ReadDir(repoPath) + if err != nil { + internal.Exit("Failed to read repository directory", err) + } + for _, entry := range entries { + if entry.Name() != ".git" { + path := filepath.Join(repoPath, entry.Name()) + if err := os.RemoveAll(path); err != nil { + internal.Exit("Failed to clean repository", err) + } + } + } + + // Copy files using the new function + fmt.Println("Copying files to repository") + if err := copyFilesToRepo(config, repoPath); err != nil { + internal.Exit("Failed to copy files", err) + } + + fmt.Println("Staging files") + cmd := exec.Command("git", "add", ".") + cmd.Dir = repoPath + output, err := cmd.CombinedOutput() + if err != nil { + internal.Exit(string(output), err) + } + + // Check git status to see if there are changes + cmd = exec.Command("git", "status", "--porcelain") + cmd.Dir = repoPath + output, err = cmd.CombinedOutput() + if err != nil { + internal.Exit(fmt.Sprintf("Failed to get git status: %s", string(output)), err) + } + + // If there are no changes, exit early + if len(output) == 0 { + fmt.Println("Everything up-to-date") + return + } + + fmt.Println("Committing changes") + timeStr := time.Now().Format("2006-01-02 15:04:05") + + // Configure git before committing + configCmd := exec.Command("git", "config", "--local", "user.name", "myd") + configCmd.Dir = repoPath + if output, err := configCmd.CombinedOutput(); err != nil { + internal.Exit(fmt.Sprintf("Failed to configure git user name: %s", string(output)), err) + } + + configCmd = exec.Command("git", "config", "--local", "user.email", "myd@local") + configCmd.Dir = repoPath + if output, err := configCmd.CombinedOutput(); err != nil { + internal.Exit(fmt.Sprintf("Failed to configure git user email: %s", string(output)), err) + } + + cmd = exec.Command("git", "commit", "-m", fmt.Sprintf("automatic update %s", timeStr)) + cmd.Dir = repoPath + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=myd", + "GIT_AUTHOR_EMAIL=myd@local", + "GIT_COMMITTER_NAME=myd", + "GIT_COMMITTER_EMAIL=myd@local", + ) + output, err = cmd.CombinedOutput() + if err != nil { + internal.Exit(fmt.Sprintf("Failed to commit: %s", string(output)), err) + } + + if !repoExists { + fmt.Println("Creating new repository on GitHub") + repo := &github.Repository{ + Name: github.String(config.UpstreamName), + Private: github.Bool(true), + } + _, _, err = client.Repositories.Create(ctx, "", repo) + if err != nil { + internal.Exit("Failed to create GitHub repository", err) + } + + fmt.Println("Adding remote") + cmd = exec.Command("git", "remote", "add", "origin", fmt.Sprintf("https://%s@github.com/%s/%s.git", user.Token, user.Username, config.UpstreamName)) + cmd.Dir = repoPath + output, err = cmd.CombinedOutput() + if err != nil { + internal.Exit(fmt.Sprintf("Failed to add remote: %s", string(output)), err) + } + + // Add these new commands to ensure main branch is set up correctly + cmd = exec.Command("git", "branch", "-M", "main") + cmd.Dir = repoPath + output, err = cmd.CombinedOutput() + if err != nil { + internal.Exit(fmt.Sprintf("Failed to rename branch to main: %s", string(output)), err) + } + } + + fmt.Println("Pushing changes") + cmd = exec.Command("git", "push", "--set-upstream", "origin", "main") + cmd.Dir = repoPath + output, err = cmd.CombinedOutput() + if err != nil { + // Check if it's just an "up-to-date" message + if strings.Contains(string(output), "Everything up-to-date") { + fmt.Println("Everything up-to-date") + return + } + internal.Exit(fmt.Sprintf("Failed to push changes: %s", string(output)), err) + } + + fmt.Println("Successfully uploaded files to GitHub") +} + +func handleIgnore(path string, config *internal.MydConfig) { + absPath, err := filepath.Abs(path) + if err != nil { + internal.Exit("Error getting absolute path", err) + } + + // Check if path exists + if _, err := os.Stat(absPath); os.IsNotExist(err) { + internal.Exit("Error: Path does not exist", nil) + } + + // Read toupload.txt to find the base directory + uploadListPath := filepath.Join(os.ExpandEnv(config.StoragePath), "toupload.txt") + uploadData, err := os.ReadFile(uploadListPath) + if err != nil { + internal.Exit("Error reading toupload.txt", err) + } + + // Find the longest matching path from toupload.txt + var baseDir string + for _, line := range strings.Split(string(uploadData), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(absPath, line) && len(line) > len(baseDir) { + baseDir = line + } + } + + if baseDir == "" { + internal.Exit("Error: Path is not within any directory in toupload.txt", nil) + } + + // Get the relative path from the base directory + relPath, err := filepath.Rel(baseDir, absPath) + if err != nil { + internal.Exit("Error getting relative path", err) + } + + // Join with the base name of the directory + repoIgnorePath := filepath.Join(filepath.Base(baseDir), relPath) + + // Create or open .gitignore file + repoPath := filepath.Join(os.ExpandEnv(config.StoragePath), config.UpstreamName) + gitignorePath := filepath.Join(repoPath, ".gitignore") + + // Read existing entries + existingEntries := make(map[string]bool) + if data, err := os.ReadFile(gitignorePath); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if line = strings.TrimSpace(line); line != "" { + existingEntries[line] = true + } + } + } + + // Check if entry already exists + if existingEntries[repoIgnorePath] { + fmt.Printf("Path %s is already in .gitignore\n", repoIgnorePath) + return + } + + // Append new entry + f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + internal.Exit("Error opening .gitignore", err) + } + defer f.Close() + + if _, err := f.WriteString(repoIgnorePath + "\n"); err != nil { + internal.Exit("Error writing to .gitignore", err) + } + + fmt.Printf("Added %s to .gitignore\n", repoIgnorePath) +} + +func handleList(config *internal.MydConfig) { + // Read toupload.txt + uploadListPath := filepath.Join(os.ExpandEnv(config.StoragePath), "toupload.txt") + data, err := os.ReadFile(uploadListPath) + if err != nil { + if os.IsNotExist(err) { + fmt.Println("No paths are currently being tracked") + return + } + internal.Exit("Error reading toupload.txt", err) + } + + paths := strings.Split(string(data), "\n") + if len(paths) == 0 || (len(paths) == 1 && paths[0] == "") { + fmt.Println("No paths are currently being tracked") + return + } + + fmt.Println("Currently tracked paths:") + for _, path := range paths { + path = strings.TrimSpace(path) + if path != "" { + fmt.Printf(" %s\n", path) + } + } +} + +func handleDelete(config *internal.MydConfig) { + p := tea.NewProgram(internal.NewDeleteModel(config)) + if _, err := p.Run(); err != nil { + internal.Exit("Error running delete menu", err) + } +} + +func handleInstall(repoURL string, config *internal.MydConfig) { + // Parse GitHub URL + repoName := filepath.Base(repoURL) + repoName = strings.TrimSuffix(repoName, ".git") + + // Create temp directory for cloning + tempDir := filepath.Join(os.ExpandEnv(config.StoragePath), "temp", repoName) + if err := os.MkdirAll(tempDir, 0755); err != nil { + internal.Exit("Failed to create temp directory", err) + } + defer os.RemoveAll(tempDir) + + // Clone the repository + fmt.Printf("Cloning %s...\n", repoURL) + cmd := exec.Command("git", "clone", repoURL, tempDir) + output, err := cmd.CombinedOutput() + if err != nil { + internal.Exit(string(output), err) + } + + // Read root .original_path file + rootOriginalPath := filepath.Join(tempDir, ".original_path") + if data, err := os.ReadFile(rootOriginalPath); err == nil { + // Handle single files + for _, line := range strings.Split(string(data), "\n") { + if line = strings.TrimSpace(line); line == "" { + continue + } + + // Expand environment variables + path := os.ExpandEnv(line) + baseName := filepath.Base(path) + srcPath := filepath.Join(tempDir, baseName) + + // Create parent directory if it doesn't exist + parentDir := filepath.Dir(path) + if err := os.MkdirAll(parentDir, 0755); err != nil { + fmt.Printf("Warning: Failed to create directory for %s: %v\n", path, err) + continue + } + + // Copy file to original location + if err := copyFile(srcPath, path); err != nil { + fmt.Printf("Warning: Failed to copy %s: %v\n", baseName, err) + continue + } + fmt.Printf("Installed %s\n", path) + } + } + + // Handle directories with .original_path + entries, err := os.ReadDir(tempDir) + if err != nil { + internal.Exit("Failed to read repository contents", err) + } + + for _, entry := range entries { + if !entry.IsDir() || entry.Name() == ".git" { + continue + } + + dirPath := filepath.Join(tempDir, entry.Name()) + originalPathFile := filepath.Join(dirPath, ".original_path") + + data, err := os.ReadFile(originalPathFile) + if err != nil { + continue + } + + originalPath := strings.TrimSpace(string(data)) + if originalPath == "" { + continue + } + + // Expand environment variables + destPath := os.ExpandEnv(originalPath) + + // Create parent directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + fmt.Printf("Warning: Failed to create directory for %s: %v\n", destPath, err) + continue + } + + // Copy directory to original location + if err := copyDir(dirPath, destPath, true); err != nil { + fmt.Printf("Warning: Failed to copy directory %s: %v\n", entry.Name(), err) + continue + } + fmt.Printf("Installed %s\n", destPath) + } + + fmt.Println("Installation complete!") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e99cf9 --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/wraient/myd + +go 1.23.2 + +require ( + github.com/charmbracelet/bubbletea v1.1.2 + github.com/charmbracelet/lipgloss v0.13.0 + github.com/google/go-github/v60 v60.0.0 + golang.org/x/oauth2 v0.18.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.4.0 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/net v0.22.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dd4c78b --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.1.2 h1:naQXF2laRxyLyil/i7fxdpiz1/k06IKquhm4vBfHsIc= +github.com/charmbracelet/bubbletea v1.1.2/go.mod h1:9HIU/hBV24qKjlehyj8z1r/tR9TYTQEag+cWZnuXo8E= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU= +github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= +github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= +golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..0ea8da0 --- /dev/null +++ b/internal/config.go @@ -0,0 +1,226 @@ +package internal + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" +) + +// MydConfig struct with field names that match the config keys +type MydConfig struct { + StoragePath string `config:"StoragePath"` + UpstreamName string `config:"UpstreamName"` + Username string `config:"Username"` +} + +// Default configuration values as a map +func defaultConfigMap() map[string]string { + return map[string]string{ + "StoragePath": "$HOME/.local/share/myd", + "UpstreamName": "dotfilestest", + "Username": "", + } +} + +var globalConfig *MydConfig + +func SetGlobalConfig(config *MydConfig) { + globalConfig = config +} + +func GetGlobalConfig() *MydConfig { + return globalConfig +} + +// LoadConfig reads or creates the config file, adds missing fields, and returns the populated CurdConfig struct +func LoadConfig(configPath string) (MydConfig, error) { + configPath = os.ExpandEnv(configPath) // Substitute environment variables like $HOME + + // Check if config file exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + // Create the config file with default values if it doesn't exist + fmt.Println("Config file not found. Creating default config...") + if err := createDefaultConfig(configPath); err != nil { + return MydConfig{}, fmt.Errorf("error creating default config file: %v", err) + } + } + + // Load the config from file + configMap, err := loadConfigFromFile(configPath) + if err != nil { + return MydConfig{}, fmt.Errorf("error loading config file: %v", err) + } + + // Add missing fields to the config map + updated := false + defaultConfigMap := defaultConfigMap() + for key, defaultValue := range defaultConfigMap { + if _, exists := configMap[key]; !exists { + configMap[key] = defaultValue + updated = true + } + } + + // Write updated config back to file if there were any missing fields + if updated { + if err := saveConfigToFile(configPath, configMap); err != nil { + return MydConfig{}, fmt.Errorf("error saving updated config file: %v", err) + } + } + + // Populate the CurdConfig struct from the config map + config := populateConfig(configMap) + + return config, nil +} + +// Create a config file with default values in key=value format +// Ensure the directory exists before creating the file +func createDefaultConfig(path string) error { + defaultConfig := defaultConfigMap() + + // Ensure the directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("error creating directory: %v", err) + } + + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("error creating file: %v", err) + } + defer file.Close() + + writer := bufio.NewWriter(file) + for key, value := range defaultConfig { + line := fmt.Sprintf("%s=%s\n", key, value) + if _, err := writer.WriteString(line); err != nil { + return fmt.Errorf("error writing to file: %v", err) + } + } + if err := writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %v", err) + } + return nil +} + +func ChangeToken(config *MydConfig, user *User) { + fmt.Print("Enter your GitHub username: ") + fmt.Scanln(&user.Username) + + fmt.Print("Please generate a token and paste it here: ") + fmt.Scanln(&user.Token) + + err := WriteTokenToFile(user.Token, filepath.Join(os.ExpandEnv(config.StoragePath), "token")) + if err != nil { + Exit("Failed to save token", err) + } + + err = WriteTokenToFile(user.Username, filepath.Join(os.ExpandEnv(config.StoragePath), "username")) + if err != nil { + Exit("Failed to save username", err) + } +} + +// CreateOrWriteTokenFile creates the token file if it doesn't exist and writes the token to it +func WriteTokenToFile(token string, filePath string) error { + // Extract the directory path + dir := filepath.Dir(filePath) + + // Create all necessary parent directories + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directories: %v", err) + } + + // Write the token to the file, creating it if it doesn't exist + err := os.WriteFile(filePath, []byte(token), 0644) + if err != nil { + return fmt.Errorf("failed to write token to file: %v", err) + } + + return nil +} + +// Load config file from disk into a map (key=value format) +func loadConfigFromFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + configMap := make(map[string]string) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue // Skip empty lines and comments + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + configMap[key] = value + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return configMap, nil +} + +// Save updated config map to file in key=value format +func saveConfigToFile(path string, configMap map[string]string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + writer := bufio.NewWriter(file) + for key, value := range configMap { + line := fmt.Sprintf("%s=%s\n", key, value) + if _, err := writer.WriteString(line); err != nil { + return err + } + } + return writer.Flush() +} + +// Populate the CurdConfig struct from a map +func populateConfig(configMap map[string]string) MydConfig { + config := MydConfig{} + configValue := reflect.ValueOf(&config).Elem() + + for i := 0; i < configValue.NumField(); i++ { + field := configValue.Type().Field(i) + tag := field.Tag.Get("config") + + if value, exists := configMap[tag]; exists { + fieldValue := configValue.FieldByName(field.Name) + + if fieldValue.CanSet() { + switch fieldValue.Kind() { + case reflect.String: + fieldValue.SetString(value) + case reflect.Int: + intVal, _ := strconv.Atoi(value) + fieldValue.SetInt(int64(intVal)) + case reflect.Bool: + boolVal, _ := strconv.ParseBool(value) + fieldValue.SetBool(boolVal) + } + } + } + } + + return config +} diff --git a/internal/delete_model.go b/internal/delete_model.go new file mode 100644 index 0000000..d1b63fd --- /dev/null +++ b/internal/delete_model.go @@ -0,0 +1,148 @@ +package internal + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type DeleteModel struct { + config *MydConfig + paths []string + cursor int + selected map[int]bool + quitting bool +} + +func NewDeleteModel(config *MydConfig) *DeleteModel { + paths := loadPaths(config) + return &DeleteModel{ + config: config, + paths: paths, + selected: make(map[int]bool), + quitting: false, + } +} + +func loadPaths(config *MydConfig) []string { + uploadListPath := filepath.Join(os.ExpandEnv(config.StoragePath), "toupload.txt") + data, err := os.ReadFile(uploadListPath) + if err != nil { + return []string{} + } + + var paths []string + for _, line := range strings.Split(string(data), "\n") { + if line = strings.TrimSpace(line); line != "" { + paths = append(paths, line) + } + } + return paths +} + +func (m *DeleteModel) Init() tea.Cmd { + return nil +} + +func (m *DeleteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.paths)-1 { + m.cursor++ + } + case " ": + m.selected[m.cursor] = !m.selected[m.cursor] + return m, nil + case "enter": + if len(m.selected) > 0 { + return m, tea.Sequence( + m.deleteSelected, + func() tea.Msg { return nil }, + ) + } + } + case error: + m.quitting = true + return m, tea.Quit + } + return m, nil +} + +func (m *DeleteModel) deleteSelected() tea.Msg { + // Create a new slice without the selected paths + var newPaths []string + for i, path := range m.paths { + if !m.selected[i] { + newPaths = append(newPaths, path) + } + } + + // Write the new paths back to toupload.txt + uploadListPath := filepath.Join(os.ExpandEnv(m.config.StoragePath), "toupload.txt") + content := strings.Join(newPaths, "\n") + if len(newPaths) > 0 { + content += "\n" + } + + if err := os.WriteFile(uploadListPath, []byte(content), 0644); err != nil { + return err + } + + // Update the model's paths + m.paths = newPaths + m.selected = make(map[int]bool) + if m.cursor >= len(m.paths) { + m.cursor = len(m.paths) - 1 + } + return nil +} + +func (m *DeleteModel) View() string { + if m.quitting { + return "" + } + + if len(m.paths) == 0 { + return "No paths are being tracked\n\nPress q to quit" + } + + s := "Select paths to delete (space to select, enter to delete):\n\n" + + style := lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + selectedStyle := style.Copy().Bold(true) + + for i, path := range m.paths { + cursor := " " + if m.cursor == i { + cursor = ">" + } + + checked := " " + if m.selected[i] { + checked = "x" + } + + line := fmt.Sprintf("%s [%s] %s", cursor, checked, path) + if m.cursor == i { + s += selectedStyle.Render(line) + "\n" + } else { + s += line + "\n" + } + } + + s += "\nPress q to quit\n" + return s +} \ No newline at end of file diff --git a/internal/myd.go b/internal/myd.go new file mode 100644 index 0000000..67102be --- /dev/null +++ b/internal/myd.go @@ -0,0 +1,52 @@ +package internal + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "time" +) + +func Exit(msg string,err error) { + if err != nil { + fmt.Println(err) + os.Exit(1) + } + if msg != "" { + fmt.Println(msg) + } + os.Exit(0) +} + +// LogData logs the input data into a specified log file with the format [LOG] time lineNumber: logData +func Log(data interface{}, logFile string) error { + // Open or create the log file + file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) + if err != nil { + return err + } + defer file.Close() // Ensure the file is closed when done + + // Attempt to marshal the data into JSON + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + // Get the caller information + _, filename, lineNumber, ok := runtime.Caller(1) // Caller 1 gives the caller of LogData + if !ok { + return fmt.Errorf("unable to get caller information") + } + + // Log the current time and the JSON representation along with caller info + currentTime := time.Now().Format("2006/01/02 15:04:05") + logMessage := fmt.Sprintf("[LOG] %s %s:%d: %s\n", currentTime, filename, lineNumber, jsonData) + _, err = fmt.Fprint(file, logMessage) // Write to the file + if err != nil { + return err + } + + return nil +} \ No newline at end of file diff --git a/internal/structs.go b/internal/structs.go new file mode 100644 index 0000000..cf14de1 --- /dev/null +++ b/internal/structs.go @@ -0,0 +1,6 @@ +package internal + +type User struct { + Token string + Username string +}