Skip to content
Merged
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
48 changes: 45 additions & 3 deletions integration/shell/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,52 @@ func Init(name string) {
// BashAdapter implementation
type BashAdapter struct{}

// clean child environment to avoid duplicate multishell entries and deduplicate path
func CleanChildEnv(envList []string, fd int, pid int) []string {
multishell := os.Getenv("FNM_MULTISHELL_PATH")
if multishell != "" {
_ = os.RemoveAll(multishell)
_ = os.RemoveAll(filepath.Dir(multishell))
}

var res []string
for _, env := range envList {
key, val, ok := strings.Cut(env, "=")
if !ok {
continue
}
if key == "FNM_MULTISHELL_PATH" {
continue
}
if key == "PATH" {
entries := filepath.SplitList(val)
seen := make(map[string]bool, len(entries))
var clean []string
for _, entry := range entries {
if entry == "" {
continue
}
if strings.Contains(entry, "fnm_multishells") || (multishell != "" && (entry == multishell || entry == filepath.Join(multishell, "bin"))) {
continue
}
if !seen[entry] {
seen[entry] = true
clean = append(clean, entry)
}
}
res = append(res, "PATH="+strings.Join(clean, string(os.PathListSeparator)))
continue
}
res = append(res, env)
}

return append(res, "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid))
}

func (b *BashAdapter) GetName() string { return "bash" }
func (b *BashAdapter) GetShellPath() string { return "bash" }
func (b *BashAdapter) GetEnv(fd int, pid int) []string {
return append(os.Environ(), "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid))
return CleanChildEnv(os.Environ(), fd, pid)
}
func (b *BashAdapter) PrepareSelectSequence(selected string, cursorFromEnd int) []byte {
return ReplaceLine([]byte(selected), cursorFromEnd)
Expand All @@ -85,7 +127,7 @@ type ZshAdapter struct{}
func (z *ZshAdapter) GetName() string { return "zsh" }
func (z *ZshAdapter) GetShellPath() string { return "zsh" }
func (z *ZshAdapter) GetEnv(fd int, pid int) []string {
return append(os.Environ(), "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid))
return CleanChildEnv(os.Environ(), fd, pid)
}
func (z *ZshAdapter) PrepareSelectSequence(selected string, cursorFromEnd int) []byte {
return ReplaceLine([]byte(selected), cursorFromEnd)
Expand Down Expand Up @@ -145,7 +187,7 @@ type FishAdapter struct{}
func (f *FishAdapter) GetName() string { return "fish" }
func (f *FishAdapter) GetShellPath() string { return "fish" }
func (f *FishAdapter) GetEnv(fd int, pid int) []string {
return append(os.Environ(), "IRIS_FD="+fmt.Sprint(fd), "IRIS_PID="+fmt.Sprint(pid))
return CleanChildEnv(os.Environ(), fd, pid)
}
func (f *FishAdapter) PrepareSelectSequence(selected string, cursorFromEnd int) []byte {
return ReplaceLine([]byte(selected), cursorFromEnd)
Expand Down
41 changes: 41 additions & 0 deletions integration/shell/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,44 @@ func TestPrepareSelectSequenceUsesReplaceLine(t *testing.T) {
}
}
}

func TestCleanChildEnv(t *testing.T) {
inputEnv := []string{
"USER=test",
"FNM_MULTISHELL_PATH=/Users/user/.local/state/fnm_multishells/5487_123/bin",
"PATH=/Users/user/.local/state/fnm_multishells/5487_123/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/bin",
}

got := CleanChildEnv(inputEnv, 13, 9999)

var hasFnmVar bool
var pathVal string
var hasFd, hasPid bool

for _, e := range got {
if e == "FNM_MULTISHELL_PATH" || len(e) > 19 && e[:19] == "FNM_MULTISHELL_PATH=" {
hasFnmVar = true
}
if len(e) > 5 && e[:5] == "PATH=" {
pathVal = e[5:]
}
if e == "IRIS_FD=13" {
hasFd = true
}
if e == "IRIS_PID=9999" {
hasPid = true
}
}

if hasFnmVar {
t.Errorf("CleanChildEnv must unset FNM_MULTISHELL_PATH, got: %v", got)
}
if !hasFd || !hasPid {
t.Errorf("CleanChildEnv must include IRIS_FD=13 and IRIS_PID=9999, got: %v", got)
}
wantPath := "/usr/local/bin:/usr/bin:/bin"
if pathVal != wantPath {
t.Errorf("CleanChildEnv PATH = %q; want %q", pathVal, wantPath)
}
}

17 changes: 17 additions & 0 deletions root/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ fi
# prompt to complete, and exec'ing here would seize the tty from the real iris
if [[ -o interactive ]] && [ -t 0 ] && [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then
export IRIS_ACTIVE_SHELL="zsh"
if [ -n "$FNM_MULTISHELL_PATH" ]; then
path=(${path:#*fnm_multishells*})
unset FNM_MULTISHELL_PATH
fi
exec iris
fi

Expand Down Expand Up @@ -82,6 +86,15 @@ fi
# prompt to complete, and exec'ing here would seize the tty from the real iris
if [[ $- == *i* ]] && [ -t 0 ] && [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then
export IRIS_ACTIVE_SHELL="bash"
if [ -n "$FNM_MULTISHELL_PATH" ]; then
PATH=":$PATH:"
PATH="${PATH//:$FNM_MULTISHELL_PATH\/bin:/:}"
PATH="${PATH//:$FNM_MULTISHELL_PATH:/:}"
PATH="${PATH#:}"
PATH="${PATH%%:}"
export PATH
unset FNM_MULTISHELL_PATH
fi
exec iris
fi

Expand Down Expand Up @@ -123,6 +136,10 @@ end

if status is-interactive; and not set -q IRIS_PID; and not set -q IRIS_RESCUE
set -gx IRIS_ACTIVE_SHELL "fish"
if set -q FNM_MULTISHELL_PATH
set -gx PATH (string match -v "*fnm_multishells*" $PATH)
set -e FNM_MULTISHELL_PATH
end
exec iris
end

Expand Down
194 changes: 194 additions & 0 deletions tests/tui/fnm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package tui

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/Gaurav-Gosain/tuitest"
)

func TestFnmMultishellNotDuplicatedInPath(t *testing.T) {
if _, err := exec.LookPath("zsh"); err != nil {
t.Skip("zsh not installed")
}

home := t.TempDir()
for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} {
if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil {
t.Fatal(err)
}
}

bin := binary(t)

outerFnmPath := filepath.Join(home, ".local/state/fnm_multishells/5487_outer/bin")
innerFnmPath := filepath.Join(home, ".local/state/fnm_multishells/5450_inner/bin")
_ = os.MkdirAll(outerFnmPath, 0o755)
_ = os.MkdirAll(innerFnmPath, 0o755)

// simulate .zshrc where fnm runs before iris init
zshrc := `
PROMPT='> '
RPROMPT=''
unsetopt PROMPT_SP
if [ -z "$IRIS_PID" ]; then
export FNM_MULTISHELL_PATH="` + outerFnmPath + `"
export PATH="$FNM_MULTISHELL_PATH:$PATH"
fi
eval "$(` + bin + ` init zsh)"
if [ -n "$IRIS_PID" ]; then
export FNM_MULTISHELL_PATH="` + innerFnmPath + `"
export PATH="$FNM_MULTISHELL_PATH:$PATH"
fi
`
if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(zshrc), 0o644); err != nil {
t.Fatal(err)
}

env := []string{
"HOME=" + home,
"ZDOTDIR=" + home,
"XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
"XDG_DATA_HOME=" + filepath.Join(home, ".local/share"),
"XDG_CACHE_HOME=" + filepath.Join(home, ".cache"),
"SHELL=/bin/zsh",
"IRIS_ACTIVE_SHELL=zsh",
"PATH=" + os.Getenv("PATH"),
"TERM=xterm-256color",
}

term := tuitest.StartT(t, []string{bin},
tuitest.WithSize(cols, rows),
tuitest.WithEnv(env...),
tuitest.WithDir(home),
)
defer func() { _ = term.Close() }()

if err := term.WaitForText(">", 20*time.Second); err != nil {
t.Fatalf("iris never reached a prompt: %v\n%s", err, term.Snapshot())
}

outLog := filepath.Join(home, "path_out.txt")
cmd := "echo $PATH > " + outLog + "\n"
if err := term.SendKeys(cmd); err != nil {
t.Fatal(err)
}

// wait for path dump file to be written by the shell
var writtenPath string
for range 20 {
time.Sleep(100 * time.Millisecond)
data, err := os.ReadFile(outLog)
if err == nil && len(data) > 0 {
writtenPath = string(data)
break
}
}

if writtenPath == "" {
t.Fatalf("path file was not written, screen:\n%s", screen(term))
}

if strings.Contains(writtenPath, "5487_outer") {
t.Fatalf("PATH should not contain dead outer fnm multishell (5487_outer), got:\n%s", writtenPath)
}
if !strings.Contains(writtenPath, "5450_inner") {
t.Fatalf("PATH must contain active inner fnm multishell (5450_inner), got:\n%s", writtenPath)
}
}

func TestRealFnmWithIris(t *testing.T) {
if _, err := exec.LookPath("zsh"); err != nil {
t.Skip("zsh not installed")
}

fnmBin := "/tmp/fnm-bin/fnm"
if _, err := os.Stat(fnmBin); err != nil {
if path, errLook := exec.LookPath("fnm"); errLook == nil {
fnmBin = path
} else {
t.Skip("fnm binary not available")
}
}

home := t.TempDir()
for _, sub := range []string{".config/iris", ".local/share/iris", ".cache"} {
if err := os.MkdirAll(filepath.Join(home, sub), 0o755); err != nil {
t.Fatal(err)
}
}

bin := binary(t)

// exact reproduction steps from issue #170
zshrc := `
PROMPT='> '
RPROMPT=''
unsetopt PROMPT_SP
eval "$(` + fnmBin + ` env --use-on-cd --shell zsh)"
eval "$(` + bin + ` init zsh)"
`
if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(zshrc), 0o644); err != nil {
t.Fatal(err)
}

env := []string{
"HOME=" + home,
"ZDOTDIR=" + home,
"XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
"XDG_DATA_HOME=" + filepath.Join(home, ".local/share"),
"XDG_CACHE_HOME=" + filepath.Join(home, ".cache"),
"SHELL=/bin/zsh",
"IRIS_ACTIVE_SHELL=zsh",
"PATH=" + os.Getenv("PATH"),
"TERM=xterm-256color",
}

term := tuitest.StartT(t, []string{bin},
tuitest.WithSize(cols, rows),
tuitest.WithEnv(env...),
tuitest.WithDir(home),
)
defer func() { _ = term.Close() }()

if err := term.WaitForText(">", 20*time.Second); err != nil {
t.Fatalf("iris never reached a prompt: %v\n%s", err, term.Snapshot())
}

outLog := filepath.Join(home, "path_out.txt")
cmd := "echo $PATH > " + outLog + "\n"
if err := term.SendKeys(cmd); err != nil {
t.Fatal(err)
}

var writtenPath string
for range 20 {
time.Sleep(100 * time.Millisecond)
data, err := os.ReadFile(outLog)
if err == nil && len(data) > 0 {
writtenPath = string(data)
break
}
}

if writtenPath == "" {
t.Fatalf("path file was not written, screen:\n%s", screen(term))
}

parts := strings.Split(strings.TrimSpace(writtenPath), ":")
fnmCount := 0
for _, p := range parts {
if strings.Contains(p, "fnm_multishells") {
fnmCount++
}
}

if fnmCount != 1 {
t.Fatalf("expected exactly 1 fnm_multishells entry in PATH, got %d:\n%s", fnmCount, writtenPath)
}
}

Loading