mirror of
https://github.com/yarlson/lnk.git
synced 2025-08-28 17:39:47 +02:00
feat(output): implement configurable color and emoji output
Add new output formatting system with flags for color and emoji control: - Introduce OutputConfig and Writer structs for flexible output handling - Add --colors and --emoji/--no-emoji global flags - Refactor commands to use new Writer for consistent formatting - Separate error content from presentation for better flexibility
This commit is contained in:
66
README.md
66
README.md
@@ -325,6 +325,72 @@ lnk pull # Get updates (work config won't affe
|
||||
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
|
||||
- `--force` - Force initialization even if directory contains managed files (WARNING: overwrites existing content)
|
||||
|
||||
### Output Formatting
|
||||
|
||||
Lnk provides flexible output formatting options to suit different environments and preferences:
|
||||
|
||||
#### Color Output
|
||||
|
||||
Control when ANSI colors are used in output:
|
||||
|
||||
```bash
|
||||
# Default: auto-detect based on TTY
|
||||
lnk init
|
||||
|
||||
# Force colors regardless of environment
|
||||
lnk init --colors=always
|
||||
|
||||
# Disable colors completely
|
||||
lnk init --colors=never
|
||||
|
||||
# Environment variable support
|
||||
NO_COLOR=1 lnk init # Disables colors (acts like --colors=never)
|
||||
```
|
||||
|
||||
**Color modes:**
|
||||
- `auto` (default): Use colors only when stdout is a TTY
|
||||
- `always`: Force color output regardless of TTY
|
||||
- `never`: Disable color output regardless of TTY
|
||||
|
||||
The `NO_COLOR` environment variable acts like `--colors=never` when set, but explicit `--colors` flags take precedence.
|
||||
|
||||
#### Emoji Output
|
||||
|
||||
Control emoji usage in output messages:
|
||||
|
||||
```bash
|
||||
# Default: emojis enabled
|
||||
lnk init
|
||||
|
||||
# Disable emojis
|
||||
lnk init --no-emoji
|
||||
|
||||
# Explicitly enable emojis
|
||||
lnk init --emoji
|
||||
```
|
||||
|
||||
**Emoji flags:**
|
||||
- `--emoji` (default: true): Enable emoji in output
|
||||
- `--no-emoji`: Disable emoji in output
|
||||
|
||||
The `--emoji` and `--no-emoji` flags are mutually exclusive.
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Clean output for scripts/pipes
|
||||
lnk init --colors=never --no-emoji
|
||||
|
||||
# Force colorful output in non-TTY environments
|
||||
lnk init --colors=always
|
||||
|
||||
# Disable colors but keep emojis
|
||||
lnk init --colors=never
|
||||
|
||||
# Disable emojis but keep colors
|
||||
lnk init --no-emoji
|
||||
```
|
||||
|
||||
### Add Command Examples
|
||||
|
||||
```bash
|
||||
|
73
cmd/add.go
73
cmd/add.go
@@ -1,9 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
@@ -33,6 +35,7 @@ changes to your system - perfect for verification before bulk operations.`,
|
||||
recursive, _ := cmd.Flags().GetBool("recursive")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
lnk := core.NewLnk(core.WithHost(host))
|
||||
w := GetWriter(cmd)
|
||||
|
||||
// Handle dry-run mode
|
||||
if dryRun {
|
||||
@@ -43,19 +46,22 @@ changes to your system - perfect for verification before bulk operations.`,
|
||||
|
||||
// Display preview output
|
||||
if recursive {
|
||||
printf(cmd, "🔍 \033[1mWould add %d files recursively:\033[0m\n", len(files))
|
||||
w.Writeln(Message{Text: fmt.Sprintf("Would add %d files recursively:", len(files)), Emoji: "🔍", Bold: true})
|
||||
} else {
|
||||
printf(cmd, "🔍 \033[1mWould add %d files:\033[0m\n", len(files))
|
||||
w.Writeln(Message{Text: fmt.Sprintf("Would add %d files:", len(files)), Emoji: "🔍", Bold: true})
|
||||
}
|
||||
|
||||
// List files that would be added
|
||||
for _, file := range files {
|
||||
basename := filepath.Base(file)
|
||||
printf(cmd, " 📄 \033[90m%s\033[0m\n", basename)
|
||||
w.WriteString(" ").
|
||||
Writeln(Message{Text: basename, Emoji: "📄"})
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 \033[33mTo proceed:\033[0m run without --dry-run flag\n")
|
||||
return nil
|
||||
w.WritelnString("").
|
||||
Writeln(Info("To proceed: run without --dry-run flag"))
|
||||
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
// Handle recursive mode
|
||||
@@ -68,7 +74,7 @@ changes to your system - perfect for verification before bulk operations.`,
|
||||
|
||||
// Create progress callback for CLI display
|
||||
progressCallback := func(current, total int, currentFile string) {
|
||||
printf(cmd, "\r⏳ Processing %d/%d: %s", current, total, currentFile)
|
||||
w.WriteString(fmt.Sprintf("\r⏳ Processing %d/%d: %s", current, total, currentFile))
|
||||
}
|
||||
|
||||
if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil {
|
||||
@@ -76,7 +82,7 @@ changes to your system - perfect for verification before bulk operations.`,
|
||||
}
|
||||
|
||||
// Clear progress line and show completion
|
||||
printf(cmd, "\r")
|
||||
w.WriteString("\r")
|
||||
|
||||
// Store processed file count for display
|
||||
args = previewFiles // Replace args with actual files for display
|
||||
@@ -99,9 +105,9 @@ changes to your system - perfect for verification before bulk operations.`,
|
||||
if recursive {
|
||||
// Recursive mode - show enhanced message with count
|
||||
if host != "" {
|
||||
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk (host: %s)\033[0m\n", len(args), host)
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk (host: %s)", len(args), host)))
|
||||
} else {
|
||||
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk\033[0m\n", len(args))
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk", len(args))))
|
||||
}
|
||||
|
||||
// Show some of the files that were added (limit to first few for readability)
|
||||
@@ -113,47 +119,70 @@ changes to your system - perfect for verification before bulk operations.`,
|
||||
for i := 0; i < filesToShow; i++ {
|
||||
basename := filepath.Base(args[i])
|
||||
if host != "" {
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
|
||||
w.WriteString(" ").
|
||||
Write(Link(basename)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan))
|
||||
} else {
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
|
||||
w.WriteString(" ").
|
||||
Write(Link(basename)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored("~/.config/lnk/...", ColorCyan))
|
||||
}
|
||||
}
|
||||
|
||||
if len(args) > 5 {
|
||||
printf(cmd, " \033[90m... and %d more files\033[0m\n", len(args)-5)
|
||||
w.WriteString(" ").
|
||||
Writeln(Colored(fmt.Sprintf("... and %d more files", len(args)-5), ColorGray))
|
||||
}
|
||||
} else if len(args) == 1 {
|
||||
// Single file - maintain existing output format for backward compatibility
|
||||
filePath := args[0]
|
||||
basename := filepath.Base(filePath)
|
||||
if host != "" {
|
||||
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath)
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk (host: %s)", basename, host)))
|
||||
w.WriteString(" ").
|
||||
Write(Link(filePath)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, filePath), ColorCyan))
|
||||
} else {
|
||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk", basename)))
|
||||
w.WriteString(" ").
|
||||
Write(Link(filePath)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s", filePath), ColorCyan))
|
||||
}
|
||||
} else {
|
||||
// Multiple files - show summary
|
||||
if host != "" {
|
||||
printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host)
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk (host: %s)", len(args), host)))
|
||||
} else {
|
||||
printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args))
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk", len(args))))
|
||||
}
|
||||
|
||||
// List each added file
|
||||
for _, filePath := range args {
|
||||
basename := filepath.Base(filePath)
|
||||
if host != "" {
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
|
||||
w.WriteString(" ").
|
||||
Write(Link(basename)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan))
|
||||
} else {
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
|
||||
w.WriteString(" ").
|
||||
Write(Link(basename)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored("~/.config/lnk/...", ColorCyan))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||
return nil
|
||||
w.WriteString(" ").
|
||||
Write(Message{Text: "Use ", Emoji: "📝"}).
|
||||
Write(Bold("lnk push")).
|
||||
WritelnString(" to sync to remote")
|
||||
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
@@ -14,6 +15,7 @@ func newBootstrapCmd() *cobra.Command {
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
lnk := core.NewLnk()
|
||||
w := GetWriter(cmd)
|
||||
|
||||
scriptPath, err := lnk.FindBootstrapScript()
|
||||
if err != nil {
|
||||
@@ -21,25 +23,40 @@ func newBootstrapCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
if scriptPath == "" {
|
||||
printf(cmd, "💡 \033[33mNo bootstrap script found\033[0m\n")
|
||||
printf(cmd, " 📝 Create a \033[1mbootstrap.sh\033[0m file in your dotfiles repository:\n")
|
||||
printf(cmd, " \033[90m#!/bin/bash\033[0m\n")
|
||||
printf(cmd, " \033[90mecho \"Setting up environment...\"\033[0m\n")
|
||||
printf(cmd, " \033[90m# Your setup commands here\033[0m\n")
|
||||
return nil
|
||||
w.Writeln(Info("No bootstrap script found")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Create a ", Emoji: "📝"}).
|
||||
Write(Bold("bootstrap.sh")).
|
||||
WritelnString(" file in your dotfiles repository:").
|
||||
WriteString(" ").
|
||||
Writeln(Colored("#!/bin/bash", ColorGray)).
|
||||
WriteString(" ").
|
||||
Writeln(Colored("echo \"Setting up environment...\"", ColorGray)).
|
||||
WriteString(" ").
|
||||
Writeln(Colored("# Your setup commands here", ColorGray))
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n")
|
||||
printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath)
|
||||
printf(cmd, "\n")
|
||||
w.Writeln(Rocket("Running bootstrap script")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Script: ", Emoji: "📄"}).
|
||||
Writeln(Colored(scriptPath, ColorCyan)).
|
||||
WritelnString("")
|
||||
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||
printf(cmd, " 🎉 Your environment is ready to use\n")
|
||||
return nil
|
||||
w.WritelnString("").
|
||||
Writeln(Success("Bootstrap completed successfully!")).
|
||||
WriteString(" ").
|
||||
Writeln(Message{Text: "Your environment is ready to use", Emoji: "🎉"})
|
||||
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
104
cmd/init.go
104
cmd/init.go
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
@@ -18,11 +19,17 @@ func newInitCmd() *cobra.Command {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
lnk := core.NewLnk()
|
||||
w := GetWriter(cmd)
|
||||
|
||||
// Show warning when force is used and there are managed files to overwrite
|
||||
if force && remote != "" && lnk.HasUserContent() {
|
||||
printf(cmd, "⚠️ \033[33mUsing --force flag: This will overwrite existing managed files\033[0m\n")
|
||||
printf(cmd, " 💡 Only use this if you understand the risks\n\n")
|
||||
w.Writeln(Warning("Using --force flag: This will overwrite existing managed files")).
|
||||
WriteString(" ").
|
||||
Writeln(Info("Only use this if you understand the risks")).
|
||||
WritelnString("")
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := lnk.InitWithRemoteForce(remote, force); err != nil {
|
||||
@@ -30,13 +37,26 @@ func newInitCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
if remote != "" {
|
||||
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||
w.Writeln(Target("Initialized lnk repository")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Cloned from: ", Emoji: "📦"}).
|
||||
Writeln(Colored(remote, ColorCyan)).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Location: ", Emoji: "📁"}).
|
||||
Writeln(Colored("~/.config/lnk", ColorGray))
|
||||
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try to run bootstrap script if not disabled
|
||||
if !noBootstrap {
|
||||
printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n")
|
||||
w.WritelnString("").
|
||||
Writeln(Message{Text: "Looking for bootstrap script...", Emoji: "🔍", Bold: true})
|
||||
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scriptPath, err := lnk.FindBootstrapScript()
|
||||
if err != nil {
|
||||
@@ -44,34 +64,68 @@ func newInitCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
if scriptPath != "" {
|
||||
printf(cmd, " ✅ Found bootstrap script: \033[36m%s\033[0m\n", scriptPath)
|
||||
printf(cmd, "\n🚀 \033[1mRunning bootstrap script...\033[0m\n")
|
||||
printf(cmd, "\n")
|
||||
w.WriteString(" ").
|
||||
Write(Success("Found bootstrap script: ")).
|
||||
Writeln(Colored(scriptPath, ColorCyan)).
|
||||
WritelnString("").
|
||||
Writeln(Rocket("Running bootstrap script...")).
|
||||
WritelnString("")
|
||||
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||
printf(cmd, "\n⚠️ \033[33mBootstrap script failed, but repository was initialized successfully\033[0m\n")
|
||||
printf(cmd, " 💡 You can run it manually with: \033[1mlnk bootstrap\033[0m\n")
|
||||
printf(cmd, " 🔧 Error: %v\n", err)
|
||||
w.WritelnString("").
|
||||
Writeln(Warning("Bootstrap script failed, but repository was initialized successfully")).
|
||||
WriteString(" ").
|
||||
Write(Info("You can run it manually with: ")).
|
||||
Writeln(Bold("lnk bootstrap")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Error: ", Emoji: "🔧"}).
|
||||
Writeln(Plain(err.Error()))
|
||||
} else {
|
||||
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||
w.WritelnString("").
|
||||
Writeln(Success("Bootstrap completed successfully!"))
|
||||
}
|
||||
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
printf(cmd, " 💡 No bootstrap script found\n")
|
||||
w.WriteString(" ").
|
||||
Writeln(Info("No bootstrap script found"))
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
||||
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
||||
} else {
|
||||
printf(cmd, "🎯 \033[1mInitialized empty lnk repository\033[0m\n")
|
||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||
printf(cmd, " • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
|
||||
printf(cmd, " • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
|
||||
}
|
||||
w.WritelnString("").
|
||||
Writeln(Info("Next steps:")).
|
||||
WriteString(" • Run ").
|
||||
Write(Bold("lnk pull")).
|
||||
Writeln(Plain(" to restore symlinks")).
|
||||
WriteString(" • Use ").
|
||||
Write(Bold("lnk add <file>")).
|
||||
Writeln(Plain(" to manage new files"))
|
||||
|
||||
return nil
|
||||
return w.Err()
|
||||
} else {
|
||||
w.Writeln(Target("Initialized empty lnk repository")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Location: ", Emoji: "📁"}).
|
||||
Writeln(Colored("~/.config/lnk", ColorGray)).
|
||||
WritelnString("").
|
||||
Writeln(Info("Next steps:")).
|
||||
WriteString(" • Run ").
|
||||
Write(Bold("lnk add <file>")).
|
||||
Writeln(Plain(" to start managing dotfiles")).
|
||||
WriteString(" • Add a remote with: ").
|
||||
Writeln(Bold("git remote add origin <url>"))
|
||||
|
||||
return w.Err()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
109
cmd/list.go
109
cmd/list.go
@@ -1,11 +1,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
@@ -42,61 +44,88 @@ func newListCmd() *cobra.Command {
|
||||
|
||||
func listCommonConfig(cmd *cobra.Command) error {
|
||||
lnk := core.NewLnk()
|
||||
w := GetWriter(cmd)
|
||||
|
||||
managedItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(managedItems) == 0 {
|
||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n")
|
||||
printf(cmd, " 💡 Use \033[1mlnk add <file>\033[0m to start managing files\n")
|
||||
return nil
|
||||
w.Writeln(Message{Text: "No files currently managed by lnk (common)", Emoji: "📋", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold("lnk add <file>")).
|
||||
WritelnString(" to start managing files")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
|
||||
countText := fmt.Sprintf("Files managed by lnk (common) (%d item", len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
countText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m):\n\n")
|
||||
countText += "):"
|
||||
|
||||
w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}).
|
||||
WritelnString("")
|
||||
|
||||
for _, item := range managedItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
w.WriteString(" ").
|
||||
Writeln(Link(item))
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
return nil
|
||||
w.WritelnString("").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold("lnk status")).
|
||||
WritelnString(" to check sync status")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||
lnk := core.NewLnk(core.WithHost(host))
|
||||
w := GetWriter(cmd)
|
||||
|
||||
managedItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(managedItems) == 0 {
|
||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host)
|
||||
printf(cmd, " 💡 Use \033[1mlnk add --host %s <file>\033[0m to start managing files\n", host)
|
||||
return nil
|
||||
w.Writeln(Message{Text: fmt.Sprintf("No files currently managed by lnk (host: %s)", host), Emoji: "📋", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold(fmt.Sprintf("lnk add --host %s <file>", host))).
|
||||
WritelnString(" to start managing files")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
|
||||
countText := fmt.Sprintf("Files managed by lnk (host: %s) (%d item", host, len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
countText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m):\n\n")
|
||||
countText += "):"
|
||||
|
||||
w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}).
|
||||
WritelnString("")
|
||||
|
||||
for _, item := range managedItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
w.WriteString(" ").
|
||||
Writeln(Link(item))
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
return nil
|
||||
w.WritelnString("").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold("lnk status")).
|
||||
WritelnString(" to check sync status")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
func listAllConfigs(cmd *cobra.Command) error {
|
||||
w := GetWriter(cmd)
|
||||
|
||||
// List common configuration
|
||||
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
||||
w.Writeln(Message{Text: "All configurations managed by lnk", Emoji: "📋", Bold: true}).
|
||||
WritelnString("")
|
||||
|
||||
lnk := core.NewLnk()
|
||||
commonItems, err := lnk.List()
|
||||
@@ -104,17 +133,21 @@ func listAllConfigs(cmd *cobra.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
|
||||
countText := fmt.Sprintf("Common configuration (%d item", len(commonItems))
|
||||
if len(commonItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
countText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
countText += "):"
|
||||
|
||||
w.Writeln(Message{Text: countText, Emoji: "🌐", Bold: true})
|
||||
|
||||
if len(commonItems) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
w.WriteString(" ").
|
||||
Writeln(Colored("(no files)", ColorGray))
|
||||
} else {
|
||||
for _, item := range commonItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
w.WriteString(" ").
|
||||
Writeln(Link(item))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,32 +158,42 @@ func listAllConfigs(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||
w.WritelnString("").
|
||||
Write(Message{Text: fmt.Sprintf("Host: %s", host), Emoji: "🖥️", Bold: true})
|
||||
|
||||
hostLnk := core.NewLnk(core.WithHost(host))
|
||||
hostItems, err := hostLnk.List()
|
||||
if err != nil {
|
||||
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
||||
w.WriteString(" ").
|
||||
Writeln(Colored(fmt.Sprintf("(error: %v)", err), ColorRed))
|
||||
continue
|
||||
}
|
||||
|
||||
printf(cmd, " (\033[36m%d item", len(hostItems))
|
||||
countText := fmt.Sprintf(" (%d item", len(hostItems))
|
||||
if len(hostItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
countText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
countText += "):"
|
||||
|
||||
w.WriteString(countText).
|
||||
WritelnString("")
|
||||
|
||||
if len(hostItems) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
w.WriteString(" ").
|
||||
Writeln(Colored("(no files)", ColorGray))
|
||||
} else {
|
||||
for _, item := range hostItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
w.WriteString(" ").
|
||||
Writeln(Link(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
|
||||
return nil
|
||||
w.WritelnString("").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold("lnk list --host <hostname>")).
|
||||
WritelnString(" to see specific host configuration")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
func findHostConfigs() ([]string, error) {
|
||||
|
230
cmd/output.go
Normal file
230
cmd/output.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// OutputConfig controls formatting behavior
|
||||
type OutputConfig struct {
|
||||
Colors bool
|
||||
Emoji bool
|
||||
}
|
||||
|
||||
// Writer provides formatted output with configurable styling
|
||||
type Writer struct {
|
||||
out io.Writer
|
||||
config OutputConfig
|
||||
err error // first error encountered
|
||||
}
|
||||
|
||||
// NewWriter creates a new Writer with the given configuration
|
||||
func NewWriter(out io.Writer, config OutputConfig) *Writer {
|
||||
return &Writer{
|
||||
out: out,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Message represents a structured message with optional formatting
|
||||
type Message struct {
|
||||
Text string
|
||||
Color string
|
||||
Emoji string
|
||||
Bold bool
|
||||
}
|
||||
|
||||
// Write outputs a message according to the writer's configuration
|
||||
func (w *Writer) Write(msg Message) *Writer {
|
||||
if w.err != nil {
|
||||
return w
|
||||
}
|
||||
|
||||
var output string
|
||||
|
||||
// Add emoji if enabled
|
||||
if w.config.Emoji && msg.Emoji != "" {
|
||||
output = msg.Emoji + " "
|
||||
}
|
||||
|
||||
// Add color/bold if enabled
|
||||
if w.config.Colors {
|
||||
if msg.Bold {
|
||||
output += "\033[1m"
|
||||
}
|
||||
if msg.Color != "" {
|
||||
output += msg.Color
|
||||
}
|
||||
}
|
||||
|
||||
output += msg.Text
|
||||
|
||||
// Close formatting if enabled
|
||||
if w.config.Colors && (msg.Bold || msg.Color != "") {
|
||||
output += "\033[0m"
|
||||
}
|
||||
|
||||
_, w.err = fmt.Fprint(w.out, output)
|
||||
return w
|
||||
}
|
||||
|
||||
// Printf is like Write but with format string
|
||||
func (w *Writer) Printf(msg Message, args ...any) *Writer {
|
||||
newMsg := msg
|
||||
newMsg.Text = fmt.Sprintf(msg.Text, args...)
|
||||
return w.Write(newMsg)
|
||||
}
|
||||
|
||||
// Writeln writes a message followed by a newline
|
||||
func (w *Writer) Writeln(msg Message) *Writer {
|
||||
return w.Write(msg).WriteString("\n")
|
||||
}
|
||||
|
||||
// WriteString outputs plain text (no formatting)
|
||||
func (w *Writer) WriteString(text string) *Writer {
|
||||
if w.err != nil {
|
||||
return w
|
||||
}
|
||||
_, w.err = fmt.Fprint(w.out, text)
|
||||
return w
|
||||
}
|
||||
|
||||
// WritelnString outputs plain text followed by a newline
|
||||
func (w *Writer) WritelnString(text string) *Writer {
|
||||
if w.err != nil {
|
||||
return w
|
||||
}
|
||||
|
||||
_, w.err = fmt.Fprintln(w.out, text)
|
||||
return w
|
||||
}
|
||||
|
||||
// ANSI color codes
|
||||
const (
|
||||
ColorRed = "\033[31m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorGray = "\033[90m"
|
||||
ColorBrightGreen = "\033[1;32m"
|
||||
ColorBrightYellow = "\033[1;33m"
|
||||
ColorBrightRed = "\033[1;31m"
|
||||
)
|
||||
|
||||
// Predefined message constructors for common patterns
|
||||
|
||||
func Success(text string) Message {
|
||||
return Message{Text: text, Color: ColorBrightGreen, Emoji: "✅", Bold: true}
|
||||
}
|
||||
|
||||
func Error(text string) Message {
|
||||
return Message{Text: text, Emoji: "❌"}
|
||||
}
|
||||
|
||||
func Warning(text string) Message {
|
||||
return Message{Text: text, Color: ColorBrightYellow, Emoji: "⚠️", Bold: true}
|
||||
}
|
||||
|
||||
func Info(text string) Message {
|
||||
return Message{Text: text, Color: ColorYellow, Emoji: "💡"}
|
||||
}
|
||||
|
||||
func Target(text string) Message {
|
||||
return Message{Text: text, Emoji: "🎯", Bold: true}
|
||||
}
|
||||
|
||||
func Rocket(text string) Message {
|
||||
return Message{Text: text, Emoji: "🚀", Bold: true}
|
||||
}
|
||||
|
||||
func Sparkles(text string) Message {
|
||||
return Message{Text: text, Emoji: "✨", Bold: true}
|
||||
}
|
||||
|
||||
func Link(text string) Message {
|
||||
return Message{Text: text, Color: ColorCyan, Emoji: "🔗"}
|
||||
}
|
||||
|
||||
func Plain(text string) Message {
|
||||
return Message{Text: text}
|
||||
}
|
||||
|
||||
func Bold(text string) Message {
|
||||
return Message{Text: text, Bold: true}
|
||||
}
|
||||
|
||||
func Colored(text, color string) Message {
|
||||
return Message{Text: text, Color: color}
|
||||
}
|
||||
|
||||
// Global output configuration
|
||||
var (
|
||||
globalConfig = OutputConfig{
|
||||
Colors: true, // auto-detect on first use
|
||||
Emoji: true,
|
||||
}
|
||||
autoDetected bool
|
||||
)
|
||||
|
||||
// SetGlobalConfig updates the global output configuration
|
||||
func SetGlobalConfig(colors string, emoji bool) error {
|
||||
switch colors {
|
||||
case "auto":
|
||||
globalConfig.Colors = isTerminal()
|
||||
case "always":
|
||||
globalConfig.Colors = true
|
||||
case "never":
|
||||
globalConfig.Colors = false
|
||||
default:
|
||||
return fmt.Errorf("invalid color mode: %s (valid: auto, always, never)", colors)
|
||||
}
|
||||
|
||||
// Check NO_COLOR environment variable (explicit flag takes precedence)
|
||||
if os.Getenv("NO_COLOR") != "" && colors == "auto" {
|
||||
globalConfig.Colors = false
|
||||
}
|
||||
|
||||
globalConfig.Emoji = emoji
|
||||
autoDetected = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTerminal checks if stdout is a terminal
|
||||
func isTerminal() bool {
|
||||
fileInfo, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fileInfo.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// autoDetectConfig performs one-time auto-detection if not explicitly configured
|
||||
func autoDetectConfig() {
|
||||
if !autoDetected {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
globalConfig.Colors = false
|
||||
} else {
|
||||
globalConfig.Colors = isTerminal()
|
||||
}
|
||||
autoDetected = true
|
||||
}
|
||||
}
|
||||
|
||||
// GetWriter returns a writer for the given cobra command
|
||||
func GetWriter(cmd *cobra.Command) *Writer {
|
||||
autoDetectConfig()
|
||||
return NewWriter(cmd.OutOrStdout(), globalConfig)
|
||||
}
|
||||
|
||||
// GetErrorWriter returns a writer for stderr
|
||||
func GetErrorWriter() *Writer {
|
||||
autoDetectConfig()
|
||||
return NewWriter(os.Stderr, globalConfig)
|
||||
}
|
||||
|
||||
// Err returns the first error encountered during writing
|
||||
func (w *Writer) Err() error {
|
||||
return w.err
|
||||
}
|
271
cmd/output_test.go
Normal file
271
cmd/output_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func TestOutputConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
colors string
|
||||
emoji bool
|
||||
expectError bool
|
||||
expectedColors bool
|
||||
expectedEmoji bool
|
||||
}{
|
||||
{
|
||||
name: "auto mode",
|
||||
colors: "auto",
|
||||
emoji: true,
|
||||
expectError: false,
|
||||
expectedColors: false, // TTY detection will return false in tests
|
||||
expectedEmoji: true,
|
||||
},
|
||||
{
|
||||
name: "always mode",
|
||||
colors: "always",
|
||||
emoji: false,
|
||||
expectError: false,
|
||||
expectedColors: true,
|
||||
expectedEmoji: false,
|
||||
},
|
||||
{
|
||||
name: "never mode",
|
||||
colors: "never",
|
||||
emoji: true,
|
||||
expectError: false,
|
||||
expectedColors: false,
|
||||
expectedEmoji: true,
|
||||
},
|
||||
{
|
||||
name: "invalid mode",
|
||||
colors: "invalid",
|
||||
emoji: true,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clear NO_COLOR for consistent testing
|
||||
_ = os.Unsetenv("NO_COLOR")
|
||||
|
||||
err := SetGlobalConfig(tt.colors, tt.emoji)
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !tt.expectError {
|
||||
if globalConfig.Colors != tt.expectedColors {
|
||||
t.Errorf("expected colors %v, got %v", tt.expectedColors, globalConfig.Colors)
|
||||
}
|
||||
if globalConfig.Emoji != tt.expectedEmoji {
|
||||
t.Errorf("expected emoji %v, got %v", tt.expectedEmoji, globalConfig.Emoji)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNOCOLOREnvironmentVariable(t *testing.T) {
|
||||
// Test NO_COLOR environment variable with auto mode
|
||||
_ = os.Setenv("NO_COLOR", "1")
|
||||
defer func() { _ = os.Unsetenv("NO_COLOR") }()
|
||||
|
||||
err := SetGlobalConfig("auto", true)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if globalConfig.Colors != false {
|
||||
t.Errorf("expected colors disabled when NO_COLOR is set, got %v", globalConfig.Colors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config OutputConfig
|
||||
message Message
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
name: "full formatting",
|
||||
config: OutputConfig{Colors: true, Emoji: true},
|
||||
message: Message{
|
||||
Text: "test message",
|
||||
Color: ColorRed,
|
||||
Emoji: "✅",
|
||||
Bold: true,
|
||||
},
|
||||
expectedOutput: "✅ \033[1m\033[31mtest message\033[0m",
|
||||
},
|
||||
{
|
||||
name: "colors only",
|
||||
config: OutputConfig{Colors: true, Emoji: false},
|
||||
message: Message{
|
||||
Text: "test message",
|
||||
Color: ColorRed,
|
||||
Emoji: "✅",
|
||||
Bold: true,
|
||||
},
|
||||
expectedOutput: "\033[1m\033[31mtest message\033[0m",
|
||||
},
|
||||
{
|
||||
name: "emoji only",
|
||||
config: OutputConfig{Colors: false, Emoji: true},
|
||||
message: Message{
|
||||
Text: "test message",
|
||||
Color: ColorRed,
|
||||
Emoji: "✅",
|
||||
Bold: true,
|
||||
},
|
||||
expectedOutput: "✅ test message",
|
||||
},
|
||||
{
|
||||
name: "no formatting",
|
||||
config: OutputConfig{Colors: false, Emoji: false},
|
||||
message: Message{
|
||||
Text: "test message",
|
||||
Color: ColorRed,
|
||||
Emoji: "✅",
|
||||
Bold: true,
|
||||
},
|
||||
expectedOutput: "test message",
|
||||
},
|
||||
{
|
||||
name: "plain message",
|
||||
config: OutputConfig{Colors: true, Emoji: true},
|
||||
message: Plain("plain text"),
|
||||
expectedOutput: "plain text",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&buf, tt.config)
|
||||
|
||||
writer.Write(tt.message)
|
||||
if err := writer.Err(); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if buf.String() != tt.expectedOutput {
|
||||
t.Errorf("expected %q, got %q", tt.expectedOutput, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredefinedMessages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
creator func(string) Message
|
||||
text string
|
||||
}{
|
||||
{"Success", Success, "operation succeeded"},
|
||||
{"Error", Error, "something failed"},
|
||||
{"Warning", Warning, "be careful"},
|
||||
{"Info", Info, "useful information"},
|
||||
{"Target", Target, "target reached"},
|
||||
{"Rocket", Rocket, "launching"},
|
||||
{"Sparkles", Sparkles, "amazing"},
|
||||
{"Link", Link, "connected"},
|
||||
{"Plain", Plain, "no formatting"},
|
||||
{"Bold", Bold, "emphasis"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&buf, OutputConfig{Colors: true, Emoji: true})
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf.Reset()
|
||||
msg := tt.creator(tt.text)
|
||||
|
||||
writer.Write(msg)
|
||||
if err := writer.Err(); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, tt.text) {
|
||||
t.Errorf("output should contain text %q, got %q", tt.text, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *core.LnkError
|
||||
config OutputConfig
|
||||
contains []string
|
||||
notContains []string
|
||||
}{
|
||||
{
|
||||
name: "structured error with full formatting",
|
||||
err: &core.LnkError{
|
||||
Message: "Something went wrong",
|
||||
Suggestion: "Try this instead",
|
||||
Path: "/some/path",
|
||||
ErrorType: "test_error",
|
||||
},
|
||||
config: OutputConfig{Colors: true, Emoji: true},
|
||||
contains: []string{"❌", "Something went wrong", "/some/path", "💡", "Try this instead"},
|
||||
},
|
||||
{
|
||||
name: "structured error without emojis",
|
||||
err: &core.LnkError{
|
||||
Message: "Something went wrong",
|
||||
Suggestion: "Try this instead",
|
||||
Path: "/some/path",
|
||||
ErrorType: "test_error",
|
||||
},
|
||||
config: OutputConfig{Colors: true, Emoji: false},
|
||||
contains: []string{"Something went wrong", "/some/path", "Try this instead"},
|
||||
notContains: []string{"❌", "💡"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := NewWriter(&buf, tt.config)
|
||||
|
||||
// Test the component messages directly
|
||||
_ = w.Write(Error(tt.err.Message))
|
||||
if tt.err.Path != "" {
|
||||
_ = w.WriteString("\n ")
|
||||
_ = w.Write(Colored(tt.err.Path, ColorRed))
|
||||
}
|
||||
if tt.err.Suggestion != "" {
|
||||
_ = w.WriteString("\n ")
|
||||
_ = w.Write(Info(tt.err.Suggestion))
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
for _, expected := range tt.contains {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("output should contain %q, got %q", expected, output)
|
||||
}
|
||||
}
|
||||
for _, notExpected := range tt.notContains {
|
||||
if strings.Contains(output, notExpected) {
|
||||
t.Errorf("output should not contain %q, got %q", notExpected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
45
cmd/pull.go
45
cmd/pull.go
@@ -1,7 +1,10 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
@@ -14,8 +17,8 @@ func newPullCmd() *cobra.Command {
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
lnk := core.NewLnk(core.WithHost(host))
|
||||
w := GetWriter(cmd)
|
||||
|
||||
restored, err := lnk.Pull()
|
||||
if err != nil {
|
||||
@@ -23,31 +26,47 @@ func newPullCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
if len(restored) > 0 {
|
||||
var successMsg string
|
||||
if host != "" {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||
successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host)
|
||||
} else {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
successMsg = "Successfully pulled changes"
|
||||
}
|
||||
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
||||
|
||||
symlinkText := fmt.Sprintf("Restored %d symlink", len(restored))
|
||||
if len(restored) > 1 {
|
||||
printf(cmd, "s")
|
||||
symlinkText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m:\n")
|
||||
symlinkText += ":"
|
||||
|
||||
w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}).
|
||||
WriteString(" ").
|
||||
Writeln(Link(symlinkText))
|
||||
|
||||
for _, file := range restored {
|
||||
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
|
||||
w.WriteString(" ").
|
||||
Writeln(Sparkles(file))
|
||||
}
|
||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Writeln(Message{Text: "Your dotfiles are synced and ready!", Emoji: "🎉"})
|
||||
} else {
|
||||
var successMsg string
|
||||
if host != "" {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||
successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host)
|
||||
} else {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
successMsg = "Successfully pulled changes"
|
||||
}
|
||||
printf(cmd, " ✅ All symlinks already in place\n")
|
||||
printf(cmd, " 🎉 Everything is up to date!\n")
|
||||
|
||||
w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}).
|
||||
WriteString(" ").
|
||||
Writeln(Success("All symlinks already in place")).
|
||||
WriteString(" ").
|
||||
Writeln(Message{Text: "Everything is up to date!", Emoji: "🎉"})
|
||||
}
|
||||
|
||||
return nil
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
|
||||
|
18
cmd/push.go
18
cmd/push.go
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
@@ -20,15 +21,22 @@ func newPushCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
lnk := core.NewLnk()
|
||||
w := GetWriter(cmd)
|
||||
|
||||
if err := lnk.Push(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
||||
printf(cmd, " 💾 Commit: \033[90m%s\033[0m\n", message)
|
||||
printf(cmd, " 📡 Synced to remote\n")
|
||||
printf(cmd, " ✨ Your dotfiles are up to date!\n")
|
||||
return nil
|
||||
w.Writeln(Rocket("Successfully pushed changes")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Commit: ", Emoji: "💾"}).
|
||||
Writeln(Colored(message, ColorGray)).
|
||||
WriteString(" ").
|
||||
Writeln(Message{Text: "Synced to remote", Emoji: "📡"}).
|
||||
WriteString(" ").
|
||||
Writeln(Sparkles("Your dotfiles are up to date!"))
|
||||
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
25
cmd/rm.go
25
cmd/rm.go
@@ -1,9 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
@@ -18,8 +20,8 @@ func newRemoveCmd() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath := args[0]
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
lnk := core.NewLnk(core.WithHost(host))
|
||||
w := GetWriter(cmd)
|
||||
|
||||
if err := lnk.Remove(filePath); err != nil {
|
||||
return err
|
||||
@@ -27,14 +29,23 @@ func newRemoveCmd() *cobra.Command {
|
||||
|
||||
basename := filepath.Base(filePath)
|
||||
if host != "" {
|
||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host)
|
||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath)
|
||||
w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk (host: %s)", basename, host), Emoji: "🗑️", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, basename), Emoji: "↩️"}).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(filePath, ColorCyan))
|
||||
} else {
|
||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
|
||||
w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk", basename), Emoji: "🗑️", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s", basename), Emoji: "↩️"}).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(filePath, ColorCyan))
|
||||
}
|
||||
printf(cmd, " 📄 Original file restored\n")
|
||||
return nil
|
||||
|
||||
w.WriteString(" ").
|
||||
Writeln(Message{Text: "Original file restored", Emoji: "📄"})
|
||||
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
|
||||
|
125
cmd/root.go
125
cmd/root.go
@@ -1,10 +1,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
"github.com/yarlson/lnk/internal/fs"
|
||||
"github.com/yarlson/lnk/internal/git"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -14,6 +19,12 @@ var (
|
||||
|
||||
// NewRootCommand creates a new root command (testable)
|
||||
func NewRootCommand() *cobra.Command {
|
||||
var (
|
||||
colors string
|
||||
emoji bool
|
||||
noEmoji bool
|
||||
)
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lnk",
|
||||
Short: "🔗 Dotfiles, linked. No fluff.",
|
||||
@@ -42,8 +53,29 @@ Supports both common configurations, host-specific setups, and bulk operations f
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Handle emoji flag logic
|
||||
emojiEnabled := emoji
|
||||
if noEmoji {
|
||||
emojiEnabled = false
|
||||
}
|
||||
err := SetGlobalConfig(colors, emojiEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Add global flags for output formatting
|
||||
rootCmd.PersistentFlags().StringVar(&colors, "colors", "auto", "when to use colors (auto, always, never)")
|
||||
rootCmd.PersistentFlags().BoolVar(&emoji, "emoji", true, "enable emoji in output")
|
||||
rootCmd.PersistentFlags().BoolVar(&noEmoji, "no-emoji", false, "disable emoji in output")
|
||||
|
||||
// Mark emoji flags as mutually exclusive
|
||||
rootCmd.MarkFlagsMutuallyExclusive("emoji", "no-emoji")
|
||||
|
||||
// Add subcommands
|
||||
rootCmd.AddCommand(newInitCmd())
|
||||
rootCmd.AddCommand(newAddCmd())
|
||||
@@ -66,7 +98,98 @@ func SetVersion(v, bt string) {
|
||||
func Execute() {
|
||||
rootCmd := NewRootCommand()
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
DisplayError(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// DisplayError formats and displays an error with appropriate styling
|
||||
func DisplayError(err error) {
|
||||
w := GetErrorWriter()
|
||||
|
||||
// Handle structured errors from core package
|
||||
var lnkErr *core.LnkError
|
||||
if errors.As(err, &lnkErr) {
|
||||
w.Write(Error(lnkErr.Message))
|
||||
if lnkErr.Path != "" {
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Colored(lnkErr.Path, ColorRed))
|
||||
}
|
||||
if lnkErr.Suggestion != "" {
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Info(lnkErr.Suggestion))
|
||||
}
|
||||
w.WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle structured errors from fs package
|
||||
var pathErr fs.ErrorWithPath
|
||||
if errors.As(err, &pathErr) {
|
||||
w.Write(Error(err.Error()))
|
||||
if pathErr.GetPath() != "" {
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Colored(pathErr.GetPath(), ColorRed))
|
||||
}
|
||||
var suggErr fs.ErrorWithSuggestion
|
||||
if errors.As(err, &suggErr) {
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Info(suggErr.GetSuggestion()))
|
||||
}
|
||||
w.WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle fs errors that only have suggestions
|
||||
var suggErr fs.ErrorWithSuggestion
|
||||
if errors.As(err, &suggErr) {
|
||||
w.Write(Error(err.Error())).
|
||||
WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Info(suggErr.GetSuggestion())).
|
||||
WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle git errors with paths
|
||||
var gitPathErr git.ErrorWithPath
|
||||
if errors.As(err, &gitPathErr) {
|
||||
w.Write(Error(err.Error())).
|
||||
WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Colored(gitPathErr.GetPath(), ColorRed)).
|
||||
WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle git errors with remotes
|
||||
var gitRemoteErr git.ErrorWithRemote
|
||||
if errors.As(err, &gitRemoteErr) {
|
||||
w.Write(Error(err.Error())).
|
||||
WritelnString("").
|
||||
WriteString(" Remote: ").
|
||||
Write(Colored(gitRemoteErr.GetRemote(), ColorCyan)).
|
||||
WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle git errors with reasons
|
||||
var gitReasonErr git.ErrorWithReason
|
||||
if errors.As(err, &gitReasonErr) {
|
||||
w.Write(Error(err.Error()))
|
||||
if gitReasonErr.GetReason() != "" {
|
||||
w.WritelnString("").
|
||||
WriteString(" Reason: ").
|
||||
Write(Colored(gitReasonErr.GetReason(), ColorYellow))
|
||||
}
|
||||
w.WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle generic errors
|
||||
w.Writeln(Error(err.Error()))
|
||||
}
|
||||
|
@@ -1,7 +1,10 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
@@ -36,51 +39,93 @@ func newStatusCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||
w := GetWriter(cmd)
|
||||
|
||||
w.Writeln(Warning("Repository has uncommitted changes")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Remote: ", Emoji: "📡"}).
|
||||
Writeln(Colored(status.Remote, ColorCyan))
|
||||
|
||||
if status.Ahead == 0 && status.Behind == 0 {
|
||||
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||
w.WritelnString("").
|
||||
Write(Info("Run ")).
|
||||
Write(Bold("git add && git commit")).
|
||||
WriteString(" in ").
|
||||
Write(Colored("~/.config/lnk", ColorCyan)).
|
||||
WriteString(" or ").
|
||||
Write(Bold("lnk push")).
|
||||
WritelnString(" to commit changes")
|
||||
return
|
||||
}
|
||||
|
||||
printf(cmd, "\n")
|
||||
w.WritelnString("")
|
||||
displayAheadBehindInfo(cmd, status, true)
|
||||
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||
w.WritelnString("").
|
||||
Write(Info("Run ")).
|
||||
Write(Bold("git add && git commit")).
|
||||
WriteString(" in ").
|
||||
Write(Colored("~/.config/lnk", ColorCyan)).
|
||||
WriteString(" or ").
|
||||
Write(Bold("lnk push")).
|
||||
WritelnString(" to commit changes")
|
||||
}
|
||||
|
||||
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
||||
w := GetWriter(cmd)
|
||||
|
||||
w.Writeln(Success("Repository is up to date")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Synced with ", Emoji: "📡"}).
|
||||
Writeln(Colored(status.Remote, ColorCyan))
|
||||
}
|
||||
|
||||
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||
printf(cmd, "\n")
|
||||
w := GetWriter(cmd)
|
||||
|
||||
w.Writeln(Message{Text: "Repository Status", Emoji: "📊", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Remote: ", Emoji: "📡"}).
|
||||
Writeln(Colored(status.Remote, ColorCyan)).
|
||||
WritelnString("")
|
||||
|
||||
displayAheadBehindInfo(cmd, status, false)
|
||||
|
||||
if status.Ahead > 0 && status.Behind == 0 {
|
||||
printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes\n")
|
||||
w.WritelnString("").
|
||||
Write(Info("Run ")).
|
||||
Write(Bold("lnk push")).
|
||||
WritelnString(" to sync your changes")
|
||||
} else if status.Behind > 0 {
|
||||
printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes\n")
|
||||
w.WritelnString("").
|
||||
Write(Info("Run ")).
|
||||
Write(Bold("lnk pull")).
|
||||
WritelnString(" to get latest changes")
|
||||
}
|
||||
}
|
||||
|
||||
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
||||
w := GetWriter(cmd)
|
||||
|
||||
if status.Ahead > 0 {
|
||||
commitText := getCommitText(status.Ahead)
|
||||
if isDirty {
|
||||
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m (excluding uncommitted changes)\n", status.Ahead, commitText)
|
||||
w.WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}).
|
||||
WritelnString(" (excluding uncommitted changes)")
|
||||
} else {
|
||||
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
||||
w.WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}).
|
||||
WritelnString(" - ready to push")
|
||||
}
|
||||
}
|
||||
|
||||
if status.Behind > 0 {
|
||||
commitText := getCommitText(status.Behind)
|
||||
printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
|
||||
w.WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("%d %s behind", status.Behind, commitText), Emoji: "⬇️", Color: ColorBrightRed, Bold: true}).
|
||||
WriteString(" - run ").
|
||||
Write(Bold("lnk pull")).
|
||||
WritelnString("")
|
||||
}
|
||||
}
|
||||
|
||||
|
12
cmd/utils.go
12
cmd/utils.go
@@ -1,12 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// printf is a helper function to simplify output formatting in commands
|
||||
func printf(cmd *cobra.Command, format string, args ...interface{}) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...)
|
||||
}
|
2
go.mod
2
go.mod
@@ -11,6 +11,6 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
2
go.sum
2
go.sum
@@ -10,6 +10,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
84
internal/core/errors.go
Normal file
84
internal/core/errors.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
|
||||
// LnkError represents a structured error with separate content and formatting hints
|
||||
type LnkError struct {
|
||||
Message string
|
||||
Suggestion string
|
||||
Path string
|
||||
ErrorType string
|
||||
}
|
||||
|
||||
func (e *LnkError) Error() string {
|
||||
if e.Suggestion != "" {
|
||||
return fmt.Sprintf("%s\n %s", e.Message, e.Suggestion)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// Error constructors that separate content from presentation
|
||||
|
||||
func ErrDirectoryContainsManagedFiles(path string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Directory %s already contains managed files", path),
|
||||
Suggestion: "Use 'lnk pull' to update from remote instead of 'lnk init -r'",
|
||||
Path: path,
|
||||
ErrorType: "managed_files_exist",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrDirectoryContainsGitRepo(path string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Directory %s contains an existing Git repository", path),
|
||||
Suggestion: "Please backup or move the existing repository before initializing lnk",
|
||||
Path: path,
|
||||
ErrorType: "git_repo_exists",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrFileAlreadyManaged(path string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("File is already managed by lnk: %s", path),
|
||||
Path: path,
|
||||
ErrorType: "already_managed",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrFileNotManaged(path string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("File is not managed by lnk: %s", path),
|
||||
Path: path,
|
||||
ErrorType: "not_managed",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrRepositoryNotInitialized() error {
|
||||
return &LnkError{
|
||||
Message: "Lnk repository not initialized",
|
||||
Suggestion: "Run 'lnk init' first",
|
||||
ErrorType: "not_initialized",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrBootstrapScriptNotFound(script string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Bootstrap script not found: %s", script),
|
||||
Path: script,
|
||||
ErrorType: "script_not_found",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrBootstrapScriptFailed(err error) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Bootstrap script failed with error: %v", err),
|
||||
ErrorType: "script_failed",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrBootstrapScriptNotExecutable(err error) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Failed to make bootstrap script executable: %v", err),
|
||||
ErrorType: "script_permissions",
|
||||
}
|
||||
}
|
@@ -156,7 +156,7 @@ func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error {
|
||||
// Safety check: prevent data loss by checking for existing managed files
|
||||
if l.HasUserContent() {
|
||||
if !force {
|
||||
return fmt.Errorf("❌ Directory \033[31m%s\033[0m already contains managed files\n 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r'", l.repoPath)
|
||||
return ErrDirectoryContainsManagedFiles(l.repoPath)
|
||||
}
|
||||
}
|
||||
// Clone from remote
|
||||
@@ -176,7 +176,7 @@ func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error {
|
||||
return nil
|
||||
} else {
|
||||
// It's not a lnk repository, error to prevent data loss
|
||||
return fmt.Errorf("❌ Directory \033[31m%s\033[0m contains an existing Git repository\n 💡 Please backup or move the existing repository before initializing lnk", l.repoPath)
|
||||
return ErrDirectoryContainsGitRepo(l.repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ func (l *Lnk) Add(filePath string) error {
|
||||
}
|
||||
for _, item := range managedItems {
|
||||
if item == relativePath {
|
||||
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||
return ErrFileAlreadyManaged(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ func (l *Lnk) AddMultiple(paths []string) error {
|
||||
}
|
||||
for _, item := range managedItems {
|
||||
if item == relativePath {
|
||||
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||
return ErrFileAlreadyManaged(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ func (l *Lnk) Remove(filePath string) error {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||
return ErrFileNotManaged(relativePath)
|
||||
}
|
||||
|
||||
// Get the target path in the repository
|
||||
@@ -551,7 +551,7 @@ type StatusInfo struct {
|
||||
func (l *Lnk) Status() (*StatusInfo, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
return nil, ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
gitStatus, err := l.git.GetStatus()
|
||||
@@ -571,7 +571,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
|
||||
func (l *Lnk) Push(message string) error {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
return ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
// Check if there are any changes
|
||||
@@ -601,7 +601,7 @@ func (l *Lnk) Push(message string) error {
|
||||
func (l *Lnk) Pull() ([]string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
return nil, ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
|
||||
@@ -622,7 +622,7 @@ func (l *Lnk) Pull() ([]string, error) {
|
||||
func (l *Lnk) List() ([]string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
return nil, ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
// Get managed items from .lnk file
|
||||
@@ -822,7 +822,7 @@ func (l *Lnk) writeManagedItems(items []string) error {
|
||||
func (l *Lnk) FindBootstrapScript() (string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return "", fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
return "", ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
// Look for bootstrap.sh - simple, opinionated choice
|
||||
@@ -840,12 +840,12 @@ func (l *Lnk) RunBootstrapScript(scriptName string) error {
|
||||
|
||||
// Verify the script exists
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("❌ Bootstrap script not found: \033[31m%s\033[0m", scriptName)
|
||||
return ErrBootstrapScriptNotFound(scriptName)
|
||||
}
|
||||
|
||||
// Make sure it's executable
|
||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
||||
return fmt.Errorf("❌ Failed to make bootstrap script executable: %w", err)
|
||||
return ErrBootstrapScriptNotExecutable(err)
|
||||
}
|
||||
|
||||
// Run with bash (since we only support bootstrap.sh)
|
||||
@@ -861,7 +861,7 @@ func (l *Lnk) RunBootstrapScript(scriptName string) error {
|
||||
|
||||
// Run the script
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("❌ Bootstrap script failed with error: %w", err)
|
||||
return ErrBootstrapScriptFailed(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -988,7 +988,7 @@ func (l *Lnk) addMultipleWithProgress(paths []string, progress ProgressCallback)
|
||||
}
|
||||
for _, item := range managedItems {
|
||||
if item == relativePath {
|
||||
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||
return ErrFileAlreadyManaged(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,28 +1,7 @@
|
||||
package fs
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ANSI color codes for consistent formatting
|
||||
const (
|
||||
colorReset = "\033[0m"
|
||||
colorRed = "\033[31m"
|
||||
colorBold = "\033[1m"
|
||||
)
|
||||
|
||||
// formatError creates a consistently formatted error message with ❌ prefix
|
||||
func formatError(message string, args ...interface{}) string {
|
||||
return fmt.Sprintf("❌ "+message, args...)
|
||||
}
|
||||
|
||||
// formatPath formats a file path with red color
|
||||
func formatPath(path string) string {
|
||||
return fmt.Sprintf("%s%s%s", colorRed, path, colorReset)
|
||||
}
|
||||
|
||||
// formatCommand formats a command with bold styling
|
||||
func formatCommand(command string) string {
|
||||
return fmt.Sprintf("%s%s%s", colorBold, command, colorReset)
|
||||
}
|
||||
// Structured errors that separate content from presentation
|
||||
// These will be formatted by the cmd package based on user preferences
|
||||
|
||||
// FileNotExistsError represents an error when a file does not exist
|
||||
type FileNotExistsError struct {
|
||||
@@ -31,20 +10,25 @@ type FileNotExistsError struct {
|
||||
}
|
||||
|
||||
func (e *FileNotExistsError) Error() string {
|
||||
return formatError("File or directory not found: %s", formatPath(e.Path))
|
||||
return "File or directory not found: " + e.Path
|
||||
}
|
||||
|
||||
func (e *FileNotExistsError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// GetPath returns the path for formatting purposes
|
||||
func (e *FileNotExistsError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
// FileCheckError represents an error when failing to check a file
|
||||
type FileCheckError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *FileCheckError) Error() string {
|
||||
return formatError("Unable to access file. Please check file permissions and try again.")
|
||||
return "Unable to access file. Please check file permissions and try again."
|
||||
}
|
||||
|
||||
func (e *FileCheckError) Unwrap() error {
|
||||
@@ -57,7 +41,15 @@ type UnsupportedFileTypeError struct {
|
||||
}
|
||||
|
||||
func (e *UnsupportedFileTypeError) Error() string {
|
||||
return formatError("Cannot manage this type of file: %s\n 💡 lnk can only manage regular files and directories", formatPath(e.Path))
|
||||
return "Cannot manage this type of file: " + e.Path
|
||||
}
|
||||
|
||||
func (e *UnsupportedFileTypeError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func (e *UnsupportedFileTypeError) GetSuggestion() string {
|
||||
return "lnk can only manage regular files and directories"
|
||||
}
|
||||
|
||||
func (e *UnsupportedFileTypeError) Unwrap() error {
|
||||
@@ -70,8 +62,15 @@ type NotManagedByLnkError struct {
|
||||
}
|
||||
|
||||
func (e *NotManagedByLnkError) Error() string {
|
||||
return formatError("File is not managed by lnk: %s\n 💡 Use %s to manage this file first",
|
||||
formatPath(e.Path), formatCommand("lnk add"))
|
||||
return "File is not managed by lnk: " + e.Path
|
||||
}
|
||||
|
||||
func (e *NotManagedByLnkError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func (e *NotManagedByLnkError) GetSuggestion() string {
|
||||
return "Use 'lnk add' to manage this file first"
|
||||
}
|
||||
|
||||
func (e *NotManagedByLnkError) Unwrap() error {
|
||||
@@ -84,7 +83,7 @@ type SymlinkReadError struct {
|
||||
}
|
||||
|
||||
func (e *SymlinkReadError) Error() string {
|
||||
return formatError("Unable to read symlink. The file may be corrupted or have invalid permissions.")
|
||||
return "Unable to read symlink. The file may be corrupted or have invalid permissions."
|
||||
}
|
||||
|
||||
func (e *SymlinkReadError) Unwrap() error {
|
||||
@@ -98,7 +97,7 @@ type DirectoryCreationError struct {
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) Error() string {
|
||||
return formatError("Failed to create directory. Please check permissions and available disk space.")
|
||||
return "Failed to create directory. Please check permissions and available disk space."
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) Unwrap() error {
|
||||
@@ -111,9 +110,21 @@ type RelativePathCalculationError struct {
|
||||
}
|
||||
|
||||
func (e *RelativePathCalculationError) Error() string {
|
||||
return formatError("Unable to create symlink due to path configuration issues. Please check file locations.")
|
||||
return "Unable to create symlink due to path configuration issues. Please check file locations."
|
||||
}
|
||||
|
||||
func (e *RelativePathCalculationError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// ErrorWithPath is an interface for errors that have an associated file path
|
||||
type ErrorWithPath interface {
|
||||
error
|
||||
GetPath() string
|
||||
}
|
||||
|
||||
// ErrorWithSuggestion is an interface for errors that provide helpful suggestions
|
||||
type ErrorWithSuggestion interface {
|
||||
error
|
||||
GetSuggestion() string
|
||||
}
|
||||
|
@@ -1,29 +1,7 @@
|
||||
package git
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ANSI color codes for consistent formatting
|
||||
const (
|
||||
colorReset = "\033[0m"
|
||||
colorBold = "\033[1m"
|
||||
colorGreen = "\033[32m"
|
||||
colorYellow = "\033[33m"
|
||||
)
|
||||
|
||||
// formatError creates a consistently formatted error message with ❌ prefix
|
||||
func formatError(message string, args ...interface{}) string {
|
||||
return fmt.Sprintf("❌ "+message, args...)
|
||||
}
|
||||
|
||||
// formatURL formats a URL with styling
|
||||
func formatURL(url string) string {
|
||||
return fmt.Sprintf("%s%s%s", colorBold, url, colorReset)
|
||||
}
|
||||
|
||||
// formatRemote formats a remote name with styling
|
||||
func formatRemote(remote string) string {
|
||||
return fmt.Sprintf("%s%s%s", colorGreen, remote, colorReset)
|
||||
}
|
||||
// Structured errors that separate content from presentation
|
||||
// These will be formatted by the cmd package based on user preferences
|
||||
|
||||
// GitInitError represents an error during git initialization
|
||||
type GitInitError struct {
|
||||
@@ -32,7 +10,7 @@ type GitInitError struct {
|
||||
}
|
||||
|
||||
func (e *GitInitError) Error() string {
|
||||
return formatError("Failed to initialize git repository. Please ensure git is installed and try again.")
|
||||
return "Failed to initialize git repository. Please ensure git is installed and try again."
|
||||
}
|
||||
|
||||
func (e *GitInitError) Unwrap() error {
|
||||
@@ -45,7 +23,7 @@ type BranchSetupError struct {
|
||||
}
|
||||
|
||||
func (e *BranchSetupError) Error() string {
|
||||
return formatError("Failed to set up the default branch. Please check your git installation.")
|
||||
return "Failed to set up the default branch. Please check your git installation."
|
||||
}
|
||||
|
||||
func (e *BranchSetupError) Unwrap() error {
|
||||
@@ -60,8 +38,19 @@ type RemoteExistsError struct {
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) Error() string {
|
||||
return formatError("Remote %s is already configured with a different repository (%s). Cannot add %s.",
|
||||
formatRemote(e.Remote), formatURL(e.ExistingURL), formatURL(e.NewURL))
|
||||
return "Remote " + e.Remote + " is already configured with a different repository (" + e.ExistingURL + "). Cannot add " + e.NewURL + "."
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) GetRemote() string {
|
||||
return e.Remote
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) GetExistingURL() string {
|
||||
return e.ExistingURL
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) GetNewURL() string {
|
||||
return e.NewURL
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) Unwrap() error {
|
||||
@@ -79,24 +68,28 @@ func (e *GitCommandError) Error() string {
|
||||
// Provide user-friendly messages based on common command types
|
||||
switch e.Command {
|
||||
case "add":
|
||||
return formatError("Failed to stage files for commit. Please check file permissions and try again.")
|
||||
return "Failed to stage files for commit. Please check file permissions and try again."
|
||||
case "commit":
|
||||
return formatError("Failed to create commit. Please ensure you have staged changes and try again.")
|
||||
return "Failed to create commit. Please ensure you have staged changes and try again."
|
||||
case "remote add":
|
||||
return formatError("Failed to add remote repository. Please check the repository URL and try again.")
|
||||
return "Failed to add remote repository. Please check the repository URL and try again."
|
||||
case "rm":
|
||||
return formatError("Failed to remove file from git tracking. Please check if the file exists and try again.")
|
||||
return "Failed to remove file from git tracking. Please check if the file exists and try again."
|
||||
case "log":
|
||||
return formatError("Failed to retrieve commit history.")
|
||||
return "Failed to retrieve commit history."
|
||||
case "remote":
|
||||
return formatError("Failed to retrieve remote repository information.")
|
||||
return "Failed to retrieve remote repository information."
|
||||
case "clone":
|
||||
return formatError("Failed to clone repository. Please check the repository URL and your network connection.")
|
||||
return "Failed to clone repository. Please check the repository URL and your network connection."
|
||||
default:
|
||||
return formatError("Git operation failed. Please check your repository state and try again.")
|
||||
return "Git operation failed. Please check your repository state and try again."
|
||||
}
|
||||
}
|
||||
|
||||
func (e *GitCommandError) GetCommand() string {
|
||||
return e.Command
|
||||
}
|
||||
|
||||
func (e *GitCommandError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
@@ -105,7 +98,7 @@ func (e *GitCommandError) Unwrap() error {
|
||||
type NoRemoteError struct{}
|
||||
|
||||
func (e *NoRemoteError) Error() string {
|
||||
return formatError("No remote repository is configured. Please add a remote repository first.")
|
||||
return "No remote repository is configured. Please add a remote repository first."
|
||||
}
|
||||
|
||||
func (e *NoRemoteError) Unwrap() error {
|
||||
@@ -119,7 +112,11 @@ type RemoteNotFoundError struct {
|
||||
}
|
||||
|
||||
func (e *RemoteNotFoundError) Error() string {
|
||||
return formatError("Remote repository %s is not configured.", formatRemote(e.Remote))
|
||||
return "Remote repository " + e.Remote + " is not configured."
|
||||
}
|
||||
|
||||
func (e *RemoteNotFoundError) GetRemote() string {
|
||||
return e.Remote
|
||||
}
|
||||
|
||||
func (e *RemoteNotFoundError) Unwrap() error {
|
||||
@@ -133,7 +130,7 @@ type GitConfigError struct {
|
||||
}
|
||||
|
||||
func (e *GitConfigError) Error() string {
|
||||
return formatError("Failed to configure git settings. Please check your git installation.")
|
||||
return "Failed to configure git settings. Please check your git installation."
|
||||
}
|
||||
|
||||
func (e *GitConfigError) Unwrap() error {
|
||||
@@ -146,7 +143,7 @@ type UncommittedChangesError struct {
|
||||
}
|
||||
|
||||
func (e *UncommittedChangesError) Error() string {
|
||||
return formatError("Failed to check repository status. Please verify your git repository is valid.")
|
||||
return "Failed to check repository status. Please verify your git repository is valid."
|
||||
}
|
||||
|
||||
func (e *UncommittedChangesError) Unwrap() error {
|
||||
@@ -160,7 +157,11 @@ type DirectoryRemovalError struct {
|
||||
}
|
||||
|
||||
func (e *DirectoryRemovalError) Error() string {
|
||||
return formatError("Failed to prepare directory for operation. Please check directory permissions.")
|
||||
return "Failed to prepare directory for operation. Please check directory permissions."
|
||||
}
|
||||
|
||||
func (e *DirectoryRemovalError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func (e *DirectoryRemovalError) Unwrap() error {
|
||||
@@ -174,7 +175,11 @@ type DirectoryCreationError struct {
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) Error() string {
|
||||
return formatError("Failed to create directory. Please check permissions and available disk space.")
|
||||
return "Failed to create directory. Please check permissions and available disk space."
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) Unwrap() error {
|
||||
@@ -190,9 +195,13 @@ type PushError struct {
|
||||
|
||||
func (e *PushError) Error() string {
|
||||
if e.Reason != "" {
|
||||
return formatError("Cannot push changes: %s", e.Reason)
|
||||
return "Cannot push changes: " + e.Reason
|
||||
}
|
||||
return formatError("Failed to push changes to remote repository. Please check your network connection and repository permissions.")
|
||||
return "Failed to push changes to remote repository. Please check your network connection and repository permissions."
|
||||
}
|
||||
|
||||
func (e *PushError) GetReason() string {
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
func (e *PushError) Unwrap() error {
|
||||
@@ -208,11 +217,33 @@ type PullError struct {
|
||||
|
||||
func (e *PullError) Error() string {
|
||||
if e.Reason != "" {
|
||||
return formatError("Cannot pull changes: %s", e.Reason)
|
||||
return "Cannot pull changes: " + e.Reason
|
||||
}
|
||||
return formatError("Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts.")
|
||||
return "Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts."
|
||||
}
|
||||
|
||||
func (e *PullError) GetReason() string {
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
func (e *PullError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// ErrorWithPath is an interface for git errors that have an associated file path
|
||||
type ErrorWithPath interface {
|
||||
error
|
||||
GetPath() string
|
||||
}
|
||||
|
||||
// ErrorWithRemote is an interface for git errors that involve a remote
|
||||
type ErrorWithRemote interface {
|
||||
error
|
||||
GetRemote() string
|
||||
}
|
||||
|
||||
// ErrorWithReason is an interface for git errors that have a specific reason
|
||||
type ErrorWithReason interface {
|
||||
error
|
||||
GetReason() string
|
||||
}
|
||||
|
Reference in New Issue
Block a user