From ae9cc175ce5589cd29ce3b762e024cdee691d48b Mon Sep 17 00:00:00 2001 From: Yar Kravtsov Date: Tue, 3 Jun 2025 08:33:59 +0300 Subject: [PATCH] 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 --- README.md | 82 ++++++++++++++---- cmd/bootstrap.go | 45 ++++++++++ cmd/init.go | 29 +++++++ cmd/root.go | 8 +- cmd/root_test.go | 171 ++++++++++++++++++++++++++++++++++++++ internal/core/lnk.go | 50 +++++++++++ internal/core/lnk_test.go | 71 ++++++++++++++++ 7 files changed, 441 insertions(+), 15 deletions(-) create mode 100644 cmd/bootstrap.go diff --git a/README.md b/README.md index e13d81e..8455b2c 100644 --- a/README.md +++ b/README.md @@ -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 `.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 @@ -184,7 +235,7 @@ lnk add --host laptop ~/.ssh/config lnk add ~/.vimrc # Common config lnk push "laptop ssh config" -# On your work machine +# On your work machine lnk pull # Get common config lnk add --host work ~/.gitconfig lnk push "work git config" @@ -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] ` - Move files to repo, create symlinks - `lnk rm [--host HOST] ` - 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 diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go new file mode 100644 index 0000000..6fbebe7 --- /dev/null +++ b/cmd/bootstrap.go @@ -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 + }, + } +} diff --git a/cmd/init.go b/cmd/init.go index 9ac4fcd..0e4f37c 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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 \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 } diff --git a/cmd/root.go b/cmd/root.go index d042c73..fffd9c2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,12 +24,17 @@ Supports both common configurations and host-specific setups. ✨ Examples: lnk init # Fresh start - lnk init -r # Clone existing dotfiles + lnk init -r # 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 } diff --git a/cmd/root_test.go b/cmd/root_test.go index 944de63..96ba940 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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)) } diff --git a/internal/core/lnk.go b/internal/core/lnk.go index a082fa6..0496b4f 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -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 +} diff --git a/internal/core/lnk_test.go b/internal/core/lnk_test.go index 0043fae..c9d8718 100644 --- a/internal/core/lnk_test.go +++ b/internal/core/lnk_test.go @@ -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)) }