From 3aa836e9968bd205b474deff61ecb11832e87343 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:20:37 +0000 Subject: [PATCH 1/2] feat(cli): add --link flag for link-only runs Implements link-only operation that symlinks configs and runs postlink hooks without installs/defaults/state persistence. Adds tests and docs. Co-Authored-By: Pablo P Varela --- README.md | 24 ++++++ cmd/dot/main.go | 38 ++++++++- cmd/dot/main_test.go | 18 +++- internal/component/component.go | 119 +++++++++++++++++++++++++++ internal/component/component_test.go | 53 ++++++++++++ 5 files changed, 250 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2541db3..d5b952b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ +# dot + +Quickly bootstrap and manage your development environment using profiles and components. + +## Usage + +- Install and link as usual: + - dot + - dot work laptop + - dot neovim + +- Link-only mode (no installs, no defaults, no state persistence): + - dot --link + - dot --link work laptop + - dot --link neovim + +Link-only mode: +- Only creates or updates symlinks defined in components (component.link) +- Runs postlink hooks when present +- Supports the same profile selection and fuzzy search +- Does not run install commands or export/import defaults +- Does not persist profiles or other state changes + + # dot - all your computers in one repo > All your tools, apps, and configs with 0 dependencies diff --git a/cmd/dot/main.go b/cmd/dot/main.go index 2c49f74..93c4980 100644 --- a/cmd/dot/main.go +++ b/cmd/dot/main.go @@ -35,6 +35,7 @@ func main() { defaultsImportShort = flag.Bool("i", false, "import macOS defaults from plist files") runPostInstall = flag.Bool("postinstall", false, "run only postinstall hooks") runPostLink = flag.Bool("postlink", false, "run only postlink hooks") + linkOnly = flag.Bool("link", false, "link configs only (no installs)") ) // Preprocess arguments to allow flexible flag/argument ordering @@ -90,6 +91,7 @@ func main() { RemoveProfile: *removeProfile, RunPostInstall: *runPostInstall, RunPostLink: *runPostLink, + LinkOnly: *linkOnly, } // Use the preprocessed positional arguments instead of flag.Args() @@ -151,6 +153,7 @@ type App struct { RemoveProfile string RunPostInstall bool RunPostLink bool + LinkOnly bool } func (a *App) Run(args []string) error { @@ -322,6 +325,39 @@ func (a *App) Run(args []string) error { return err } a.printResults("PostLink Hook", results) + // Handle link-only operation + if a.LinkOnly { + if a.Verbose { + fmt.Printf("🔗 Starting link-only operation...\n") + if len(activeProfiles) > 0 { + fmt.Printf(" Active profiles: %s\n", strings.Join(activeProfiles, ", ")) + } else { + fmt.Printf(" Active profiles: * (default)\n") + } + if fuzzySearch != "" { + fmt.Printf(" Fuzzy search: %s\n", fuzzySearch) + } + if a.DryRun { + fmt.Printf(" Dry run: enabled\n") + } + fmt.Println() + results, err := componentManager.LinkComponents(activeProfiles, fuzzySearch) + if err != nil { + return err + } + a.printResults("Link", results) + } else { + progressManager := ui.NewProgressManager(false) + defer progressManager.StopAll() + results, err := componentManager.LinkComponentsWithProgress(activeProfiles, fuzzySearch, progressManager) + if err != nil { + return err + } + a.printSummaryResults("Link", results) + } + return nil + } + return nil } @@ -336,7 +372,7 @@ func (a *App) Run(args []string) error { } // Persist active profiles early when provided by user (ensures they are saved even if installs fail hard) - if !a.DryRun && profilesFromUser { + if !a.DryRun && profilesFromUser && !a.LinkOnly { stateManager, err := state.NewManager() if err != nil { return fmt.Errorf("failed to create state manager: %w", err) diff --git a/cmd/dot/main_test.go b/cmd/dot/main_test.go index c29755f..3d491bf 100644 --- a/cmd/dot/main_test.go +++ b/cmd/dot/main_test.go @@ -411,11 +411,12 @@ func TestFlagParsing(t *testing.T) { }, { name: "multiple flags", - args: []string{"-v", "--dry-run", "--install"}, + args: []string{"-v", "--dry-run", "--install", "--link"}, expected: map[string]bool{ "verbose": true, "dry-run": true, "install": true, + "link": true, }, }, { @@ -430,6 +431,14 @@ func TestFlagParsing(t *testing.T) { args: []string{"--postlink"}, expected: map[string]bool{ "postlink": true, + { + name: "link flag", + args: []string{"--link"}, + expected: map[string]bool{ + "link": true, + }, + }, + }, }, } @@ -446,6 +455,7 @@ func TestFlagParsing(t *testing.T) { install := flag.Bool("install", false, "force reinstall") postinstall := flag.Bool("postinstall", false, "run only postinstall hooks") postlink := flag.Bool("postlink", false, "run only postlink hooks") + link := flag.Bool("link", false, "link configs only (no installs)") // Parse test args os.Args = append([]string{"dot"}, tt.args...) @@ -470,6 +480,12 @@ func TestFlagParsing(t *testing.T) { } } + if expected, exists := tt.expected["link"]; exists { + if *link != expected { + t.Errorf("link flag = %v, want %v", *link, expected) + } + } + if expected, exists := tt.expected["postinstall"]; exists { if *postinstall != expected { t.Errorf("postinstall flag = %v, want %v", *postinstall, expected) diff --git a/internal/component/component.go b/internal/component/component.go index 287963f..de8dc8c 100644 --- a/internal/component/component.go +++ b/internal/component/component.go @@ -546,6 +546,125 @@ func (m *Manager) uninstallComponent(comp profile.ComponentInfo, componentState return result } +func (m *Manager) LinkComponents(activeProfiles []string, fuzzySearch string) ([]InstallResult, error) { + components, err := m.profileManager.GetActiveComponents(activeProfiles, fuzzySearch) + if err != nil { + return nil, err + } + + var results []InstallResult + for _, comp := range components { + result := InstallResult{Component: comp} + + if len(comp.Component.Link) == 0 { + result.Skipped = true + results = append(results, result) + continue + } + + if !m.linkManager.NeedsLinking(comp.Component.Link) { + result.Skipped = true + results = append(results, result) + continue + } + + linkResults, err := m.linkManager.CreateLinks(comp.Component.Link) + result.LinkResults = linkResults + if err != nil { + result.Error = fmt.Errorf("linking failed: %w", err) + results = append(results, result) + continue + } + + for _, lr := range linkResults { + if !lr.WasSuccessful() { + result.Error = fmt.Errorf("linking failed: %v", lr.Error) + break + } + } + if result.Error != nil { + results = append(results, result) + continue + } + + if comp.Component.PostLink != "" && len(result.LinkResults) > 0 { + execResult := m.execManager.ExecuteShellCommand(comp.Component.PostLink) + result.PostLinkResult = &execResult + if !execResult.Success { + result.Error = fmt.Errorf("post-link hook failed: %w", execResult.Error) + } + } + + results = append(results, result) + } + + return results, nil +} + +func (m *Manager) LinkComponentsWithProgress(activeProfiles []string, fuzzySearch string, progressManager *ui.ProgressManager) ([]InstallResult, error) { + components, err := m.profileManager.GetActiveComponents(activeProfiles, fuzzySearch) + if err != nil { + return nil, err + } + + var results []InstallResult + for _, comp := range components { + result := InstallResult{Component: comp} + progress := progressManager.NewComponentProgress(comp.ComponentName) + + if len(comp.Component.Link) == 0 { + result.Skipped = true + results = append(results, result) + continue + } + + if !m.linkManager.NeedsLinking(comp.Component.Link) { + result.Skipped = true + results = append(results, result) + continue + } + + progress.StartLinking() + linkResults, err := m.linkManager.CreateLinks(comp.Component.Link) + result.LinkResults = linkResults + if err != nil { + result.Error = fmt.Errorf("linking failed: %w", err) + progress.CompleteFailed(result.Error) + results = append(results, result) + continue + } + + for _, lr := range linkResults { + if !lr.WasSuccessful() { + result.Error = fmt.Errorf("linking failed: %v", lr.Error) + progress.CompleteFailed(result.Error) + break + } + } + if result.Error != nil { + results = append(results, result) + continue + } + + if comp.Component.PostLink != "" && len(result.LinkResults) > 0 { + progress.StartPostHooks() + execResult := m.execManager.ExecuteShellCommandWithProgress(comp.Component.PostLink, progress) + result.PostLinkResult = &execResult + if !execResult.Success { + result.Error = fmt.Errorf("post-link hook failed: %w", execResult.Error) + progress.CompleteFailed(result.Error) + results = append(results, result) + continue + } + } + + progress.CompleteSuccess() + results = append(results, result) + } + + return results, nil +} + func (m *Manager) ExportDefaults(activeProfiles []string) error { components, err := m.profileManager.GetActiveComponents(activeProfiles, "") if err != nil { diff --git a/internal/component/component_test.go b/internal/component/component_test.go index aeb2ca0..b6ab466 100644 --- a/internal/component/component_test.go +++ b/internal/component/component_test.go @@ -255,6 +255,59 @@ func TestInstallComponentsFuzzySearch(t *testing.T) { } } +func TestLinkComponents(t *testing.T) { + manager, _ := createTestComponentManager(t, false) + + results, err := manager.LinkComponents([]string{"work"}, "") + if err != nil { + t.Fatalf("LinkComponents() error = %v", err) + } + + // Should have bash (from *) + git and docker (from work) + if len(results) != 3 { + t.Fatalf("LinkComponents() results count = %v, want 3", len(results)) + } + + var gitResult, dockerResult *InstallResult + for i := range results { + switch results[i].Component.ComponentName { + case "git": + gitResult = &results[i] + case "docker": + dockerResult = &results[i] + } + } + + if gitResult == nil || dockerResult == nil { + t.Fatal("Expected git and docker results") + } + + if gitResult.InstallResult != nil { + t.Error("git InstallResult should be nil in link-only mode") + } + if dockerResult.InstallResult != nil { + t.Error("docker InstallResult should be nil in link-only mode") + } + + if len(gitResult.LinkResults) == 0 { + t.Error("git should have link results") + } + if len(dockerResult.LinkResults) == 0 { + t.Error("docker should have link results") + } + + if gitResult.PostLinkResult == nil { + t.Error("git should have post-link result") + } + if dockerResult.PostLinkResult == nil { + t.Error("docker should have post-link result") + } + + if len(manager.stateManager.GetInstalledComponents()) != 0 { + t.Error("state should not be modified by link-only operations") + } +} + func TestUninstallRemovedComponents(t *testing.T) { manager, _ := createTestComponentManager(t, true) From e0115a0065eddf52b99cd4f69e7abafe7323180f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:29:55 +0000 Subject: [PATCH 2/2] test: fix TestFlagParsing table for --link; docs: document link-only mode under Advanced Usage Co-Authored-By: Pablo P Varela --- README.md | 36 +++++++++++++----------------------- cmd/dot/main_test.go | 14 ++------------ 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d5b952b..6fcdd39 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,3 @@ -# dot - -Quickly bootstrap and manage your development environment using profiles and components. - -## Usage - -- Install and link as usual: - - dot - - dot work laptop - - dot neovim - -- Link-only mode (no installs, no defaults, no state persistence): - - dot --link - - dot --link work laptop - - dot --link neovim - -Link-only mode: -- Only creates or updates symlinks defined in components (component.link) -- Runs postlink hooks when present -- Supports the same profile selection and fuzzy search -- Does not run install commands or export/import defaults -- Does not persist profiles or other state changes - # dot - all your computers in one repo @@ -236,6 +213,19 @@ dot -v work ### Advanced Usage +#### Link-only mode +- Create or update symlinks defined in components without running installs or macOS defaults +- Runs postlink hooks when links are created or updated +- Supports the same profile selection and fuzzy search +- Does not persist state changes + +Examples: +```bash +dot --link +dot --link work laptop +dot --link neovim +``` + ```bash # Fuzzy search for components dot git # Installs any component matching "git" diff --git a/cmd/dot/main_test.go b/cmd/dot/main_test.go index 3d491bf..6394c78 100644 --- a/cmd/dot/main_test.go +++ b/cmd/dot/main_test.go @@ -431,6 +431,8 @@ func TestFlagParsing(t *testing.T) { args: []string{"--postlink"}, expected: map[string]bool{ "postlink": true, + }, + }, { name: "link flag", args: []string{"--link"}, @@ -438,17 +440,12 @@ func TestFlagParsing(t *testing.T) { "link": true, }, }, - - }, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Reset flag package state flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) - // Redefine flags for this test verbose := flag.Bool("v", false, "verbose output") verboseLong := flag.Bool("verbose", false, "verbose output") dryRun := flag.Bool("dry-run", false, "preview actions without making changes") @@ -457,41 +454,34 @@ func TestFlagParsing(t *testing.T) { postlink := flag.Bool("postlink", false, "run only postlink hooks") link := flag.Bool("link", false, "link configs only (no installs)") - // Parse test args os.Args = append([]string{"dot"}, tt.args...) flag.Parse() - // Check expected values if expected, exists := tt.expected["verbose"]; exists { if (*verbose || *verboseLong) != expected { t.Errorf("verbose flag = %v, want %v", (*verbose || *verboseLong), expected) } } - if expected, exists := tt.expected["dry-run"]; exists { if *dryRun != expected { t.Errorf("dry-run flag = %v, want %v", *dryRun, expected) } } - if expected, exists := tt.expected["install"]; exists { if *install != expected { t.Errorf("install flag = %v, want %v", *install, expected) } } - if expected, exists := tt.expected["link"]; exists { if *link != expected { t.Errorf("link flag = %v, want %v", *link, expected) } } - if expected, exists := tt.expected["postinstall"]; exists { if *postinstall != expected { t.Errorf("postinstall flag = %v, want %v", *postinstall, expected) } } - if expected, exists := tt.expected["postlink"]; exists { if *postlink != expected { t.Errorf("postlink flag = %v, want %v", *postlink, expected)