3 Commits

Author SHA1 Message Date
Yar Kravtsov
61a9cc8c88 feat: enhance CLI output with colorful and informative messages 2025-05-24 10:13:00 +03:00
Yar Kravtsov
1e2728fe33 feat(install): enhance installer script robustness and flexibility 2025-05-24 09:56:51 +03:00
Yar Kravtsov
13657a8142 feat(release): enhance Homebrew integration and security 2025-05-24 09:45:31 +03:00
14 changed files with 213 additions and 68 deletions

View File

@@ -32,6 +32,4 @@ jobs:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# If you have a personal access token for cross-repo access, use it instead:
# GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}

View File

@@ -15,15 +15,21 @@ lnk push "setup"
```bash
# Quick install (recommended)
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```
```bash
# Homebrew (macOS/Linux)
brew tap yarlson/lnk
brew install lnk
```
```bash
# Manual download
wget https://github.com/yarlson/lnk/releases/latest/download/lnk-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64
chmod +x lnk-* && sudo mv lnk-* /usr/local/bin/lnk
```
```bash
# From source
git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/
```

View File

@@ -9,6 +9,25 @@ This document describes how to create releases for the lnk project using GoRelea
- 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
@@ -45,11 +64,13 @@ git push origin v1.0.0
## 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.
@@ -57,6 +78,7 @@ git push origin v1.0.0
3. **Generates checksums** for verification
4. **Creates GitHub release** with:
- Automatic changelog from conventional commits
- Installation instructions
- Download links for all platforms
@@ -93,17 +115,20 @@ ls -la dist/
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

View File

