Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion commands/config_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
112 changes: 98 additions & 14 deletions internal/config/alt/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
miguelsanchez-upsun marked this conversation as resolved.

userConfigDir, err := os.UserConfigDir()
if err != nil {
return "", err
Expand All @@ -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"),
}
Comment thread
miguelsanchez-upsun marked this conversation as resolved.
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.
Expand Down
180 changes: 149 additions & 31 deletions internal/config/alt/fs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
miguelsanchez-upsun marked this conversation as resolved.
}
})
}

func TestFindBinDir(t *testing.T) {
tempDir := t.TempDir()
if runtime.GOOS == "windows" {
t.Skip("path setup in this test is unix-style")
}
Comment on lines +58 to +60
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestFindBinDir is now skipped entirely on Windows, but this PR introduces/changes Windows-specific allowlist entries (e.g., Scoop shims). Consider adding Windows-only tests (e.g., via _test.go with //go:build windows) to cover the Windows branch of binDirAllowlist() and FindBinDir() behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think this is needed, there's no Windows-specific logic to exercise beyond what the existing branches do on Linux/macOS.


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) {
Expand Down
Loading
Loading