12 Commits

Author SHA1 Message Date
dependabot[bot]
f09f96a729 chore(deps): bump github.com/stretchr/testify from 1.11.0 to 1.11.1
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.0 to 1.11.1.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 17:48:10 +00:00
Yar Kravtsov
430619b7e8 Merge pull request #20 from yarlson/dependabot/go_modules/github.com/stretchr/testify-1.11.0
chore(deps): bump github.com/stretchr/testify from 1.10.0 to 1.11.0
2025-08-27 08:09:21 +03:00
Yar Kravtsov
48535a68d3 Merge pull request #19 from yarlson/dependabot/github_actions/actions-a331d3ec2d
chore(deps): bump actions/checkout from 4 to 5 in the actions group
2025-08-27 08:08:52 +03:00
dependabot[bot]
0a3e522457 chore(deps): bump github.com/stretchr/testify from 1.10.0 to 1.11.0
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.10.0 to 1.11.0.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.10.0...v1.11.0)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-25 18:20:53 +00:00
dependabot[bot]
b94870b91a chore(deps): bump actions/checkout from 4 to 5 in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-25 15:40:47 +00:00
Yar Kravtsov
2aac515606 docs(README): clarify color modes and emoji flags with improved formatting 2025-08-13 21:36:40 +03:00
Yar Kravtsov
30ab78d506 Merge pull request #18 from yarlson/no-color
feat(output): implement no-color and no-emoji flags for customizable output
2025-08-03 14:36:44 +03:00
Yar Kravtsov
7f10e1ce8a 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
2025-08-03 14:33:44 +03:00
Yar Kravtsov
57839c795e Merge pull request #15 from yarlson/fix/dynamic-branch-detection
fix: remove hardcoded branch names from push/pull operations
2025-08-01 06:49:24 +03:00
Yar Kravtsov
dc524607fa fix: remove hardcoded branch names from push/pull operations
- Remove hardcoded "main" branch from git push and pull commands
- Let Git automatically detect and use current branch
- Add comprehensive tests for different branch names (main, master, develop)
- Fixes GitHub issue #14 where operations failed on repos using "master"
2025-08-01 06:45:56 +03:00
Yar Kravtsov
9bf2e70d13 docs: remove RELEASE.md in favor of automated process 2025-07-30 10:57:32 +03:00
Yar Kravtsov
65db5fe738 Merge pull request #13 from yarlson/force
fix(init): prevent data loss when reinitializing with existing content
2025-07-30 10:42:54 +03:00
26 changed files with 1400 additions and 439 deletions

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
@@ -55,7 +55,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
@@ -72,7 +72,7 @@ jobs:
needs: [test, lint]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

1
.gitignore vendored
View File

@@ -46,4 +46,3 @@ desktop.ini
goreleaser/
*.md
!/README.md
!/RELEASE.md

View File

@@ -325,6 +325,74 @@ 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

View File

