feat(bootstrap): add automatic environment setup with bootstrap scripts

Implement bootstrap functionality for streamlined dotfiles setup:
- Add 'bootstrap' command to run setup scripts manually
- Auto-execute bootstrap on 'init' with remote (--no-bootstrap to skip)
- Update README with bootstrap usage and examples
- Extend tests to cover bootstrap scenarios
This commit is contained in:
Yar Kravtsov
2025-06-03 08:33:59 +03:00
parent 1e2c9704f3
commit ae9cc175ce
7 changed files with 441 additions and 15 deletions

View File

@@ -2,12 +2,12 @@
**Git-native dotfiles management that doesn't suck.**
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups. Automatically runs bootstrap scripts to set up your environment.
```bash
lnk init
lnk add ~/.vimrc ~/.bashrc # Common config
lnk add --host work ~/.ssh/config # Host-specific config
lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
lnk add ~/.vimrc ~/.bashrc # Common config
lnk add --host work ~/.ssh/config # Host-specific config
lnk push "setup"
```
@@ -43,8 +43,14 @@ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv
# Fresh start
lnk init
# With existing repo
# With existing repo (runs bootstrap automatically)
lnk init -r git@github.com:user/dotfiles.git
# Skip automatic bootstrap
lnk init -r git@github.com:user/dotfiles.git --no-bootstrap
# Run bootstrap script manually
lnk bootstrap
```
### Daily workflow
@@ -85,6 +91,44 @@ After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
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.
## Bootstrap Support
Lnk automatically runs bootstrap scripts when cloning dotfiles repositories, making it easy to set up your development environment. Just add a `bootstrap.sh` file to your dotfiles repo.
### Examples
**Simple bootstrap script:**
```bash
#!/bin/bash
# bootstrap.sh
echo "Setting up development environment..."
# Install Homebrew (macOS)
if ! command -v brew &> /dev/null; then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Install packages
brew install git vim tmux
echo "✅ Setup complete!"
```
**Usage:**
```bash
# Automatic bootstrap on clone
lnk init -r git@github.com:you/dotfiles.git
# → Clones repo and runs bootstrap script automatically
# Skip bootstrap if needed
lnk init -r git@github.com:you/dotfiles.git --no-bootstrap
# Run bootstrap manually later
lnk bootstrap
```
## Multihost Support
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
@@ -142,7 +186,9 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
### First time setup
```bash
# Clone dotfiles and run bootstrap automatically
lnk init -r git@github.com:you/dotfiles.git
# → Downloads dependencies, installs packages, configures environment
# Add common config (shared across all machines)
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
@@ -156,13 +202,18 @@ lnk push "initial setup"
### On a new machine
```bash
# Bootstrap runs automatically
lnk init -r git@github.com:you/dotfiles.git
# → Sets up environment, installs dependencies
# Pull common config
lnk pull
# Pull host-specific config (if it exists)
lnk pull --host $(hostname)
# Or run bootstrap manually if needed
lnk bootstrap
```
### Daily edits
@@ -195,19 +246,21 @@ lnk pull # Get updates (work config won't affect laptop)
## Commands
- `lnk init [-r remote]` - Create repo
- `lnk init [-r remote] [--no-bootstrap]` - Create repo (runs bootstrap automatically)
- `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 [--host HOST]` - Pull + restore missing symlinks
- `lnk bootstrap` - Run bootstrap script manually
### 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
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
## Technical bits
@@ -215,17 +268,18 @@ lnk pull # Get updates (work config won't affect laptop)
- **Relative symlinks** (portable)
- **XDG compliant** (`~/.config/lnk`)
- **Multihost support** (common + host-specific configs)
- **Bootstrap support** (automatic environment setup)
- **Git-native** (standard Git repo, no special formats)
## Alternatives
| Tool | Complexity | Why choose it |
| ------- | ---------- | -------------------------------------------- |
| **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 |
| stow | Low | Perl, symlink only |
| Tool | Complexity | Why choose it |
| ------- | ---------- | ------------------------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap |
| chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only |
## Contributing

45
cmd/bootstrap.go Normal file
View File

@@ -0,0 +1,45 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newBootstrapCmd() *cobra.Command {
return &cobra.Command{
Use: "bootstrap",
Short: "🚀 Run the bootstrap script to set up your environment",
Long: "Executes the bootstrap script from your dotfiles repository to install dependencies and configure your system.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
scriptPath, err := lnk.FindBootstrapScript()
if err != nil {
return err
}
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
}
printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n")
printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath)
printf(cmd, "\n")
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
},
}
}

View File

