From 08b0f1a6a79ab30533d241c77f3b70332cc21186 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 8 May 2026 13:51:21 -0400 Subject: [PATCH] feat(v0.5.3): shared persistent preflight (cmd/persistent.go) --- cmd/persistent.go | 86 +++++++++++++++++++++++++++ cmd/persistent_test.go | 130 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 cmd/persistent.go create mode 100644 cmd/persistent_test.go diff --git a/cmd/persistent.go b/cmd/persistent.go new file mode 100644 index 0000000..7b4978f --- /dev/null +++ b/cmd/persistent.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "runtime" + + "github.com/warrenronsiek/ctask/internal/shell" +) + +// isTTYCheck is the test seam for terminal detection. Tests override this +// package-level variable to control the TTY refusal path without depending +// on the real process's stdin/stdout state. Production callers go through +// defaultIsTTYCheck. +var isTTYCheck = defaultIsTTYCheck + +func defaultIsTTYCheck() bool { + return shell.IsTTY(os.Stdin) && shell.IsTTY(os.Stdout) +} + +// preflightPersistentEntry validates the host environment supports +// tmux-based persistent mode and returns the validated tmux binary path +// for callers to pass through `session.LaunchOpts.TmuxPath`. +// +// Order of checks: +// +// 1. Native Windows refusal — tmux is not supported; recommend WSL. +// 2. Nested tmux refusal — `$TMUX` is set in the parent process. +// 3. Non-TTY refusal — stdin or stdout is not a terminal. +// 4. shell.LookupTmux — handles ErrTmuxNotFound and ErrTmuxTooOld +// with platform-aware install hints. +// +// commandName ("new", "resume", "last", "open", "attach") is rendered into +// the bypass hint so each command tells the user the right form. +func preflightPersistentEntry(commandName string) (string, error) { + bypass := " ctask " + commandName + " --direct" + + if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" { + return "", fmt.Errorf( + "ctask persistent mode requires tmux, which is not supported on native Windows.\n\n"+ + "Recommended:\n Run ctask from WSL and install tmux there:\n sudo apt install tmux\n\n"+ + "Or bypass persistent mode:\n%s", bypass) + } + if os.Getenv("TMUX") != "" { + return "", fmt.Errorf( + "ctask persistent mode cannot attach while already inside tmux.\n\n"+ + "Run ctask from outside tmux, or bypass persistent mode:\n%s", bypass) + } + if !isTTYCheck() { + return "", fmt.Errorf( + "ctask persistent mode requires an interactive terminal.\n\n"+ + "Over SSH, use:\n ssh -t ctask %s \n\n"+ + "Or bypass persistent mode:\n%s", commandName, bypass) + } + tmuxPath, ver, err := shell.LookupTmux() + if err != nil { + if errors.Is(err, shell.ErrTmuxNotFound) { + return "", fmt.Errorf( + "ctask is configured for persistent sessions, but tmux is not installed.\n\n"+ + "Install tmux:\n"+ + " Debian/Ubuntu/WSL: sudo apt install tmux\n"+ + " macOS: brew install tmux\n"+ + " Arch: sudo pacman -S tmux\n"+ + " Fedora: sudo dnf install tmux\n\n"+ + "Or bypass persistent mode for this command:\n%s\n\n"+ + "To disable persistent mode:\n unset CTASK_SESSION_MODE", bypass) + } + if errors.Is(err, shell.ErrTmuxTooOld) { + raw := ver.Raw + if raw == "" { + raw = "unknown version" + } + return "", fmt.Errorf( + "ctask persistent mode requires tmux 3.0 or newer (found: %s).\n\n"+ + "Update tmux:\n"+ + " Debian/Ubuntu/WSL: sudo apt install tmux (Debian 10+ ships 2.8; consider backports or a newer release)\n"+ + " macOS: brew upgrade tmux\n"+ + " Arch: sudo pacman -Syu tmux\n"+ + " Fedora: sudo dnf upgrade tmux\n\n"+ + "Or bypass persistent mode for this command:\n%s", raw, bypass) + } + return "", err + } + return tmuxPath, nil +} diff --git a/cmd/persistent_test.go b/cmd/persistent_test.go new file mode 100644 index 0000000..dc7cd4e --- /dev/null +++ b/cmd/persistent_test.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "errors" + "os" + "runtime" + "strings" + "testing" + + "github.com/warrenronsiek/ctask/internal/shell" +) + +// withTTYCheck swaps the package-level isTTYCheck for the duration of the +// test. Tests that exercise refusal paths must NOT run in parallel — they +// mutate a package global. +func withTTYCheck(t *testing.T, fn func() bool) { + t.Helper() + orig := isTTYCheck + isTTYCheck = fn + t.Cleanup(func() { isTTYCheck = orig }) +} + +func TestPreflightRefusesNativeWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("native-Windows refusal applies only on Windows") + } + had := os.Getenv("WSL_DISTRO_NAME") + os.Unsetenv("WSL_DISTRO_NAME") + t.Cleanup(func() { + if had != "" { + os.Setenv("WSL_DISTRO_NAME", had) + } + }) + _, err := preflightPersistentEntry("resume") + if err == nil { + t.Fatal("expected refusal on native Windows") + } + if !strings.Contains(err.Error(), "tmux") || !strings.Contains(err.Error(), "WSL") { + t.Errorf("expected tmux+WSL message: %v", err) + } +} + +func TestPreflightRefusesNestedTmux(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("nested-tmux check runs on Unix paths only in this test") + } + withTTYCheck(t, func() bool { return true }) + os.Setenv("TMUX", "/tmp/tmux-1000/default,1234,0") + t.Cleanup(func() { os.Unsetenv("TMUX") }) + _, err := preflightPersistentEntry("resume") + if err == nil { + t.Fatal("expected refusal when $TMUX is set") + } + if !strings.Contains(err.Error(), "already inside tmux") { + t.Errorf("expected nested-tmux message: %v", err) + } +} + +func TestPreflightRefusesNonTTY(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("focus this case on Unix; Windows TTY semantics covered by manual smoke") + } + os.Unsetenv("TMUX") + withTTYCheck(t, func() bool { return false }) + _, err := preflightPersistentEntry("resume") + if err == nil { + t.Fatal("expected refusal when not a TTY") + } + if !strings.Contains(err.Error(), "interactive terminal") { + t.Errorf("expected interactive-terminal message: %v", err) + } + if !strings.Contains(err.Error(), "ssh -t") { + t.Errorf("error should mention ssh -t: %v", err) + } + if !strings.Contains(err.Error(), "ctask resume") { + t.Errorf("error should mention command-specific bypass: %v", err) + } +} + +func TestPreflightCommandNameRendersInHints(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("path-based check covered on Unix") + } + os.Unsetenv("TMUX") + withTTYCheck(t, func() bool { return false }) + _, err := preflightPersistentEntry("attach") + if err == nil || !strings.Contains(err.Error(), "ctask attach") { + t.Errorf("commandName must appear in hint: %v", err) + } +} + +func TestPreflightTmuxNotFound(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH manipulation applies on Unix here") + } + os.Unsetenv("TMUX") + withTTYCheck(t, func() bool { return true }) + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", "") + _, err := preflightPersistentEntry("resume") + if err == nil { + t.Fatal("expected refusal when tmux missing") + } + if !strings.Contains(err.Error(), "tmux is not installed") { + t.Errorf("expected install-hint message: %v", err) + } +} + +// Confirm the helper returns the validated tmux path on the happy path so +// callers can pass it through LaunchOpts.TmuxPath without re-resolving. +func TestPreflightSuccessReturnsTmuxPath(t *testing.T) { + if _, _, err := shell.LookupTmux(); err != nil { + if errors.Is(err, shell.ErrTmuxNotFound) || errors.Is(err, shell.ErrTmuxTooOld) { + t.Skip("tmux not adequate on this host") + } + } + if runtime.GOOS == "windows" { + t.Skip("happy-path test on WSL/Linux only") + } + os.Unsetenv("TMUX") + withTTYCheck(t, func() bool { return true }) + tmuxPath, err := preflightPersistentEntry("resume") + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if tmuxPath == "" { + t.Error("expected non-empty tmuxPath on success") + } +}