@@ -1,190 +0,0 @@
# Release Process
This document describes how to create releases for the lnk project using GoReleaser.
## Prerequisites
- Push access to the main repository
- Git tags pushed to GitHub trigger releases automatically
- GoReleaser is configured in `.goreleaser.yml`
- GitHub Actions will handle the release process
- Access to the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap repository
- **Personal Access Token** set up as `HOMEBREW_TAP_TOKEN` secret (see Setup section)
## Setup (One-time)
### GitHub Personal Access Token
For GoReleaser to update the Homebrew formula, you need a Personal Access Token:
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
2. Click "Generate new token" → "Generate new token (classic)"
3. Name: "GoReleaser Homebrew Access"
4. Scopes: Select `repo` (Full control of private repositories)
5. Generate and copy the token
6. In your `yarlson/lnk` repository:
- Go to Settings → Secrets and variables → Actions
- Add new repository secret: `HOMEBREW_TAP_TOKEN`
- Paste the token as the value
This allows GoReleaser to automatically update the Homebrew formula in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk).
## Creating a Release
### 1. Ensure everything is ready
```bash
# Run all quality checks
make check
# Test GoReleaser configuration
make goreleaser-check
# Test build process
make goreleaser-snapshot
```
### 2. Create and push a version tag
```bash
# Create a new tag (replace x.y.z with actual version)
git tag -a v1.0.0 -m "Release v1.0.0"
# Push the tag to trigger the release
git push origin v1.0.0
```
### 3. Monitor the release
- GitHub Actions will automatically build and release when the tag is pushed
- Check the [Actions tab](https://github.com/yarlson/lnk/actions) for build status
- The release will appear in [GitHub Releases](https://github.com/yarlson/lnk/releases)
- The Homebrew formula will be automatically updated in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk)
## What GoReleaser Does
1. **Builds binaries** for multiple platforms:
- Linux (amd64, arm64)
- macOS (amd64, arm64)
- Windows (amd64)
2. **Creates archives** with consistent naming:
- `lnk_Linux_x86_64.tar.gz`
- `lnk_Darwin_arm64.tar.gz`
- etc.
3. **Generates checksums** for verification
4. **Creates GitHub release** with:
- Automatic changelog from conventional commits
- Installation instructions
- Download links for all platforms
5. **Updates Homebrew formula** automatically in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap
## Manual Release (if needed)
If you need to create a release manually:
```bash
# Export GitHub token
export GITHUB_TOKEN="your_token_here"
# Create release (requires a git tag)
goreleaser release --clean
```
## Testing Releases Locally
```bash
# Test the build process without releasing
make goreleaser-snapshot
# Built artifacts will be in dist/
ls -la dist/
# Test a binary
./dist/lnk_<platform>/lnk --version
```
## Installation Methods
After a release is published, users can install lnk using multiple methods:
### 1. Shell Script (Recommended)
```bash
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```
### 2. Homebrew (macOS/Linux)
```bash
brew tap yarlson/lnk
brew install lnk
```
### 3. Manual Download
```bash
# Download from GitHub releases
wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz
tar -xzf lnk_Linux_x86_64.tar.gz
sudo mv lnk /usr/local/bin/
```
## Version Numbering
We use [Semantic Versioning](https://semver.org/):
- `v1.0.0` - Major release (breaking changes)
- `v1.1.0` - Minor release (new features, backward compatible)
- `v1.1.1` - Patch release (bug fixes)
## Changelog
GoReleaser automatically generates changelogs from git commits using conventional commit format:
- `feat:` - New features
- `fix:` - Bug fixes
- `docs:` - Documentation changes (excluded from changelog)
- `test:` - Test changes (excluded from changelog)
- `ci:` - CI changes (excluded from changelog)
## Homebrew Tap
The Homebrew formula is automatically maintained in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) repository. When a new release is created:
1. GoReleaser automatically creates/updates the formula
2. The formula is committed to the tap repository
3. Users can immediately install the new version via `brew install yarlson/lnk/lnk`
## Troubleshooting
### Release failed to create
1. Check that the tag follows the format `vX.Y.Z`
2. Ensure GitHub Actions has proper permissions
3. Check the Actions log for detailed error messages
### Missing binaries in release
1. Verify GoReleaser configuration: `make goreleaser-check`
2. Test build locally: `make goreleaser-snapshot`
3. Check the build matrix in `.goreleaser.yml`
### Changelog is empty
1. Ensure commits follow conventional commit format
2. Check that there are commits since the last tag
3. Verify changelog configuration in `.goreleaser.yml`
### Homebrew formula not updated
1. Check that the GITHUB_TOKEN has access to the homebrew-lnk repository
2. Verify the repository name and owner in `.goreleaser.yml`
3. Check the release workflow logs for Homebrew-related errors
4. Ensure the homebrew-lnk repository exists and is accessible

View File

@@ -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()
},
}

View File

@@ -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()
},
}
}

View File

@@ -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()
}
},
}

View File

@@ -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
View 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
View 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)
}
}
})
}
}

View File

@@ -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()
},
}

View File

@@ -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()
},
}
}

View File

@@ -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()
},
}

View File

@@ -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()))
}

View File

