mirror of
https://github.com/yarlson/lnk.git
synced 2025-08-30 17:59:47 +02:00
feat(multihost): add support for host-specific configurations
Implement multihost functionality allowing separate management of common and host-specific dotfiles. Add new commands and flags for handling host-specific files, update core logic for file storage and tracking, and enhance documentation to reflect new capabilities.
This commit is contained in:
124
README.md
124
README.md
@@ -2,11 +2,12 @@
|
||||
|
||||
**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.
|
||||
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
|
||||
|
||||
```bash
|
||||
lnk init
|
||||
lnk add ~/.vimrc ~/.bashrc
|
||||
lnk add ~/.vimrc ~/.bashrc # Common config
|
||||
lnk add --host work ~/.ssh/config # Host-specific config
|
||||
lnk push "setup"
|
||||
```
|
||||
|
||||
@@ -49,28 +50,85 @@ lnk init -r git@github.com:user/dotfiles.git
|
||||
### Daily workflow
|
||||
|
||||
```bash
|
||||
# Add files/directories
|
||||
# Add files/directories (common config)
|
||||
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
||||
|
||||
# Add host-specific files
|
||||
lnk add --host laptop ~/.ssh/config
|
||||
lnk add --host work ~/.aws/credentials
|
||||
|
||||
# List managed files
|
||||
lnk list
|
||||
lnk list # Common config only
|
||||
lnk list --host laptop # Laptop-specific config
|
||||
lnk list --all # All configurations
|
||||
|
||||
# Check status
|
||||
lnk status
|
||||
|
||||
# Sync changes
|
||||
lnk push "updated vim config"
|
||||
lnk pull
|
||||
lnk pull # Pull common config
|
||||
lnk pull --host laptop # Pull laptop-specific config
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Common files:
|
||||
Before: ~/.vimrc (file)
|
||||
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
|
||||
|
||||
Host-specific files:
|
||||
Before: ~/.ssh/config (file)
|
||||
After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
|
||||
```
|
||||
|
||||
Your files live in `~/.config/lnk` (a Git repo). Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||
|
||||
## Multihost Support
|
||||
|
||||
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
|
||||
|
||||
### File Organization
|
||||
|
||||
```
|
||||
~/.config/lnk/
|
||||
├── .lnk # Tracks common files
|
||||
├── .lnk.laptop # Tracks laptop-specific files
|
||||
├── .lnk.work # Tracks work-specific files
|
||||
├── .vimrc # Common file
|
||||
├── .gitconfig # Common file
|
||||
├── laptop.lnk/ # Laptop-specific storage
|
||||
│ ├── .ssh/
|
||||
│ │ └── config
|
||||
│ └── .aws/
|
||||
│ └── credentials
|
||||
└── work.lnk/ # Work-specific storage
|
||||
├── .ssh/
|
||||
│ └── config
|
||||
└── .company/
|
||||
└── config
|
||||
```
|
||||
|
||||
### Usage Patterns
|
||||
|
||||
```bash
|
||||
# Common config (shared everywhere)
|
||||
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig
|
||||
|
||||
# Host-specific config (unique per machine)
|
||||
lnk add --host $(hostname) ~/.ssh/config
|
||||
lnk add --host work ~/.aws/credentials
|
||||
|
||||
# List configurations
|
||||
lnk list # Common only
|
||||
lnk list --host work # Work host only
|
||||
lnk list --all # Everything
|
||||
|
||||
# Pull configurations
|
||||
lnk pull # Common config
|
||||
lnk pull --host work # Work-specific config
|
||||
```
|
||||
|
||||
## Why not just Git?
|
||||
|
||||
@@ -87,7 +145,13 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
|
||||
|
||||
```bash
|
||||
lnk init -r git@github.com:you/dotfiles.git
|
||||
|
||||
# Add common config (shared across all machines)
|
||||
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
||||
|
||||
# Add host-specific config
|
||||
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||
|
||||
lnk push "initial setup"
|
||||
```
|
||||
|
||||
@@ -95,39 +159,71 @@ lnk push "initial setup"
|
||||
|
||||
```bash
|
||||
lnk init -r git@github.com:you/dotfiles.git
|
||||
lnk pull # auto-creates symlinks
|
||||
|
||||
# Pull common config
|
||||
lnk pull
|
||||
|
||||
# Pull host-specific config (if it exists)
|
||||
lnk pull --host $(hostname)
|
||||
```
|
||||
|
||||
### Daily edits
|
||||
|
||||
```bash
|
||||
vim ~/.vimrc # edit normally
|
||||
lnk list # see what's managed
|
||||
lnk list # see common config
|
||||
lnk list --host $(hostname) # see host-specific config
|
||||
lnk list --all # see everything
|
||||
lnk status # check what changed
|
||||
lnk push "new plugins" # commit & push
|
||||
```
|
||||
|
||||
### Multi-machine workflow
|
||||
|
||||
```bash
|
||||
# On your laptop
|
||||
lnk add --host laptop ~/.ssh/config
|
||||
lnk add ~/.vimrc # Common config
|
||||
lnk push "laptop ssh config"
|
||||
|
||||
# On your work machine
|
||||
lnk pull # Get common config
|
||||
lnk add --host work ~/.aws/credentials
|
||||
lnk push "work aws config"
|
||||
|
||||
# Back on laptop
|
||||
lnk pull # Get updates (work config won't affect laptop)
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `lnk init [-r remote]` - Create repo
|
||||
- `lnk add <files>` - Move files to repo, create symlinks
|
||||
- `lnk rm <files>` - Move files back, remove symlinks
|
||||
- `lnk list` - List files managed by lnk
|
||||
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
|
||||
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
||||
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
||||
- `lnk status` - Git status + sync info
|
||||
- `lnk push [msg]` - Stage all, commit, push
|
||||
- `lnk pull` - Pull + restore missing symlinks
|
||||
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
|
||||
|
||||
### Command Options
|
||||
|
||||
- `--host HOST` - Manage files for specific host (default: common configuration)
|
||||
- `--all` - Show all configurations (common + all hosts) when listing
|
||||
- `-r, --remote URL` - Clone from remote URL when initializing
|
||||
|
||||
## Technical bits
|
||||
|
||||
- **Single binary** (~8MB, no deps)
|
||||
- **Relative symlinks** (portable)
|
||||
- **XDG compliant** (`~/.config/lnk`)
|
||||
- **Multihost support** (common + host-specific configs)
|
||||
- **Git-native** (standard Git repo, no special formats)
|
||||
|
||||
## Alternatives
|
||||
|
||||
| Tool | Complexity | Why choose it |
|
||||
| ------- | ---------- | ------------------------------------- |
|
||||
| **lnk** | Minimal | Just works, no config, Git-native |
|
||||
| ------- | ---------- | -------------------------------------------- |
|
||||
| **lnk** | Minimal | Just works, no config, Git-native, multihost |
|
||||
| chezmoi | High | Templates, encryption, cross-platform |
|
||||
| yadm | Medium | Git power user, encryption |
|
||||
| dotbot | Low | YAML config, basic features |
|
||||
|
19
cmd/add.go
19
cmd/add.go
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func newAddCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &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.",
|
||||
@@ -17,17 +17,32 @@ func newAddCmd() *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath := args[0]
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
}
|
||||
|
||||
lnk := core.NewLnk()
|
||||
if err := lnk.Add(filePath); err != nil {
|
||||
return fmt.Errorf("failed to add file: %w", err)
|
||||
}
|
||||
|
||||
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, basename)
|
||||
} 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, basename)
|
||||
}
|
||||
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
|
||||
return cmd
|
||||
}
|
||||
|
160
cmd/list.go
160
cmd/list.go
@@ -2,18 +2,45 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "📋 List files managed by lnk",
|
||||
Long: "Display all files and directories currently managed by lnk.",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
all, _ := cmd.Flags().GetBool("all")
|
||||
|
||||
if host != "" {
|
||||
// Show specific host configuration
|
||||
return listHostConfig(cmd, host)
|
||||
}
|
||||
|
||||
if all {
|
||||
// Show all configurations (common + all hosts)
|
||||
return listAllConfigs(cmd)
|
||||
}
|
||||
|
||||
// Default: show common configuration
|
||||
return listCommonConfig(cmd)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("host", "H", "", "List files for specific host")
|
||||
cmd.Flags().BoolP("all", "a", false, "List files for all hosts and common configuration")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listCommonConfig(cmd *cobra.Command) error {
|
||||
lnk := core.NewLnk()
|
||||
managedItems, err := lnk.List()
|
||||
if err != nil {
|
||||
@@ -21,12 +48,12 @@ func newListCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
if len(managedItems) == 0 {
|
||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk\033[0m\n")
|
||||
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
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk\033[0m (\033[36m%d item", len(managedItems))
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
@@ -38,6 +65,129 @@ func newListCmd() *cobra.Command {
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||
lnk := core.NewLnkWithHost(host)
|
||||
managedItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list managed items for host %s: %w", host, 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
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n\n")
|
||||
|
||||
for _, item := range managedItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func listAllConfigs(cmd *cobra.Command) error {
|
||||
// List common configuration
|
||||
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
||||
|
||||
lnk := core.NewLnk()
|
||||
commonItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list common managed items: %w", err)
|
||||
}
|
||||
|
||||
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
|
||||
if len(commonItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
|
||||
if len(commonItems) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
} else {
|
||||
for _, item := range commonItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
}
|
||||
}
|
||||
|
||||
// Find all host-specific configurations
|
||||
hosts, err := findHostConfigs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find host configurations: %w", err)
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||
|
||||
hostLnk := core.NewLnkWithHost(host)
|
||||
hostItems, err := hostLnk.List()
|
||||
if err != nil {
|
||||
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
printf(cmd, " (\033[36m%d item", len(hostItems))
|
||||
if len(hostItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
|
||||
if len(hostItems) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
} else {
|
||||
for _, item := range hostItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func findHostConfigs() ([]string, error) {
|
||||
repoPath := getRepoPath()
|
||||
|
||||
// Check if repo exists
|
||||
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(repoPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read repository directory: %w", err)
|
||||
}
|
||||
|
||||
var hosts []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
// Look for .lnk.<hostname> files
|
||||
if strings.HasPrefix(name, ".lnk.") && name != ".lnk" {
|
||||
host := strings.TrimPrefix(name, ".lnk.")
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
}
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func getRepoPath() string {
|
||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdgConfig == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
xdgConfig = "."
|
||||
} else {
|
||||
xdgConfig = filepath.Join(homeDir, ".config")
|
||||
}
|
||||
}
|
||||
return filepath.Join(xdgConfig, "lnk")
|
||||
}
|
||||
|
23
cmd/pull.go
23
cmd/pull.go
@@ -8,20 +8,32 @@ import (
|
||||
)
|
||||
|
||||
func newPullCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &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.",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
lnk := core.NewLnk()
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
}
|
||||
|
||||
restored, err := lnk.Pull()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull changes: %w", err)
|
||||
}
|
||||
|
||||
if len(restored) > 0 {
|
||||
if host != "" {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||
} else {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
}
|
||||
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
||||
if len(restored) > 1 {
|
||||
printf(cmd, "s")
|
||||
@@ -31,8 +43,12 @@ func newPullCmd() *cobra.Command {
|
||||
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
|
||||
}
|
||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||
} else {
|
||||
if host != "" {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||
} else {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
}
|
||||
printf(cmd, " ✅ All symlinks already in place\n")
|
||||
printf(cmd, " 🎉 Everything is up to date!\n")
|
||||
}
|
||||
@@ -40,4 +56,7 @@ func newPullCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)")
|
||||
return cmd
|
||||
}
|
||||
|
19
cmd/rm.go
19
cmd/rm.go
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func newRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &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.",
|
||||
@@ -17,17 +17,32 @@ func newRemoveCmd() *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath := args[0]
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
}
|
||||
|
||||
lnk := core.NewLnk()
|
||||
if err := lnk.Remove(filePath); err != nil {
|
||||
return fmt.Errorf("failed to remove file: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
printf(cmd, " 📄 Original file restored\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)")
|
||||
return cmd
|
||||
}
|
||||
|
10
cmd/root.go
10
cmd/root.go
@@ -20,16 +20,18 @@ func NewRootCommand() *cobra.Command {
|
||||
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.
|
||||
Supports both common configurations and host-specific setups.
|
||||
|
||||
✨ Examples:
|
||||
lnk init # Fresh start
|
||||
lnk init -r <repo-url> # Clone existing dotfiles
|
||||
lnk add ~/.vimrc ~/.bashrc # Start managing files
|
||||
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
||||
lnk add --host work ~/.ssh/config # Manage host-specific files
|
||||
lnk list --all # Show all configurations
|
||||
lnk pull --host work # Pull host-specific changes
|
||||
lnk push "setup complete" # Sync to remote
|
||||
lnk pull # Get latest changes
|
||||
|
||||
🎯 Simple, fast, and Git-native.`,
|
||||
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||
SilenceUsage: true,
|
||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||
}
|
||||
|
107
cmd/root_test.go
107
cmd/root_test.go
@@ -528,6 +528,113 @@ func (suite *CLITestSuite) TestStatusDirtyRepo() {
|
||||
suite.Contains(output, "lnk push")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestMultihostCommands() {
|
||||
// Initialize repository
|
||||
err := suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Create test files
|
||||
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
|
||||
err = os.WriteFile(testFile1, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||
err = os.WriteFile(testFile2, []byte("set number"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file to common configuration
|
||||
err = suite.runCommand("add", testFile1)
|
||||
suite.NoError(err)
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Added .bashrc to lnk")
|
||||
suite.NotContains(output, "host:")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Add file to host-specific configuration
|
||||
err = suite.runCommand("add", "--host", "workstation", testFile2)
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Added .vimrc to lnk (host: workstation)")
|
||||
suite.Contains(output, "workstation.lnk")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test list command - common only
|
||||
err = suite.runCommand("list")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Files managed by lnk (common)")
|
||||
suite.Contains(output, ".bashrc")
|
||||
suite.NotContains(output, ".vimrc")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test list command - specific host
|
||||
err = suite.runCommand("list", "--host", "workstation")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Files managed by lnk (host: workstation)")
|
||||
suite.Contains(output, ".vimrc")
|
||||
suite.NotContains(output, ".bashrc")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test list command - all configurations
|
||||
err = suite.runCommand("list", "--all")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "All configurations managed by lnk")
|
||||
suite.Contains(output, "Common configuration")
|
||||
suite.Contains(output, "Host: workstation")
|
||||
suite.Contains(output, ".bashrc")
|
||||
suite.Contains(output, ".vimrc")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test remove from host-specific
|
||||
err = suite.runCommand("rm", "--host", "workstation", testFile2)
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Removed .vimrc from lnk (host: workstation)")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test remove from common
|
||||
err = suite.runCommand("rm", testFile1)
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Removed .bashrc from lnk")
|
||||
suite.NotContains(output, "host:")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Verify files are restored
|
||||
info1, err := os.Lstat(testFile1)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
|
||||
|
||||
info2, err := os.Lstat(testFile2)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestMultihostErrorHandling() {
|
||||
// Initialize repository
|
||||
err := suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Try to remove from non-existent host config
|
||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.runCommand("rm", "--host", "nonexistent", testFile)
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "File is not managed by lnk")
|
||||
|
||||
// Try to list non-existent host config
|
||||
err = suite.runCommand("list", "--host", "nonexistent")
|
||||
suite.NoError(err) // Should not error, just show empty
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)")
|
||||
}
|
||||
|
||||
func TestCLISuite(t *testing.T) {
|
||||
suite.Run(t, new(CLITestSuite))
|
||||
}
|
||||
|
@@ -14,20 +14,42 @@ import (
|
||||
// Lnk represents the main application logic
|
||||
type Lnk struct {
|
||||
repoPath string
|
||||
host string // Host-specific configuration
|
||||
git *git.Git
|
||||
fs *fs.FileSystem
|
||||
}
|
||||
|
||||
// NewLnk creates a new Lnk instance
|
||||
// NewLnk creates a new Lnk instance for common configuration
|
||||
func NewLnk() *Lnk {
|
||||
repoPath := getRepoPath()
|
||||
return &Lnk{
|
||||
repoPath: repoPath,
|
||||
host: "", // Empty host means common configuration
|
||||
git: git.New(repoPath),
|
||||
fs: fs.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewLnkWithHost creates a new Lnk instance for host-specific configuration
|
||||
func NewLnkWithHost(host string) *Lnk {
|
||||
repoPath := getRepoPath()
|
||||
return &Lnk{
|
||||
repoPath: repoPath,
|
||||
host: host,
|
||||
git: git.New(repoPath),
|
||||
fs: fs.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentHostname returns the current system hostname
|
||||
func GetCurrentHostname() (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||
}
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
// getRepoPath returns the path to the lnk repository directory
|
||||
func getRepoPath() string {
|
||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
@@ -43,14 +65,38 @@ func getRepoPath() string {
|
||||
return filepath.Join(xdgConfig, "lnk")
|
||||
}
|
||||
|
||||
// generateRepoName creates a unique repository filename from a relative path
|
||||
func generateRepoName(relativePath string) string {
|
||||
// Replace slashes and backslashes with underscores to create valid filename
|
||||
// generateRepoName creates a repository path from a relative path
|
||||
func generateRepoName(relativePath string, host string) string {
|
||||
if host != "" {
|
||||
// For host-specific files, preserve the directory structure
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// For common files, replace slashes and backslashes with underscores to create valid filename
|
||||
repoName := strings.ReplaceAll(relativePath, "/", "_")
|
||||
repoName = strings.ReplaceAll(repoName, "\\", "_")
|
||||
|
||||
return repoName
|
||||
}
|
||||
|
||||
// getHostStoragePath returns the storage path for host-specific or common files
|
||||
func (l *Lnk) getHostStoragePath() string {
|
||||
if l.host == "" {
|
||||
// Common configuration - store in root of repo
|
||||
return l.repoPath
|
||||
}
|
||||
// Host-specific configuration - store in host subdirectory
|
||||
return filepath.Join(l.repoPath, l.host+".lnk")
|
||||
}
|
||||
|
||||
// getLnkFileName returns the appropriate .lnk tracking file name
|
||||
func (l *Lnk) getLnkFileName() string {
|
||||
if l.host == "" {
|
||||
return ".lnk"
|
||||
}
|
||||
return ".lnk." + l.host
|
||||
}
|
||||
|
||||
// getRelativePath converts an absolute path to a relative path from home directory
|
||||
func getRelativePath(absPath string) (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
@@ -147,9 +193,16 @@ func (l *Lnk) Add(filePath string) error {
|
||||
return fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
// Generate unique repository name from relative path
|
||||
repoName := generateRepoName(relativePath)
|
||||
destPath := filepath.Join(l.repoPath, repoName)
|
||||
// Generate repository path from relative path
|
||||
repoName := generateRepoName(relativePath, l.host)
|
||||
storagePath := l.getHostStoragePath()
|
||||
destPath := filepath.Join(storagePath, repoName)
|
||||
|
||||
// Ensure destination directory exists (including parent directories for host-specific files)
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if this relative path is already managed
|
||||
managedItems, err := l.getManagedItems()
|
||||
@@ -203,7 +256,12 @@ func (l *Lnk) Add(filePath string) error {
|
||||
}
|
||||
|
||||
// Add both the item and .lnk file to git in a single commit
|
||||
if err := l.git.Add(repoName); err != nil {
|
||||
// For host-specific files, we need to add the relative path from repo root
|
||||
gitPath := repoName
|
||||
if l.host != "" {
|
||||
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||
}
|
||||
if err := l.git.Add(gitPath); err != nil {
|
||||
// Try to restore the original state if git add fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
@@ -216,7 +274,7 @@ func (l *Lnk) Add(filePath string) error {
|
||||
}
|
||||
|
||||
// Add .lnk file to the same commit
|
||||
if err := l.git.Add(".lnk"); err != nil {
|
||||
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||
// Try to restore the original state if git add fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
@@ -292,8 +350,6 @@ func (l *Lnk) Remove(filePath string) error {
|
||||
target = filepath.Join(filepath.Dir(absPath), target)
|
||||
}
|
||||
|
||||
repoName := filepath.Base(target)
|
||||
|
||||
// Check if target is a directory or file
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
@@ -310,13 +366,18 @@ func (l *Lnk) Remove(filePath string) error {
|
||||
return fmt.Errorf("failed to update tracking file: %w", err)
|
||||
}
|
||||
|
||||
// Remove from Git first (while the item is still in the repository)
|
||||
if err := l.git.Remove(repoName); err != nil {
|
||||
// Generate the correct git path for removal
|
||||
repoName := generateRepoName(relativePath, l.host)
|
||||
gitPath := repoName
|
||||
if l.host != "" {
|
||||
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||
}
|
||||
if err := l.git.Remove(gitPath); err != nil {
|
||||
return fmt.Errorf("failed to remove from git: %w", err)
|
||||
}
|
||||
|
||||
// Add .lnk file to the same commit
|
||||
if err := l.git.Add(".lnk"); err != nil {
|
||||
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
||||
}
|
||||
|
||||
@@ -461,8 +522,9 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||
|
||||
for _, relativePath := range managedItems {
|
||||
// Generate repository name from relative path
|
||||
repoName := generateRepoName(relativePath)
|
||||
repoItem := filepath.Join(l.repoPath, repoName)
|
||||
repoName := generateRepoName(relativePath, l.host)
|
||||
storagePath := l.getHostStoragePath()
|
||||
repoItem := filepath.Join(storagePath, repoName)
|
||||
|
||||
// Check if item exists in repository
|
||||
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
|
||||
@@ -540,7 +602,7 @@ func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
|
||||
|
||||
// getManagedItems returns the list of managed files and directories from .lnk file
|
||||
func (l *Lnk) getManagedItems() ([]string, error) {
|
||||
lnkFile := filepath.Join(l.repoPath, ".lnk")
|
||||
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
|
||||
|
||||
// If .lnk file doesn't exist, return empty list
|
||||
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
|
||||
@@ -613,7 +675,7 @@ func (l *Lnk) removeManagedItem(relativePath string) error {
|
||||
|
||||
// writeManagedItems writes the list of managed items to .lnk file
|
||||
func (l *Lnk) writeManagedItems(items []string) error {
|
||||
lnkFile := filepath.Join(l.repoPath, ".lnk")
|
||||
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
|
||||
|
||||
content := strings.Join(items, "\n")
|
||||
if len(items) > 0 {
|
||||
|
@@ -586,6 +586,185 @@ func (suite *CoreTestSuite) TestListManagedItems() {
|
||||
suite.Contains(items[0], ".config")
|
||||
}
|
||||
|
||||
// Test multihost functionality
|
||||
func (suite *CoreTestSuite) TestMultihostFileOperations() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create test files for different hosts
|
||||
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
|
||||
content1 := "export PATH=$PATH:/usr/local/bin"
|
||||
err = os.WriteFile(testFile1, []byte(content1), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||
content2 := "set number"
|
||||
err = os.WriteFile(testFile2, []byte(content2), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file to common configuration
|
||||
commonLnk := NewLnk()
|
||||
err = commonLnk.Add(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file to host-specific configuration
|
||||
hostLnk := NewLnkWithHost("workstation")
|
||||
err = hostLnk.Add(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify both files are symlinks
|
||||
info1, err := os.Lstat(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink)
|
||||
|
||||
info2, err := os.Lstat(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink)
|
||||
|
||||
// Verify common configuration tracking
|
||||
commonItems, err := commonLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(commonItems, 1)
|
||||
suite.Contains(commonItems[0], ".bashrc")
|
||||
|
||||
// Verify host-specific configuration tracking
|
||||
hostItems, err := hostLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(hostItems, 1)
|
||||
suite.Contains(hostItems[0], ".vimrc")
|
||||
|
||||
// Verify files are stored in correct locations
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// Common file should be in root
|
||||
commonFile := filepath.Join(lnkDir, ".lnk")
|
||||
suite.FileExists(commonFile)
|
||||
|
||||
// Host-specific file should be in host subdirectory
|
||||
hostDir := filepath.Join(lnkDir, "workstation.lnk")
|
||||
suite.DirExists(hostDir)
|
||||
|
||||
hostTrackingFile := filepath.Join(lnkDir, ".lnk.workstation")
|
||||
suite.FileExists(hostTrackingFile)
|
||||
|
||||
// Test removal
|
||||
err = commonLnk.Remove(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = hostLnk.Remove(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify files are restored
|
||||
info1, err = os.Lstat(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
|
||||
|
||||
info2, err = os.Lstat(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
|
||||
}
|
||||
|
||||
// Test hostname detection
|
||||
func (suite *CoreTestSuite) TestHostnameDetection() {
|
||||
hostname, err := GetCurrentHostname()
|
||||
suite.NoError(err)
|
||||
suite.NotEmpty(hostname)
|
||||
}
|
||||
|
||||
// Test host-specific symlink restoration
|
||||
func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create files directly in host-specific storage (simulating a pull)
|
||||
hostLnk := NewLnkWithHost("testhost")
|
||||
|
||||
// Ensure host storage directory exists
|
||||
hostStoragePath := hostLnk.getHostStoragePath()
|
||||
err = os.MkdirAll(hostStoragePath, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a file in host storage
|
||||
repoFile := filepath.Join(hostStoragePath, ".bashrc")
|
||||
content := "export HOST=testhost"
|
||||
err = os.WriteFile(repoFile, []byte(content), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create host tracking file
|
||||
trackingFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
|
||||
err = os.WriteFile(trackingFile, []byte(".bashrc\n"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Get home directory for the test
|
||||
homeDir, err := os.UserHomeDir()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
targetFile := filepath.Join(homeDir, ".bashrc")
|
||||
|
||||
// Clean up the test file after the test
|
||||
defer func() {
|
||||
_ = os.Remove(targetFile)
|
||||
}()
|
||||
|
||||
// Test symlink restoration
|
||||
restored, err := hostLnk.RestoreSymlinks()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Should have restored the symlink
|
||||
suite.Len(restored, 1)
|
||||
suite.Equal(".bashrc", restored[0])
|
||||
|
||||
// Check that file is now a symlink
|
||||
info, err := os.Lstat(targetFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
}
|
||||
|
||||
// Test that common and host-specific configurations don't interfere
|
||||
func (suite *CoreTestSuite) TestMultihostIsolation() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create same file for common and host-specific
|
||||
testFile := filepath.Join(suite.tempDir, ".gitconfig")
|
||||
commonContent := "[user]\n\tname = Common User"
|
||||
err = os.WriteFile(testFile, []byte(commonContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add to common
|
||||
commonLnk := NewLnk()
|
||||
err = commonLnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Remove and recreate with different content
|
||||
err = commonLnk.Remove(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
hostContent := "[user]\n\tname = Work User"
|
||||
err = os.WriteFile(testFile, []byte(hostContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add to host-specific
|
||||
hostLnk := NewLnkWithHost("work")
|
||||
err = hostLnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify tracking files are separate
|
||||
commonItems, err := commonLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(commonItems, 0) // Should be empty after removal
|
||||
|
||||
hostItems, err := hostLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(hostItems, 1)
|
||||
suite.Contains(hostItems[0], ".gitconfig")
|
||||
|
||||
// Verify content is correct
|
||||
symlinkContent, err := os.ReadFile(testFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(hostContent, string(symlinkContent))
|
||||
}
|
||||
|
||||
func TestCoreSuite(t *testing.T) {
|
||||
suite.Run(t, new(CoreTestSuite))
|
||||
}
|
||||
|
Reference in New Issue
Block a user