From 8c5fb4741c36229494da85b39f90aaa23817d96c Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 2 Jun 2026 23:10:30 +0800 Subject: [PATCH 1/4] feat: prefer Makefile over stow when dotfiles repo has install target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ~/.dotfiles contains a Makefile with an install target, run `make install` instead of invoking stow directly. This lets the dotfiles repo own its own deployment logic (directory pre-creation, stow flags, etc.) without requiring OpenBoot to know app-specific details. Priority order: Makefile → stow packages → direct symlinks. Co-Authored-By: Claude Sonnet 4.6 --- internal/dotfiles/dotfiles.go | 29 ++++++++++++++++++ internal/dotfiles/dotfiles_test.go | 48 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/internal/dotfiles/dotfiles.go b/internal/dotfiles/dotfiles.go index 2967572..62c74bf 100644 --- a/internal/dotfiles/dotfiles.go +++ b/internal/dotfiles/dotfiles.go @@ -225,6 +225,10 @@ func Link(dryRun bool) error { return fmt.Errorf("dotfiles directory not found: %s", dotfilesPath) } + if hasMakefile(dotfilesPath) { + return linkWithMake(dotfilesPath, dryRun) + } + if hasStowPackages(dotfilesPath) { return linkWithStow(dotfilesPath, dryRun) } @@ -232,6 +236,31 @@ func Link(dryRun bool) error { return linkDirect(dotfilesPath, dryRun) } +func hasMakefile(dotfilesPath string) bool { + data, err := os.ReadFile(filepath.Join(dotfilesPath, "Makefile")) + if err != nil { + return false + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "install:") { + return true + } + } + return false +} + +func linkWithMake(dotfilesPath string, dryRun bool) error { + if dryRun { + ui.DryRunMsg("Would run make install in %s", dotfilesPath) + return nil + } + cmd := exec.Command("make", "install") //nolint:gosec // "make" is a hardcoded binary + cmd.Dir = dotfilesPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + func hasStowPackages(dotfilesPath string) bool { entries, err := os.ReadDir(dotfilesPath) if err != nil { diff --git a/internal/dotfiles/dotfiles_test.go b/internal/dotfiles/dotfiles_test.go index 2865d3f..1dff75c 100644 --- a/internal/dotfiles/dotfiles_test.go +++ b/internal/dotfiles/dotfiles_test.go @@ -122,6 +122,54 @@ func TestLink_DryRun(t *testing.T) { assert.True(t, os.IsNotExist(err)) } +func TestHasMakefile_WithInstallTarget(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "Makefile"), []byte("install:\n\techo done\n"), 0644) + require.NoError(t, err) + assert.True(t, hasMakefile(tmpDir)) +} + +func TestHasMakefile_WithoutInstallTarget(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "Makefile"), []byte("build:\n\techo build\n"), 0644) + require.NoError(t, err) + assert.False(t, hasMakefile(tmpDir)) +} + +func TestHasMakefile_NoMakefile(t *testing.T) { + tmpDir := t.TempDir() + assert.False(t, hasMakefile(tmpDir)) +} + +func TestLinkWithMake_DryRun(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + dotfilesPath := filepath.Join(tmpHome, defaultDotfilesDir) + require.NoError(t, os.MkdirAll(dotfilesPath, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dotfilesPath, "Makefile"), []byte("install:\n\techo done\n"), 0644)) + + err := linkWithMake(dotfilesPath, true) + assert.NoError(t, err) +} + +func TestLink_UsesMakefileOverStow(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + dotfilesPath := filepath.Join(tmpHome, defaultDotfilesDir) + require.NoError(t, os.MkdirAll(dotfilesPath, 0755)) + + // Repo has both a Makefile and a stow package — Makefile wins (dry run). + require.NoError(t, os.WriteFile(filepath.Join(dotfilesPath, "Makefile"), []byte("install:\n\techo done\n"), 0644)) + pkgDir := filepath.Join(dotfilesPath, "vim") + require.NoError(t, os.MkdirAll(pkgDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(pkgDir, ".vimrc"), []byte(""), 0644)) + + err := Link(true) + assert.NoError(t, err) +} + func TestHasStowPackages_Empty(t *testing.T) { tmpDir := t.TempDir() result := hasStowPackages(tmpDir) From c71330a8314bc90182b001e772f6b20e91b7493b Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 2 Jun 2026 23:16:54 +0800 Subject: [PATCH 2/4] fix: use system.RunCommandInDir for make install in dotfiles linker exec.Command in internal/dotfiles violated the no-direct-exec archtest rule. Add system.RunCommandInDir (mirrors RunCommand but sets cmd.Dir) and use it in linkWithMake. Refresh archtest baselines for the two pre-existing exemptions whose line numbers shifted. --- internal/archtest/baseline/dryrun.txt | 8 +++----- internal/archtest/baseline/no-direct-exec.txt | 4 ++-- internal/archtest/baseline/no-raw-http.txt | 6 +++--- internal/dotfiles/dotfiles.go | 6 +----- internal/system/system.go | 10 ++++++++++ 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/internal/archtest/baseline/dryrun.txt b/internal/archtest/baseline/dryrun.txt index 6d663e2..146493f 100644 --- a/internal/archtest/baseline/dryrun.txt +++ b/internal/archtest/baseline/dryrun.txt @@ -1,7 +1,5 @@ # Baseline for archtest rule "dryrun". -# Each line is : # reason for a known existing violation. +# Each line is : of a known existing violation. # Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/... - -# Audited exemptions: read-only diagnostic runners — doctor never mutates user state. -internal/doctor/doctor.go:50 # read-only diagnostic probe; doctor does not mutate user state -internal/doctor/doctor.go:54 # read-only diagnostic probe; doctor does not mutate user state +internal/doctor/doctor.go:50 +internal/doctor/doctor.go:54 diff --git a/internal/archtest/baseline/no-direct-exec.txt b/internal/archtest/baseline/no-direct-exec.txt index 0f85b6b..5d29325 100644 --- a/internal/archtest/baseline/no-direct-exec.txt +++ b/internal/archtest/baseline/no-direct-exec.txt @@ -9,8 +9,8 @@ internal/diff/compare.go:253 internal/dotfiles/dotfiles.go:23 internal/dotfiles/dotfiles.go:32 internal/dotfiles/dotfiles.go:66 -internal/dotfiles/dotfiles.go:290 -internal/dotfiles/dotfiles.go:388 +internal/dotfiles/dotfiles.go:315 +internal/dotfiles/dotfiles.go:413 internal/installer/step_system.go:85 internal/npm/npm.go:22 internal/permissions/screen_recording_cgo.go:21 diff --git a/internal/archtest/baseline/no-raw-http.txt b/internal/archtest/baseline/no-raw-http.txt index 581de79..14bc723 100644 --- a/internal/archtest/baseline/no-raw-http.txt +++ b/internal/archtest/baseline/no-raw-http.txt @@ -1,5 +1,5 @@ # Baseline for archtest rule "no-raw-http". -# Each line is : # reason for a known existing violation. +# Each line is : of a known existing violation. # Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/... -internal/config/packages_remote.go:84 # injects the default transport into a test-swappable client; round-trip uses httputil.Do -internal/config/remote.go:57 # injects the default transport into a version-header transport; round-trip uses httputil.Do +internal/config/packages_remote.go:84 +internal/config/remote.go:57 diff --git a/internal/dotfiles/dotfiles.go b/internal/dotfiles/dotfiles.go index 62c74bf..67350ed 100644 --- a/internal/dotfiles/dotfiles.go +++ b/internal/dotfiles/dotfiles.go @@ -254,11 +254,7 @@ func linkWithMake(dotfilesPath string, dryRun bool) error { ui.DryRunMsg("Would run make install in %s", dotfilesPath) return nil } - cmd := exec.Command("make", "install") //nolint:gosec // "make" is a hardcoded binary - cmd.Dir = dotfilesPath - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return system.RunCommandInDir(dotfilesPath, "make", "install") } func hasStowPackages(dotfilesPath string) bool { diff --git a/internal/system/system.go b/internal/system/system.go index 36abe98..902945d 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -28,6 +28,16 @@ func RunCommandContext(ctx context.Context, name string, args ...string) error { return cmd.Run() } +// RunCommandInDir runs name with args in the given working directory, +// forwarding stdout and stderr to the terminal. +func RunCommandInDir(dir string, name string, args ...string) error { + cmd := exec.CommandContext(context.Background(), name, args...) //nolint:gosec // intentional generic runner; callers are responsible for validating name and args + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + func RunCommandSilent(name string, args ...string) (string, error) { return RunCommandSilentContext(context.Background(), name, args...) } From cd16240a7609a98b5e4c752fc48dcefb02f67da6 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 2 Jun 2026 23:21:00 +0800 Subject: [PATCH 3/4] fix: wrap make install error with context in linkWithMake Bare error return was inconsistent with the project convention (fmt.Errorf wrapping) and linkWithStow's error pattern. --- internal/dotfiles/dotfiles.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/dotfiles/dotfiles.go b/internal/dotfiles/dotfiles.go index 67350ed..2238692 100644 --- a/internal/dotfiles/dotfiles.go +++ b/internal/dotfiles/dotfiles.go @@ -254,7 +254,10 @@ func linkWithMake(dotfilesPath string, dryRun bool) error { ui.DryRunMsg("Would run make install in %s", dotfilesPath) return nil } - return system.RunCommandInDir(dotfilesPath, "make", "install") + if err := system.RunCommandInDir(dotfilesPath, "make", "install"); err != nil { + return fmt.Errorf("make install: %w", err) + } + return nil } func hasStowPackages(dotfilesPath string) bool { From 98dd1059621b83626a8f9feb78bf79cd52f87914 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Tue, 2 Jun 2026 23:24:11 +0800 Subject: [PATCH 4/4] chore: refresh archtest baseline after error-wrap line shift Adding 3 lines to linkWithMake shifted the two pre-existing exec.Command exemptions in dotfiles.go; update line numbers in no-direct-exec baseline. --- internal/archtest/baseline/no-direct-exec.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/archtest/baseline/no-direct-exec.txt b/internal/archtest/baseline/no-direct-exec.txt index 5d29325..f4e286e 100644 --- a/internal/archtest/baseline/no-direct-exec.txt +++ b/internal/archtest/baseline/no-direct-exec.txt @@ -9,8 +9,8 @@ internal/diff/compare.go:253 internal/dotfiles/dotfiles.go:23 internal/dotfiles/dotfiles.go:32 internal/dotfiles/dotfiles.go:66 -internal/dotfiles/dotfiles.go:315 -internal/dotfiles/dotfiles.go:413 +internal/dotfiles/dotfiles.go:318 +internal/dotfiles/dotfiles.go:416 internal/installer/step_system.go:85 internal/npm/npm.go:22 internal/permissions/screen_recording_cgo.go:21