@@ -1686,6 +1686,125 @@ func (suite *CLITestSuite) TestInitCommand_HelpText_ExplainsDataProtection() {
suite.Contains(output, "overwrite existing content", "Should mention overwrite risk")
}
// TestPushPullWithDifferentBranches tests push/pull operations with different default branch names
func (suite *CLITestSuite) TestPushPullWithDifferentBranches() {
testCases := []struct {
name string
branchName string
setupRemote func(remoteDir string) error
}{
{
name: "master branch",
branchName: "master",
setupRemote: func(remoteDir string) error {
cmd := exec.Command("git", "init", "--bare", "--initial-branch=master")
cmd.Dir = remoteDir
return cmd.Run()
},
},
{
name: "main branch",
branchName: "main",
setupRemote: func(remoteDir string) error {
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
cmd.Dir = remoteDir
return cmd.Run()
},
},
{
name: "custom branch",
branchName: "develop",
setupRemote: func(remoteDir string) error {
cmd := exec.Command("git", "init", "--bare", "--initial-branch=develop")
cmd.Dir = remoteDir
return cmd.Run()
},
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
// Create a separate temp directory for this test case
testDir, err := os.MkdirTemp("", "lnk-push-pull-test-*")
suite.Require().NoError(err)
defer func() { _ = os.RemoveAll(testDir) }()
// Save current dir and change to test dir
originalDir, err := os.Getwd()
suite.Require().NoError(err)
defer func() { _ = os.Chdir(originalDir) }()
err = os.Chdir(testDir)
suite.Require().NoError(err)
// Set HOME to test directory
suite.T().Setenv("HOME", testDir)
suite.T().Setenv("XDG_CONFIG_HOME", testDir)
// Create remote repository
remoteDir := filepath.Join(testDir, "remote.git")
err = os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
err = tc.setupRemote(remoteDir)
suite.Require().NoError(err)
// Initialize lnk with remote
err = suite.runCommand("init", "--remote", remoteDir)
suite.Require().NoError(err)
// Switch to the test branch if not main/master (since init creates main by default)
if tc.branchName != "main" {
lnkDir := filepath.Join(testDir, "lnk")
cmd := exec.Command("git", "checkout", "-b", tc.branchName)
cmd.Dir = lnkDir
_, err = cmd.CombinedOutput()
suite.Require().NoError(err)
}
// Add a test file
testFile := filepath.Join(testDir, ".testrc")
err = os.WriteFile(testFile, []byte("test config"), 0644)
suite.Require().NoError(err)
err = suite.runCommand("add", testFile)
suite.Require().NoError(err)
// Test push operation
err = suite.runCommand("push", "test push with "+tc.branchName)
suite.Require().NoError(err, "Push should work with %s branch", tc.branchName)
// Create another test directory to simulate pulling from another machine
pullTestDir, err := os.MkdirTemp("", "lnk-pull-test-*")
suite.Require().NoError(err)
defer func() { _ = os.RemoveAll(pullTestDir) }()
err = os.Chdir(pullTestDir)
suite.Require().NoError(err)
// Set HOME for pull test
suite.T().Setenv("HOME", pullTestDir)
suite.T().Setenv("XDG_CONFIG_HOME", pullTestDir)
// Clone and test pull
err = suite.runCommand("init", "--remote", remoteDir)
suite.Require().NoError(err)
err = suite.runCommand("pull")
suite.Require().NoError(err, "Pull should work with %s branch", tc.branchName)
// Verify the file was pulled correctly
lnkDir := filepath.Join(pullTestDir, "lnk")
pulledFile := filepath.Join(lnkDir, ".testrc")
suite.FileExists(pulledFile, "File should exist after pull with %s branch", tc.branchName)
content, err := os.ReadFile(pulledFile)
suite.Require().NoError(err)
suite.Equal("test config", string(content), "File content should match after pull with %s branch", tc.branchName)
})
}
}
func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLITestSuite))
}

View File

@@ -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("")
}
}

View File

@@ -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...)
}

4
go.mod
View File

@@ -4,13 +4,13 @@ go 1.24
require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
)
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
)

7
go.sum
View File

@@ -8,10 +8,11 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

84
internal/core/errors.go Normal file
View 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",
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -437,7 +437,7 @@ func (g *Git) Push() error {
return &PushError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "push", "-u", "origin", "main")
cmd := exec.Command("git", "push", "-u", "origin")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
@@ -456,7 +456,7 @@ func (g *Git) Pull() error {
return &PullError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "pull", "origin", "main")
cmd := exec.Command("git", "pull", "origin")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()