@@ -14,6 +14,7 @@ func newInitCmd() *cobra.Command {
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote")
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil {
@@ -24,6 +25,33 @@ func newInitCmd() *cobra.Command {
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")
// Try to run bootstrap script if not disabled
if !noBootstrap {
printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n")
scriptPath, err := lnk.FindBootstrapScript()
if err != nil {
return err
}
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")
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)
} else {
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
}
} else {
printf(cmd, " 💡 No bootstrap script found\n")
}
}
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")
@@ -40,5 +68,6 @@ func newInitCmd() *cobra.Command {
}
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning")
return cmd
}

View File

@@ -24,12 +24,17 @@ Supports both common configurations and host-specific setups.
✨ Examples:
lnk init # Fresh start
lnk init -r <repo-url> # Clone existing dotfiles
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
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 bootstrap # Run bootstrap script manually
🚀 Bootstrap Support:
Automatically runs bootstrap.sh when cloning a repository.
Use --no-bootstrap to disable.
🎯 Simple, fast, Git-native, and multi-host ready.`,
SilenceUsage: true,
@@ -45,6 +50,7 @@ Supports both common configurations and host-specific setups.
rootCmd.AddCommand(newStatusCmd())
rootCmd.AddCommand(newPushCmd())
rootCmd.AddCommand(newPullCmd())
rootCmd.AddCommand(newBootstrapCmd())
return rootCmd
}

View File

@@ -746,6 +746,177 @@ func (suite *CLITestSuite) TestMultihostErrorHandling() {
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)")
}
func (suite *CLITestSuite) TestBootstrapCommand() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Test bootstrap command with no script
err = suite.runCommand("bootstrap")
suite.NoError(err)
output := suite.stdout.String()
suite.Contains(output, "No bootstrap script found")
suite.Contains(output, "bootstrap.sh")
suite.stdout.Reset()
// Create a bootstrap script
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
bootstrapScript := filepath.Join(lnkDir, "bootstrap.sh")
scriptContent := `#!/bin/bash
echo "Bootstrap script executed!"
echo "Working directory: $(pwd)"
touch bootstrap-ran.txt
`
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Test bootstrap command with script
err = suite.runCommand("bootstrap")
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "Running bootstrap script")
suite.Contains(output, "bootstrap.sh")
suite.Contains(output, "Bootstrap completed successfully")
// Verify script actually ran
markerFile := filepath.Join(lnkDir, "bootstrap-ran.txt")
suite.FileExists(markerFile)
}
func (suite *CLITestSuite) TestInitWithBootstrap() {
// Create a temporary remote repository with bootstrap script
remoteDir := filepath.Join(suite.tempDir, "remote")
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
// Initialize git repo in remote
cmd := exec.Command("git", "init", "--bare")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Create a working repo to populate the remote
workingDir := filepath.Join(suite.tempDir, "working")
err = os.MkdirAll(workingDir, 0755)
suite.Require().NoError(err)
cmd = exec.Command("git", "clone", remoteDir, workingDir)
err = cmd.Run()
suite.Require().NoError(err)
// Add a bootstrap script to the working repo
bootstrapScript := filepath.Join(workingDir, "bootstrap.sh")
scriptContent := `#!/bin/bash
echo "Remote bootstrap script executed!"
touch remote-bootstrap-ran.txt
`
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Add a dummy config file
configFile := filepath.Join(workingDir, ".bashrc")
err = os.WriteFile(configFile, []byte("echo 'Hello from remote!'"), 0644)
suite.Require().NoError(err)
// Add .lnk file to track the config
lnkFile := filepath.Join(workingDir, ".lnk")
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
// Commit and push to remote
cmd = exec.Command("git", "add", ".")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap and config")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "push", "origin", "master")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
// Now test init with remote and automatic bootstrap
err = suite.runCommand("init", "-r", remoteDir)
suite.NoError(err)
output := suite.stdout.String()
suite.Contains(output, "Cloned from:")
suite.Contains(output, "Looking for bootstrap script")
suite.Contains(output, "Found bootstrap script:")
suite.Contains(output, "bootstrap.sh")
suite.Contains(output, "Running bootstrap script")
suite.Contains(output, "Bootstrap completed successfully")
// Verify bootstrap actually ran
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
markerFile := filepath.Join(lnkDir, "remote-bootstrap-ran.txt")
suite.FileExists(markerFile)
}
func (suite *CLITestSuite) TestInitWithBootstrapDisabled() {
// Create a temporary remote repository with bootstrap script
remoteDir := filepath.Join(suite.tempDir, "remote")
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
// Initialize git repo in remote
cmd := exec.Command("git", "init", "--bare")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Create a working repo to populate the remote
workingDir := filepath.Join(suite.tempDir, "working")
err = os.MkdirAll(workingDir, 0755)
suite.Require().NoError(err)
cmd = exec.Command("git", "clone", remoteDir, workingDir)
err = cmd.Run()
suite.Require().NoError(err)
// Add a bootstrap script
bootstrapScript := filepath.Join(workingDir, "bootstrap.sh")
scriptContent := `#!/bin/bash
echo "This should not run!"
touch should-not-exist.txt
`
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Commit and push
cmd = exec.Command("git", "add", ".")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "push", "origin", "master")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
// Test init with --no-bootstrap flag
err = suite.runCommand("init", "-r", remoteDir, "--no-bootstrap")
suite.NoError(err)
output := suite.stdout.String()
suite.Contains(output, "Cloned from:")
suite.NotContains(output, "Looking for bootstrap script")
suite.NotContains(output, "Running bootstrap script")
// Verify bootstrap did NOT run
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
markerFile := filepath.Join(lnkDir, "should-not-exist.txt")
suite.NoFileExists(markerFile)
}
func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLITestSuite))
}

View File

@@ -3,6 +3,7 @@ package core
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
@@ -630,3 +631,52 @@ func (l *Lnk) writeManagedItems(items []string) error {
return nil
}
// FindBootstrapScript searches for a bootstrap script in the repository
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")
}
// Look for bootstrap.sh - simple, opinionated choice
scriptPath := filepath.Join(l.repoPath, "bootstrap.sh")
if _, err := os.Stat(scriptPath); err == nil {
return "bootstrap.sh", nil
}
return "", nil // No bootstrap script found
}
// RunBootstrapScript executes the bootstrap script
func (l *Lnk) RunBootstrapScript(scriptName string) error {
scriptPath := filepath.Join(l.repoPath, scriptName)
// 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)
}
// Make sure it's executable
if err := os.Chmod(scriptPath, 0755); err != nil {
return fmt.Errorf("❌ Failed to make bootstrap script executable: %w", err)
}
// Run with bash (since we only support bootstrap.sh)
cmd := exec.Command("bash", scriptPath)
// Set working directory to the repository
cmd.Dir = l.repoPath
// Connect to stdout/stderr for user to see output
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
// Run the script
if err := cmd.Run(); err != nil {
return fmt.Errorf("❌ Bootstrap script failed with error: %w", err)
}
return nil
}

View File

@@ -1,6 +1,7 @@
package core
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -749,6 +750,76 @@ func (suite *CoreTestSuite) TestMultihostIsolation() {
suite.Equal(hostContent, string(symlinkContent))
}
// Test bootstrap script detection
func (suite *CoreTestSuite) TestFindBootstrapScript() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Test with no bootstrap script
scriptPath, err := suite.lnk.FindBootstrapScript()
suite.NoError(err)
suite.Empty(scriptPath)
// Test with bootstrap.sh
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "bootstrap.sh")
err = os.WriteFile(bootstrapScript, []byte("#!/bin/bash\necho 'test'"), 0644)
suite.Require().NoError(err)
scriptPath, err = suite.lnk.FindBootstrapScript()
suite.NoError(err)
suite.Equal("bootstrap.sh", scriptPath)
}
// Test bootstrap script execution
func (suite *CoreTestSuite) TestRunBootstrapScript() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create a test script that creates a marker file
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "test.sh")
markerFile := filepath.Join(suite.tempDir, "lnk", "bootstrap-executed.txt")
scriptContent := fmt.Sprintf("#!/bin/bash\ntouch %s\necho 'Bootstrap executed'", markerFile)
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Run the bootstrap script
err = suite.lnk.RunBootstrapScript("test.sh")
suite.NoError(err)
// Verify the marker file was created
suite.FileExists(markerFile)
}
// Test bootstrap script execution with error
func (suite *CoreTestSuite) TestRunBootstrapScriptWithError() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create a script that will fail
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "failing.sh")
scriptContent := "#!/bin/bash\nexit 1"
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Run the bootstrap script - should fail
err = suite.lnk.RunBootstrapScript("failing.sh")
suite.Error(err)
suite.Contains(err.Error(), "Bootstrap script failed")
}
// Test running bootstrap on non-existent script
func (suite *CoreTestSuite) TestRunBootstrapScriptNotFound() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Try to run non-existent script
err = suite.lnk.RunBootstrapScript("nonexistent.sh")
suite.Error(err)
suite.Contains(err.Error(), "Bootstrap script not found")
}
func TestCoreSuite(t *testing.T) {
suite.Run(t, new(CoreTestSuite))
}