@@ -9,10 +9,11 @@ import (
)
var addCmd = &cobra.Command{
Use: "add <file>",
Short: "Add a file to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
Args: cobra.ExactArgs(1),
Use: "add <file>",
Short: "✨ Add a file to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
@@ -22,7 +23,9 @@ var addCmd = &cobra.Command{
}
basename := filepath.Base(filePath)
fmt.Printf("Added %s to lnk\n", basename)
fmt.Printf("✨ \033[1mAdded %s to lnk\033[0m\n", basename)
fmt.Printf(" 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
fmt.Printf(" 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
return nil
},
}

View File

@@ -8,9 +8,10 @@ import (
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
Use: "init",
Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote")
@@ -20,9 +21,18 @@ var initCmd = &cobra.Command{
}
if remote != "" {
fmt.Printf("Initialized lnk repository by cloning: %s\n", remote)
fmt.Printf("🎯 \033[1mInitialized lnk repository\033[0m\n")
fmt.Printf(" 📦 Cloned from: \033[36m%s\033[0m\n", remote)
fmt.Printf(" 📁 Location: \033[90m~/.config/lnk\033[0m\n")
fmt.Printf("\n💡 \033[33mNext steps:\033[0m\n")
fmt.Printf(" • Run \033[1mlnk pull\033[0m to restore symlinks\n")
fmt.Printf(" • Use \033[1mlnk add <file>\033[0m to manage new files\n")
} else {
fmt.Println("Initialized lnk repository")
fmt.Printf("🎯 \033[1mInitialized empty lnk repository\033[0m\n")
fmt.Printf(" 📁 Location: \033[90m~/.config/lnk\033[0m\n")
fmt.Printf("\n💡 \033[33mNext steps:\033[0m\n")
fmt.Printf(" • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
fmt.Printf(" • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
}
return nil

View File

@@ -8,9 +8,10 @@ import (
)
var pullCmd = &cobra.Command{
Use: "pull",
Short: "Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
Use: "pull",
Short: "⬇️ Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
restored, err := lnk.Pull()
@@ -19,12 +20,20 @@ var pullCmd = &cobra.Command{
}
if len(restored) > 0 {
fmt.Printf("Successfully pulled changes and restored %d symlink(s):\n", len(restored))
for _, file := range restored {
fmt.Printf(" - %s\n", file)
fmt.Printf("⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
fmt.Printf(" 🔗 Restored \033[1m%d symlink", len(restored))
if len(restored) > 1 {
fmt.Printf("s")
}
fmt.Printf("\033[0m:\n")
for _, file := range restored {
fmt.Printf(" ✨ \033[36m%s\033[0m\n", file)
}
fmt.Printf("\n 🎉 Your dotfiles are synced and ready!\n")
} else {
fmt.Println("Successfully pulled changes (no symlinks needed restoration)")
fmt.Printf("⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
fmt.Printf(" ✅ All symlinks already in place\n")
fmt.Printf(" 🎉 Everything is up to date!\n")
}
return nil

View File

@@ -8,10 +8,11 @@ import (
)
var pushCmd = &cobra.Command{
Use: "push [message]",
Short: "Push local changes to remote repository",
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1),
Use: "push [message]",
Short: "🚀 Push local changes to remote repository",
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
message := "lnk: sync configuration files"
if len(args) > 0 {
@@ -23,7 +24,10 @@ var pushCmd = &cobra.Command{
return fmt.Errorf("failed to push changes: %w", err)
}
fmt.Println("Successfully pushed changes to remote")
fmt.Printf("🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
fmt.Printf(" 💾 Commit: \033[90m%s\033[0m\n", message)
fmt.Printf(" 📡 Synced to remote\n")
fmt.Printf(" ✨ Your dotfiles are up to date!\n")
return nil
},
}

View File

@@ -9,10 +9,11 @@ import (
)
var rmCmd = &cobra.Command{
Use: "rm <file>",
Short: "Remove a file from lnk management",
Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1),
Use: "rm <file>",
Short: "🗑️ Remove a file from lnk management",
Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
@@ -22,7 +23,9 @@ var rmCmd = &cobra.Command{
}
basename := filepath.Base(filePath)
fmt.Printf("Removed %s from lnk\n", basename)
fmt.Printf("🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
fmt.Printf(" ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
fmt.Printf(" 📄 Original file restored\n")
return nil
},
}

View File

@@ -14,8 +14,21 @@ var (
var rootCmd = &cobra.Command{
Use: "lnk",
Short: "Dotfiles, linked. No fluff.",
Long: "Lnk is a minimalist CLI tool for managing dotfiles using symlinks and Git.",
Short: "🔗 Dotfiles, linked. No fluff.",
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
That's it.
✨ Examples:
lnk init # Fresh start
lnk init -r <repo-url> # Clone existing dotfiles
lnk add ~/.vimrc ~/.bashrc # Start managing files
lnk push "setup complete" # Sync to remote
lnk pull # Get latest changes
🎯 Simple, fast, and Git-native.`,
SilenceUsage: true,
}
// SetVersion sets the version information for the CLI

View File

@@ -8,9 +8,10 @@ import (
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
Use: "status",
Short: "📊 Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
status, err := lnk.Status()
@@ -19,13 +20,32 @@ var statusCmd = &cobra.Command{
}
if status.Ahead == 0 && status.Behind == 0 {
fmt.Println("Repository is up to date with remote")
fmt.Printf("✅ \033[1;32mRepository is up to date\033[0m\n")
fmt.Printf(" 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
} else {
fmt.Printf("📊 \033[1mRepository Status\033[0m\n")
fmt.Printf(" 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
fmt.Printf("\n")
if status.Ahead > 0 {
fmt.Printf("Your branch is ahead of '%s' by %d commit(s)\n", status.Remote, status.Ahead)
commitText := "commit"
if status.Ahead > 1 {
commitText = "commits"
}
fmt.Printf(" ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
}
if status.Behind > 0 {
fmt.Printf("Your branch is behind '%s' by %d commit(s)\n", status.Remote, status.Behind)
commitText := "commit"
if status.Behind > 1 {
commitText = "commits"
}
fmt.Printf(" ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
}
if status.Ahead > 0 && status.Behind == 0 {
fmt.Printf("\n💡 Run \033[1mlnk push\033[0m to sync your changes")
} else if status.Behind > 0 {
fmt.Printf("\n💡 Run \033[1mlnk pull\033[0m to get latest changes")
}
}

View File

@@ -17,6 +17,9 @@ REPO="yarlson/lnk"
INSTALL_DIR="/usr/local/bin"
BINARY_NAME="lnk"
# Fallback version if redirect fails
FALLBACK_VERSION="v0.0.2"
# Detect OS and architecture
detect_platform() {
local os arch
@@ -45,11 +48,44 @@ detect_platform() {
echo "${os}_${arch}"
}
# Get the latest release version
# Get latest version by following redirect
get_latest_version() {
curl -s "https://api.github.com/repos/${REPO}/releases/latest" | \
grep '"tag_name":' | \
sed -E 's/.*"([^"]+)".*/\1/'
echo -e "${BLUE}Getting latest release version...${NC}" >&2
# Get redirect location from releases/latest
local redirect_url
redirect_url=$(curl -s -I "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/\r$//' | cut -d' ' -f2-)
if [ -z "$redirect_url" ]; then
echo -e "${YELLOW}⚠ Could not get redirect URL, using fallback version ${FALLBACK_VERSION}${NC}" >&2
echo "$FALLBACK_VERSION"
return 0
fi
# Extract version from redirect URL (format: https://github.com/user/repo/releases/tag/v1.2.3)
local version
version=$(echo "$redirect_url" | sed -E 's|.*/releases/tag/([^/]*)\s*$|\1|')
if [ -z "$version" ] || [ "$version" = "$redirect_url" ]; then
echo -e "${YELLOW}⚠ Could not parse version from redirect URL: $redirect_url${NC}" >&2
echo -e "${YELLOW}Using fallback version ${FALLBACK_VERSION}${NC}" >&2
echo "$FALLBACK_VERSION"
return 0
fi
echo "$version"
}
# Get version to install
get_version() {
# Allow override via environment variable
if [ -n "$LNK_VERSION" ]; then
echo "$LNK_VERSION"
elif [ -n "$1" ]; then
echo "$1"
else
get_latest_version
fi
}
# Download and install
@@ -59,14 +95,9 @@ install_lnk() {
echo -e "${BLUE}🔗 Installing lnk...${NC}"
platform=$(detect_platform)
version=$(get_latest_version)
version=$(get_version "$1")
if [ -z "$version" ]; then
echo -e "${RED}Error: Failed to get latest version${NC}"
exit 1
fi
echo -e "${BLUE}Latest version: ${version}${NC}"
echo -e "${BLUE}Version: ${version}${NC}"
echo -e "${BLUE}Platform: ${platform}${NC}"
# Download URL
@@ -82,6 +113,16 @@ install_lnk() {
# Download the binary
if ! curl -sL "$url" -o "$filename"; then
echo -e "${RED}Error: Failed to download ${url}${NC}"
echo -e "${YELLOW}Please check if the release exists at: https://github.com/${REPO}/releases/tag/${version}${NC}"
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
exit 1
fi
# Check if we got an HTML error page instead of the binary
if file "$filename" 2>/dev/null | grep -q "HTML"; then
echo -e "${RED}Error: Downloaded file appears to be an HTML page (404 error)${NC}"
echo -e "${YELLOW}The release ${version} might not exist.${NC}"
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
exit 1
fi
@@ -107,20 +148,33 @@ install_lnk() {
echo -e "${GREEN}✅ lnk installed successfully!${NC}"
echo -e "${GREEN}Run 'lnk --help' to get started.${NC}"
# Test the installation
if command -v lnk >/dev/null 2>&1; then
echo -e "${GREEN}Installed version: $(lnk --version)${NC}"
fi
}
# Check if running with --help
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo "Lnk installer script"
echo ""
echo "Usage: curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
echo "Usage:"
echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash -s v0.0.1"
echo " LNK_VERSION=v0.0.1 curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
echo ""
echo "This script will:"
echo " 1. Detect your OS and architecture"
echo " 2. Download the latest lnk release"
echo " 3. Install it to /usr/local/bin (requires sudo)"
echo " 2. Auto-detect the latest release by following GitHub redirects"
echo " 3. Download and install to /usr/local/bin (requires sudo)"
echo ""
echo "Environment variables:"
echo " LNK_VERSION - Specify version to install (e.g., v0.0.1)"
echo ""
echo "Manual installation: https://github.com/yarlson/lnk/releases"
exit 0
fi
# Run the installer
install_lnk
install_lnk "$1"

View File

@@ -68,7 +68,7 @@ func (l *Lnk) InitWithRemote(remoteURL string) error {
return nil
} else {
// It's not a lnk repository, error to prevent data loss
return fmt.Errorf("directory %s appears to contain an existing Git repository that is not managed by lnk. Please backup or move the existing repository before initializing lnk", l.repoPath)
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)
}
}
@@ -282,7 +282,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 - run 'lnk init' first")
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
}
gitStatus, err := l.git.GetStatus()
@@ -301,7 +301,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 - run 'lnk init' first")
return fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
}
// Check if there are any changes
@@ -335,7 +335,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 - run 'lnk init' first")
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
}
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes)

View File

@@ -21,14 +21,14 @@ func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", filePath)
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
}
return fmt.Errorf("failed to stat file: %w", err)
return fmt.Errorf("❌ Failed to check file: %w", err)
}
// Allow both regular files and directories
if !info.Mode().IsRegular() && !info.IsDir() {
return fmt.Errorf("only regular files and directories are supported: %s", filePath)
return fmt.Errorf("❌ Only regular files and directories are supported: \033[31m%s\033[0m", filePath)
}
return nil
@@ -40,14 +40,14 @@ func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", filePath)
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
}
return fmt.Errorf("failed to stat file: %w", err)
return fmt.Errorf("❌ Failed to check file: %w", err)
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink == 0 {
return fmt.Errorf("file is not managed by lnk: %s", filePath)
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m\n 💡 Use \033[1mlnk add\033[0m to manage this file first", filePath)
}
// Check if symlink points to the repository
@@ -67,7 +67,7 @@ func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error
// Check if target is inside the repository
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
return fmt.Errorf("file is not managed by lnk: %s", filePath)
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", filePath)
}
return nil

View File

@@ -162,7 +162,7 @@ func (suite *LnkIntegrationTestSuite) TestAddNonexistentFile() {
err = suite.lnk.Add("/nonexistent/file")
suite.Error(err)
suite.Contains(err.Error(), "file does not exist")
suite.Contains(err.Error(), "File does not exist")
}
func (suite *LnkIntegrationTestSuite) TestAddDirectory() {
@@ -406,7 +406,7 @@ func (suite *LnkIntegrationTestSuite) TestRemoveNonSymlink() {
err = suite.lnk.Remove(testFile)
suite.Error(err)
suite.Contains(err.Error(), "file is not managed by lnk")
suite.Contains(err.Error(), "File is not managed by lnk")
}
func (suite *LnkIntegrationTestSuite) TestXDGConfigHomeFallback() {
@@ -550,7 +550,7 @@ func (suite *LnkIntegrationTestSuite) TestInitWithNonLnkRepo() {
// Now try to init lnk - should error to protect existing repo
err = suite.lnk.Init()
suite.Error(err)
suite.Contains(err.Error(), "appears to contain an existing Git repository")
suite.Contains(err.Error(), "contains an existing Git repository")
// Verify the original file is still there
suite.FileExists(testFile)