diff --git a/commands/config_install.go b/commands/config_install.go index 6ebc1ded..ec464f09 100644 --- a/commands/config_install.go +++ b/commands/config_install.go @@ -12,6 +12,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/symfony-cli/terminal" "github.com/upsun/cli/internal/config" @@ -102,7 +103,7 @@ func runConfigInstall(cmd *cobra.Command, args []string) error { cmd.PrintErrf("The executable runs %s with the new configuration.\n", color.CyanString(formatPath(target))) cmd.PrintErrln() - if terminal.Stdin.IsInteractive() { + if terminal.Stdin.IsInteractive() && !viper.GetBool("no-interaction") { if !terminal.AskConfirmation("Are you sure you want to continue?", true) { os.Exit(1) } diff --git a/go.mod b/go.mod index 6c5c820f..5ee5123a 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( golang.org/x/crypto v0.42.0 golang.org/x/oauth2 v0.31.0 golang.org/x/sync v0.17.0 + golang.org/x/sys v0.36.0 golang.org/x/term v0.35.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -128,7 +129,6 @@ require ( golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect diff --git a/internal/config/alt/fs.go b/internal/config/alt/fs.go index 37f33718..7ed448bc 100644 --- a/internal/config/alt/fs.go +++ b/internal/config/alt/fs.go @@ -14,8 +14,19 @@ const ( homeSubDir = ".platform-alt" ) +// var so tests can stub os.Executable. +var executableFn = os.Executable + // FindConfigDir finds an appropriate destination directory for an "alt" CLI configuration YAML file. +// +// XDG_CONFIG_HOME is honored explicitly because os.UserConfigDir ignores it on macOS and Windows. +// Per the XDG Base Directory spec, an explicitly set value is honored regardless of whether the +// directory already exists — writeFile creates parents as needed. func FindConfigDir() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, subDir), nil + } + userConfigDir, err := os.UserConfigDir() if err != nil { return "", err @@ -36,36 +47,109 @@ func FindConfigDir() (string, error) { return filepath.Join(homeDir, homeSubDir), nil } -// FindBinDir finds an appropriate destination directory for an "alt" CLI executable. +// FindBinDir picks a bin directory from a per-OS allowlist. It prefers the allowlist entry that +// already holds the running executable (so the alt installs alongside its source binary in +// package-manager layouts), falling back to the first allowlist entry that is on PATH and +// writable, then ~/.platform-alt/bin. func FindBinDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("could not determine home directory: %w", err) } - var candidates []string - if runtime.GOOS == "windows" { - candidates = []string{ - filepath.Join(homeDir, "AppData", "Local", "Programs"), + candidates := binDirAllowlist(homeDir) + pathValue := os.Getenv("PATH") + matchExe := exeMatcher(homeDir) + + var firstValid string + for _, c := range candidates { + if !inPathValue(c, pathValue) || !isWritableDir(c) { + continue + } + if matchExe(c) { + return c, nil + } + if firstValid == "" { + firstValid = c + } + } + + if firstValid != "" { + return firstValid, nil + } + return filepath.Join(homeDir, homeSubDir, "bin"), nil +} + +// exeMatcher returns a predicate that reports whether a candidate bin directory holds the +// running executable. The symlink branch handles bin directories injected via XDG_BIN_HOME +// that contain a symlink into a separate install location (e.g. a Cellar-style layout): the +// candidate's path doesn't match os.Executable directly, but the symlinked entry resolves to +// the same file. +func exeMatcher(homeDir string) func(string) bool { + exe, err := executableFn() + if err != nil { + return func(string) bool { return false } + } + normExeDir := normalizePathEntry(filepath.Dir(exe), homeDir) + exeBase := filepath.Base(exe) + resolvedExe, resolveErr := filepath.EvalSymlinks(exe) + + return func(c string) bool { + if normalizePathEntry(c, homeDir) == normExeDir { + return true + } + if resolveErr != nil { + return false + } + resolved, err := filepath.EvalSymlinks(filepath.Join(c, exeBase)) + return err == nil && resolved == resolvedExe + } +} + +func binDirAllowlist(homeDir string) []string { + xdgBinHome := os.Getenv("XDG_BIN_HOME") + + // Homebrew bin directories (/opt/homebrew/bin on macOS, /home/linuxbrew/.linuxbrew/bin on + // Linux) are deliberately omitted: those directories are managed by Homebrew, and we should + // not deposit binaries there. + var raw []string + switch runtime.GOOS { + case "darwin": + raw = []string{ + "/usr/local/bin", + xdgBinHome, filepath.Join(homeDir, ".local", "bin"), filepath.Join(homeDir, "bin"), } - } else { - candidates = []string{ + case "windows": + raw = []string{ + filepath.Join(homeDir, "scoop", "shims"), + filepath.Join(homeDir, "AppData", "Local", "Programs"), + xdgBinHome, + filepath.Join(homeDir, ".local", "bin"), + } + default: + raw = []string{ + xdgBinHome, filepath.Join(homeDir, ".local", "bin"), filepath.Join(homeDir, "bin"), } } - // Use the first candidate that is in the PATH. - pathValue := os.Getenv("PATH") - for _, c := range candidates { - if inPathValue(c, pathValue) { - return c, nil + out := make([]string, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, p := range raw { + if p == "" { + continue + } + norm := normalizePathEntry(p, homeDir) + if _, ok := seen[norm]; ok { + continue } + seen[norm] = struct{}{} + out = append(out, p) } - - return filepath.Join(homeDir, homeSubDir, "bin"), nil + return out } // isExistingDirectory checks if a path exists and is a directory. diff --git a/internal/config/alt/fs_test.go b/internal/config/alt/fs_test.go index caeeec71..11642dc8 100644 --- a/internal/config/alt/fs_test.go +++ b/internal/config/alt/fs_test.go @@ -13,57 +13,175 @@ import ( func TestFindConfigDir(t *testing.T) { tempDir := t.TempDir() - t.Run("XDG_CONFIG_HOME exists", func(t *testing.T) { - switch runtime.GOOS { - case "windows", "darwin", "ios", "plan9": + t.Run("XDG_CONFIG_HOME set", func(t *testing.T) { + if runtime.GOOS == "plan9" { t.Skip() } - err := os.Setenv("XDG_CONFIG_HOME", tempDir) - require.NoError(t, err) - defer os.Unsetenv("XDG_CONFIG_HOME") - - err = os.Mkdir(filepath.Join(tempDir, subDir), 0o755) - require.NoError(t, err) + t.Setenv("XDG_CONFIG_HOME", tempDir) result, err := FindConfigDir() assert.NoError(t, err) assert.Equal(t, filepath.Join(tempDir, subDir), result) }) + t.Run("XDG_CONFIG_HOME honored even when target does not yet exist", func(t *testing.T) { + if runtime.GOOS == "plan9" { + t.Skip() + } + nonExistent := filepath.Join(tempDir, "does-not-exist-yet") + t.Setenv("XDG_CONFIG_HOME", nonExistent) + + result, err := FindConfigDir() + assert.NoError(t, err) + assert.Equal(t, filepath.Join(nonExistent, subDir), result) + }) + t.Run("HOME fallback", func(t *testing.T) { - err := os.Setenv("HOME", tempDir) - require.NoError(t, err) - defer os.Unsetenv("HOME") + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", tempDir) result, err := FindConfigDir() assert.NoError(t, err) - assert.Equal(t, filepath.Join(tempDir, homeSubDir), result) + // On platforms where os.UserConfigDir resolves to an existing directory under the test + // HOME, that directory wins. + ucd, ucdErr := os.UserConfigDir() + isDir, _ := isExistingDirectory(ucd) + if ucdErr == nil && isDir { + assert.Equal(t, filepath.Join(ucd, subDir), result) + } else { + assert.Equal(t, filepath.Join(tempDir, homeSubDir), result) + } }) } func TestFindBinDir(t *testing.T) { - tempDir := t.TempDir() + if runtime.GOOS == "windows" { + t.Skip("path setup in this test is unix-style") + } - err := os.Setenv("HOME", tempDir) - require.NoError(t, err) - defer os.Unsetenv("HOME") + originalExecFn := executableFn + t.Cleanup(func() { executableFn = originalExecFn }) - result, err := FindBinDir() - assert.NoError(t, err) - assert.Equal(t, filepath.Join(tempDir, homeSubDir, "bin"), result) + setExe := func(p string) { executableFn = func() (string, error) { return p, nil } } - var standardDir string - if runtime.GOOS == "windows" { - standardDir = filepath.Join("AppData", "Local", "Programs") - } else { - standardDir = filepath.Join(".local", "bin") - } - err = os.Setenv("PATH", os.Getenv("PATH")+string(os.PathListSeparator)+filepath.Join(tempDir, standardDir)) - require.NoError(t, err) + t.Run("home fallback when nothing on PATH", func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + t.Setenv("PATH", "/nonexistent/dir") + t.Setenv("XDG_BIN_HOME", "") + setExe(filepath.Join(tempDir, "exe")) - result, err = FindBinDir() - assert.NoError(t, err) - assert.Equal(t, filepath.Join(tempDir, standardDir), result) + result, err := FindBinDir() + assert.NoError(t, err) + assert.Equal(t, filepath.Join(tempDir, homeSubDir, "bin"), result) + }) + + t.Run("allowlist fallback picks first writable PATH entry", func(t *testing.T) { + tempDir := t.TempDir() + localBin := filepath.Join(tempDir, ".local", "bin") + require.NoError(t, os.MkdirAll(localBin, 0o755)) + t.Setenv("HOME", tempDir) + t.Setenv("PATH", localBin) + t.Setenv("XDG_BIN_HOME", "") + setExe(filepath.Join(tempDir, "build", "exe")) + + result, err := FindBinDir() + assert.NoError(t, err) + assert.Equal(t, localBin, result) + }) + + t.Run("source-dir match on allowlist co-locates", func(t *testing.T) { + tempDir := t.TempDir() + sourceBin := filepath.Join(tempDir, "bin") + higherPriorityBin := filepath.Join(tempDir, ".local", "bin") + require.NoError(t, os.MkdirAll(sourceBin, 0o755)) + require.NoError(t, os.MkdirAll(higherPriorityBin, 0o755)) + t.Setenv("HOME", tempDir) + t.Setenv("PATH", higherPriorityBin+string(os.PathListSeparator)+sourceBin) + t.Setenv("XDG_BIN_HOME", "") + setExe(filepath.Join(sourceBin, "exe")) + + result, err := FindBinDir() + assert.NoError(t, err) + assert.Equal(t, sourceBin, result) + }) + + t.Run("symlinked source dir is co-located via allowlist", func(t *testing.T) { + tempDir := t.TempDir() + shimBin := filepath.Join(tempDir, "shim", "bin") + realBin := filepath.Join(tempDir, "real", "bin") + require.NoError(t, os.MkdirAll(shimBin, 0o755)) + require.NoError(t, os.MkdirAll(realBin, 0o755)) + realExe := filepath.Join(realBin, "exe") + require.NoError(t, os.WriteFile(realExe, []byte("#!/bin/sh\n"), 0o600)) + require.NoError(t, os.Symlink(realExe, filepath.Join(shimBin, "exe"))) + + higherPriorityBin := filepath.Join(tempDir, ".local", "bin") + require.NoError(t, os.MkdirAll(higherPriorityBin, 0o755)) + t.Setenv("HOME", tempDir) + t.Setenv("PATH", higherPriorityBin+string(os.PathListSeparator)+shimBin) + // XDG_BIN_HOME pulls shimBin onto the allowlist; the running exe lives outside the + // allowlist but is reachable via a symlink from shimBin, so co-location should still + // pick shimBin. + t.Setenv("XDG_BIN_HOME", shimBin) + setExe(realExe) + + result, err := FindBinDir() + assert.NoError(t, err) + assert.Equal(t, shimBin, result) + }) + + t.Run("nvm-style source dir is not selected", func(t *testing.T) { + tempDir := t.TempDir() + nvmBin := filepath.Join(tempDir, ".nvm", "versions", "node", "v20.0.0", "bin") + localBin := filepath.Join(tempDir, ".local", "bin") + require.NoError(t, os.MkdirAll(nvmBin, 0o755)) + require.NoError(t, os.MkdirAll(localBin, 0o755)) + t.Setenv("HOME", tempDir) + t.Setenv("PATH", nvmBin+string(os.PathListSeparator)+localBin) + t.Setenv("XDG_BIN_HOME", "") + setExe(filepath.Join(nvmBin, "upsun")) + + result, err := FindBinDir() + assert.NoError(t, err) + assert.Equal(t, localBin, result) + }) + + t.Run("XDG_BIN_HOME is honored", func(t *testing.T) { + tempDir := t.TempDir() + xdgBin := filepath.Join(tempDir, "xdg-bin") + require.NoError(t, os.MkdirAll(xdgBin, 0o755)) + t.Setenv("HOME", tempDir) + t.Setenv("XDG_BIN_HOME", xdgBin) + t.Setenv("PATH", xdgBin) + setExe(filepath.Join(tempDir, "exe")) + + result, err := FindBinDir() + assert.NoError(t, err) + assert.Equal(t, xdgBin, result) + }) + + t.Run("non-writable PATH entry is skipped", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root; cannot create a non-writable directory") + } + tempDir := t.TempDir() + readOnlyBin := filepath.Join(tempDir, ".local", "bin") + writableBin := filepath.Join(tempDir, "bin") + require.NoError(t, os.MkdirAll(readOnlyBin, 0o755)) + require.NoError(t, os.MkdirAll(writableBin, 0o755)) + require.NoError(t, os.Chmod(readOnlyBin, 0o555)) + t.Cleanup(func() { _ = os.Chmod(readOnlyBin, 0o755) }) + + t.Setenv("HOME", tempDir) + t.Setenv("PATH", readOnlyBin+string(os.PathListSeparator)+writableBin) + t.Setenv("XDG_BIN_HOME", "") + setExe(filepath.Join(tempDir, "exe")) + + result, err := FindBinDir() + assert.NoError(t, err) + assert.Equal(t, writableBin, result) + }) } func TestFSHelpers(t *testing.T) { diff --git a/internal/config/alt/fs_unix.go b/internal/config/alt/fs_unix.go new file mode 100644 index 00000000..f1e4a4e1 --- /dev/null +++ b/internal/config/alt/fs_unix.go @@ -0,0 +1,19 @@ +//go:build !windows + +package alt + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// isWritableDir reports whether path is a directory the current user can write to. The check is +// permission-based (no temp file is created), so it is safe to call repeatedly. +func isWritableDir(path string) bool { + stat, err := os.Stat(path) + if err != nil || !stat.IsDir() { + return false + } + return unix.Access(path, unix.W_OK) == nil +} diff --git a/internal/config/alt/fs_windows.go b/internal/config/alt/fs_windows.go new file mode 100644 index 00000000..d4cbf02a --- /dev/null +++ b/internal/config/alt/fs_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package alt + +import "os" + +// isWritableDir reports whether path is a directory the current user can write to. Windows +// has no equivalent of access(2), so the check is performed by creating and removing a probe +// file. +func isWritableDir(path string) bool { + stat, err := os.Stat(path) + if err != nil || !stat.IsDir() { + return false + } + f, err := os.CreateTemp(path, ".platform-alt-write-check-*") + if err != nil { + return false + } + name := f.Name() + if cerr := f.Close(); cerr != nil { + _ = os.Remove(name) + return false + } + return os.Remove(name) == nil +}