diff --git a/internal/approve/ioctl_windows.go b/internal/approve/ioctl_windows.go deleted file mode 100644 index 7b32618..0000000 --- a/internal/approve/ioctl_windows.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build windows - -package approve - -const ( - ioctlGetTermios = 0 - ioctlSetTermios = 0 -) diff --git a/internal/approve/prompt_shared.go b/internal/approve/prompt_shared.go index 9856e38..9f13b15 100644 --- a/internal/approve/prompt_shared.go +++ b/internal/approve/prompt_shared.go @@ -2,12 +2,13 @@ package approve import ( "fmt" + "os" "strings" "github.com/php-workx/fuse/internal/sanitize" ) -var errNonInteractive = fmt.Errorf("fuse:NON_INTERACTIVE_MODE STOP. Approval requires an interactive terminal (/dev/tty unavailable)") +var errNonInteractive = fmt.Errorf("fuse:NON_INTERACTIVE_MODE STOP. Approval requires an interactive terminal (console unavailable)") var errPromptTimeout = fmt.Errorf("fuse:TIMEOUT_WAITING_FOR_USER STOP. The user did not approve this action in time") @@ -19,3 +20,27 @@ func sanitizePrompt(s string) string { s = strings.ReplaceAll(s, "\r", " ") return s } + +// getContextVars returns relevant environment variables for the prompt. +// Used by both Unix and Windows prompt implementations. +func getContextVars() string { + relevantVars := []string{ + "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", + "TF_WORKSPACE", "TF_VAR_environment", + "KUBECONFIG", "KUBECONTEXT", + "GCP_PROJECT", "GOOGLE_CLOUD_PROJECT", + "AZURE_SUBSCRIPTION", + } + + var result string + for _, v := range relevantVars { + val := os.Getenv(v) + if val != "" { + if result != "" { + result += ", " + } + result += v + "=" + val + } + } + return result +} diff --git a/internal/approve/prompt_test.go b/internal/approve/prompt_test.go index 2bc0bc3..3388ed8 100644 --- a/internal/approve/prompt_test.go +++ b/internal/approve/prompt_test.go @@ -1,6 +1,9 @@ package approve -import "testing" +import ( + "strings" + "testing" +) // Comprehensive sanitization tests are in internal/sanitize/sanitize_test.go. // This test verifies the delegation wrapper works. @@ -29,3 +32,50 @@ func TestSanitizePrompt_StripsNewlines(t *testing.T) { t.Errorf("newlines not replaced: got %q", got) } } + +// clearTrackedVars blanks all env vars that getContextVars monitors, +// ensuring test isolation regardless of the host environment. +func clearTrackedVars(t *testing.T) { + t.Helper() + for _, v := range []string{ + "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", + "TF_WORKSPACE", "TF_VAR_environment", + "KUBECONFIG", "KUBECONTEXT", + "GCP_PROJECT", "GOOGLE_CLOUD_PROJECT", + "AZURE_SUBSCRIPTION", + } { + t.Setenv(v, "") + } +} + +func TestGetContextVars_Empty(t *testing.T) { + clearTrackedVars(t) + + got := getContextVars() + if got != "" { + t.Errorf("expected empty string, got %q", got) + } +} + +func TestGetContextVars_SingleVar(t *testing.T) { + clearTrackedVars(t) + t.Setenv("AWS_PROFILE", "prod") + + got := getContextVars() + if got != "AWS_PROFILE=prod" { + t.Errorf("expected AWS_PROFILE=prod, got %q", got) + } +} + +func TestGetContextVars_MultipleVars(t *testing.T) { + t.Setenv("AWS_PROFILE", "staging") + t.Setenv("KUBECONFIG", "/home/user/.kube/config") + got := getContextVars() + // Both should appear, comma-separated. + if !strings.Contains(got, "AWS_PROFILE=staging") { + t.Errorf("missing AWS_PROFILE in %q", got) + } + if !strings.Contains(got, "KUBECONFIG=/home/user/.kube/config") { + t.Errorf("missing KUBECONFIG in %q", got) + } +} diff --git a/internal/approve/prompt_unix.go b/internal/approve/prompt_unix.go index 4e9d525..6ca87ad 100644 --- a/internal/approve/prompt_unix.go +++ b/internal/approve/prompt_unix.go @@ -105,7 +105,7 @@ func readApprovalDecision(ctx context.Context, tty *os.File, deadline time.Time, select { case <-ctx.Done(): fmt.Fprintf(tty, "\n Denied (shutdown).\n\n") - return false, "", nil + return false, "", fmt.Errorf("approval interrupted: %w", ctx.Err()) case <-sigCh: fmt.Fprintf(tty, "\n Denied (signal received).\n\n") return false, "", nil @@ -249,26 +249,3 @@ func renderPrompt(tty *os.File, command, reason string) { fmt.Fprintf(tty, " \033[1;32m[A]pprove\033[0m | \033[1;31m[D]eny\033[0m\n") fmt.Fprintf(tty, " > ") } - -// getContextVars returns relevant environment variables for the prompt. -func getContextVars() string { - relevantVars := []string{ - "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", - "TF_WORKSPACE", "TF_VAR_environment", - "KUBECONFIG", "KUBECONTEXT", - "GCP_PROJECT", "GOOGLE_CLOUD_PROJECT", - "AZURE_SUBSCRIPTION", - } - - var result string - for _, v := range relevantVars { - val := os.Getenv(v) - if val != "" { - if result != "" { - result += ", " - } - result += v + "=" + val - } - } - return result -} diff --git a/internal/approve/prompt_windows.go b/internal/approve/prompt_windows.go index b433dd6..b12490f 100644 --- a/internal/approve/prompt_windows.go +++ b/internal/approve/prompt_windows.go @@ -2,13 +2,323 @@ package approve -import "context" - -// PromptUser on Windows returns errNonInteractive immediately. -// This stub is only reached from run mode (runner.go handleApprovalCommand). -// Hook mode short-circuits at hook.go handleApproval before calling -// RequestApproval, so this function is never invoked from the hook path. -// Planned replacement: Phase 3 (Windows Console API prompts). -func PromptUser(_ context.Context, _, _ string, _, _ bool) (bool, string, error) { - return false, "", errNonInteractive +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "sync" + "time" + + "golang.org/x/sys/windows" +) + +// ttyMu serializes concurrent console approval prompts. Without this, two +// goroutines could both open CONIN$/CONOUT$ and fight over console mode. +var ttyMu sync.Mutex + +// PromptUser shows a TUI approval prompt on the Windows console (CONIN$/CONOUT$). +// Returns the user's decision (approved bool), chosen scope, and any error. +// hookMode: true = short prompt timeout (25s), false = 5min timeout. +func PromptUser(ctx context.Context, command, reason string, hookMode, nonInteractive bool) (approved bool, scope string, err error) { + // Fast path: non-interactive mode returns immediately without locking. + if nonInteractive || os.Getenv("FUSE_NON_INTERACTIVE") != "" { + return false, "", errNonInteractive + } + + // Use TryLock to avoid blocking on the mutex for minutes when another + // approval prompt holds the lock. If the lock is unavailable, the DB poll + // goroutine can still resolve the request via the TUI. + if !ttyMu.TryLock() { + return false, "", errNonInteractive + } + defer ttyMu.Unlock() + + conIn, conOut, err := openConsole(false) // already checked non-interactive above + if err != nil { + return false, "", err + } + defer func() { _ = conIn.Close() }() + defer func() { _ = conOut.Close() }() + + inHandle := windows.Handle(conIn.Fd()) + outHandle := windows.Handle(conOut.Fd()) + + // Save original console modes (input and output). + var origMode uint32 + if err := windows.GetConsoleMode(inHandle, &origMode); err != nil { + return false, "", fmt.Errorf("get console mode: %w", err) + } + var origOutMode uint32 + hasOutMode := windows.GetConsoleMode(outHandle, &origOutMode) == nil + + // Restore console modes on panic. + defer func() { + if r := recover(); r != nil { + _ = windows.SetConsoleMode(inHandle, origMode) + if hasOutMode { + _ = windows.SetConsoleMode(outHandle, origOutMode) + } + fmt.Fprintf(os.Stderr, "fuse: prompt panic recovered: %v\n", r) + approved = false + scope = "" + err = fmt.Errorf("prompt panic: %v", r) + } + }() + + // Set up signal handling. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) // only os.Interrupt on Windows (no SIGTERM/SIGHUP) + defer signal.Stop(sigCh) + + // Enter raw mode: clear line input, echo, processed input, mouse, and window events. + rawMode := origMode &^ (windows.ENABLE_LINE_INPUT | + windows.ENABLE_ECHO_INPUT | + windows.ENABLE_PROCESSED_INPUT | + windows.ENABLE_MOUSE_INPUT | + windows.ENABLE_WINDOW_INPUT) + if err := windows.SetConsoleMode(inHandle, rawMode); err != nil { + return false, "", fmt.Errorf("set raw console mode: %w", err) + } + + // Ensure console modes are always restored (input + output). + restoreConsole := func() { + _ = windows.SetConsoleMode(inHandle, origMode) + if hasOutMode { + _ = windows.SetConsoleMode(outHandle, origOutMode) + } + } + defer restoreConsole() + + // Flush any stale input before rendering the prompt. + _ = windows.FlushConsoleInputBuffer(inHandle) + + // Determine timeout. + timeout := 5 * time.Minute + if hookMode { + timeout = 25 * time.Second + } + + // Render the prompt and read the user's decision. + renderPrompt(conOut, command, reason) + deadline := time.Now().Add(timeout) + return readApprovalDecision(ctx, conIn, conOut, deadline, sigCh) +} + +// openConsole opens CONIN$ and CONOUT$ for interactive prompts. +// Uses os.OpenFile (not GetStdHandle) for anti-spoofing: CONIN$/CONOUT$ +// always refer to the real console, even if stdin/stdout are redirected. +func openConsole(nonInteractive bool) (conIn, conOut *os.File, err error) { + if nonInteractive || os.Getenv("FUSE_NON_INTERACTIVE") != "" { + return nil, nil, errNonInteractive + } + conIn, err = os.OpenFile("CONIN$", os.O_RDWR, 0) + if err != nil { + slog.Debug("failed to open CONIN$", "error", err) + return nil, nil, errNonInteractive + } + conOut, err = os.OpenFile("CONOUT$", os.O_RDWR, 0) + if err != nil { + _ = conIn.Close() + slog.Debug("failed to open CONOUT$", "error", err) + return nil, nil, errNonInteractive + } + return conIn, conOut, nil +} + +// readApprovalDecision polls the console for the user's approve/deny decision. +func readApprovalDecision(ctx context.Context, conIn, conOut *os.File, deadline time.Time, sigCh <-chan os.Signal) (bool, string, error) { + inHandle := windows.Handle(conIn.Fd()) + buf := make([]byte, 1) + + for { + select { + case <-ctx.Done(): + fmt.Fprintf(conOut, "\n Denied (shutdown).\n\n") + return false, "", fmt.Errorf("approval interrupted: %w", ctx.Err()) + case <-sigCh: + fmt.Fprintf(conOut, "\n Denied (signal received).\n\n") + return false, "", nil + default: // non-blocking: fall through to deadline + read + } + + if time.Now().After(deadline) { + fmt.Fprintf(conOut, "\n Timed out. The command remains pending — approve via fuse monitor.\n\n") + return false, "", errPromptTimeout + } + + // Wait up to 100ms for input to become available. + event, waitErr := windows.WaitForSingleObject(inHandle, 100) + if event == 0xFFFFFFFF { // WAIT_FAILED + return false, "", fmt.Errorf("console wait failed: %w", waitErr) + } + if event != windows.WAIT_OBJECT_0 { + continue // timeout — loop back to check ctx/deadline/signals + } + + n, err := conIn.Read(buf) + if err != nil { + return false, "", fmt.Errorf("console read: %w", err) + } + if n == 0 { + continue + } + + ch := buf[0] + + // Ctrl-C arrives as byte 0x03 with ENABLE_PROCESSED_INPUT cleared. + if ch == 3 { + fmt.Fprintf(conOut, "\n Denied (Ctrl-C).\n\n") + return false, "", nil + } + + switch ch { + case 'a', 'A', 'y', 'Y': + fmt.Fprintf(conOut, "\n Approved. Select scope:\n") + fmt.Fprintf(conOut, " [o] once | [c] command | [s] session | [f] forever\n") + fmt.Fprintf(conOut, " > ") + + scopeResult, denied := readScope(ctx, conIn, conOut, deadline, sigCh) + if denied { + return false, "", nil + } + fmt.Fprintf(conOut, "\n Scope: %s\n\n", scopeResult) + return true, scopeResult, nil + + case 'd', 'D', 'n', 'N': + fmt.Fprintf(conOut, "\n Denied.\n\n") + return false, "", nil + + default: + fmt.Fprintf(conOut, "\r Press: [a]pprove or [d]eny ") + } + } +} + +// readScope reads the scope selection from the user. +// Returns the scope string and whether the user denied. +func readScope(ctx context.Context, conIn, conOut *os.File, deadline time.Time, sigCh <-chan os.Signal) (string, bool) { + inHandle := windows.Handle(conIn.Fd()) + buf := make([]byte, 1) + + for { + select { + case <-ctx.Done(): + fmt.Fprintf(conOut, "\n Denied (shutdown).\n\n") + return "", true + case <-sigCh: + fmt.Fprintf(conOut, "\n Denied (signal received).\n\n") + return "", true + default: + } + + if time.Now().After(deadline) { + fmt.Fprintf(conOut, "\n Denied (timeout).\n\n") + return "", true + } + + // Wait up to 100ms for input to become available. + event, _ := windows.WaitForSingleObject(inHandle, 100) + if event == 0xFFFFFFFF { // WAIT_FAILED — console handle invalid + return "", true // deny on failure + } + if event != windows.WAIT_OBJECT_0 { + continue + } + + n, err := conIn.Read(buf) + if err != nil { + slog.Debug("console read failed while selecting approval scope", "error", err) + return "", true // console error — deny + } + if n == 0 { + continue + } + + ch := buf[0] + + // Ctrl-C. + if ch == 3 { + fmt.Fprintf(conOut, "\n Denied (Ctrl-C).\n\n") + return "", true + } + + switch ch { + case 'o', 'O': + return "once", false + case 'c', 'C': + return "command", false + case 's', 'S': + return "session", false + case 'f', 'F': + return "forever", false + default: + fmt.Fprintf(conOut, "\r Scope: [o]nce [c]ommand [s]ession [f]orever > ") + } + } +} + +// renderPrompt writes the approval prompt to the console output. +// Attempts ANSI color output first; falls back to plain text if VT processing +// is not available. +func renderPrompt(conOut *os.File, command, reason string) { + outHandle := windows.Handle(conOut.Fd()) + + // Try to enable ANSI/VT processing on the output handle. + var outMode uint32 + if err := windows.GetConsoleMode(outHandle, &outMode); err == nil { + if err := windows.SetConsoleMode(outHandle, outMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING); err == nil { + // VT processing enabled — use ANSI colors. + renderPromptANSI(conOut, command, reason) + return + } + } + + // Fallback: plain text without ANSI escape sequences. + renderPromptPlain(conOut, command, reason) +} + +// renderPromptANSI writes the prompt with ANSI color escape sequences. +func renderPromptANSI(conOut *os.File, command, reason string) { + contextVars := getContextVars() + cwd, _ := os.Getwd() + + fmt.Fprintf(conOut, "\n") + fmt.Fprintf(conOut, " \033[1;33m--- fuse: approval required ---\033[0m\n") + fmt.Fprintf(conOut, "\n") + fmt.Fprintf(conOut, " \033[1mAgent requested:\033[0m %s\n", sanitizePrompt(command)) + fmt.Fprintf(conOut, " \033[1mCwd:\033[0m %s\n", sanitizePrompt(cwd)) + fmt.Fprintf(conOut, " \033[1mRisk:\033[0m APPROVAL\n") + if reason != "" { + fmt.Fprintf(conOut, " \033[1mReason:\033[0m %s\n", sanitizePrompt(reason)) + } + if contextVars != "" { + fmt.Fprintf(conOut, " \033[1mContext:\033[0m %s\n", sanitizePrompt(contextVars)) + } + fmt.Fprintf(conOut, "\n") + fmt.Fprintf(conOut, " \033[1;32m[A]pprove\033[0m | \033[1;31m[D]eny\033[0m\n") + fmt.Fprintf(conOut, " > ") +} + +// renderPromptPlain writes the prompt without ANSI escape sequences. +func renderPromptPlain(conOut *os.File, command, reason string) { + contextVars := getContextVars() + cwd, _ := os.Getwd() + + fmt.Fprintf(conOut, "\n") + fmt.Fprintf(conOut, " --- fuse: approval required ---\n") + fmt.Fprintf(conOut, "\n") + fmt.Fprintf(conOut, " Agent requested: %s\n", sanitizePrompt(command)) + fmt.Fprintf(conOut, " Cwd: %s\n", sanitizePrompt(cwd)) + fmt.Fprintf(conOut, " Risk: APPROVAL\n") + if reason != "" { + fmt.Fprintf(conOut, " Reason: %s\n", sanitizePrompt(reason)) + } + if contextVars != "" { + fmt.Fprintf(conOut, " Context: %s\n", sanitizePrompt(contextVars)) + } + fmt.Fprintf(conOut, "\n") + fmt.Fprintf(conOut, " [A]pprove | [D]eny\n") + fmt.Fprintf(conOut, " > ") } diff --git a/internal/approve/prompt_windows_test.go b/internal/approve/prompt_windows_test.go new file mode 100644 index 0000000..afe2ff0 --- /dev/null +++ b/internal/approve/prompt_windows_test.go @@ -0,0 +1,87 @@ +//go:build windows + +package approve + +import ( + "os" + "strings" + "testing" +) + +// TestOpenConsole_NonInteractiveFlag verifies that openConsole returns +// errNonInteractive when the nonInteractive flag is set. +func TestOpenConsole_NonInteractiveFlag(t *testing.T) { + conIn, conOut, err := openConsole(true) + if err != errNonInteractive { + t.Errorf("expected errNonInteractive, got %v", err) + } + if conIn != nil { + _ = conIn.Close() + t.Error("conIn should be nil when nonInteractive is true") + } + if conOut != nil { + _ = conOut.Close() + t.Error("conOut should be nil when nonInteractive is true") + } +} + +// TestOpenConsole_NonInteractiveEnv verifies that openConsole returns +// errNonInteractive when FUSE_NON_INTERACTIVE is set. +func TestOpenConsole_NonInteractiveEnv(t *testing.T) { + t.Setenv("FUSE_NON_INTERACTIVE", "1") + conIn, conOut, err := openConsole(false) + if err != errNonInteractive { + t.Errorf("expected errNonInteractive, got %v", err) + } + if conIn != nil { + _ = conIn.Close() + t.Error("conIn should be nil when FUSE_NON_INTERACTIVE is set") + } + if conOut != nil { + _ = conOut.Close() + t.Error("conOut should be nil when FUSE_NON_INTERACTIVE is set") + } +} + +// TestPromptUser_NonInteractiveFlag verifies PromptUser returns +// errNonInteractive when the nonInteractive parameter is true. +func TestPromptUser_NonInteractiveFlag(t *testing.T) { + _, _, err := PromptUser(t.Context(), "rm -rf /", "dangerous", false, true) + if err != errNonInteractive { + t.Errorf("expected errNonInteractive, got %v", err) + } +} + +// TestPromptUser_NonInteractiveEnv verifies PromptUser returns +// errNonInteractive when FUSE_NON_INTERACTIVE env var is set. +func TestPromptUser_NonInteractiveEnv(t *testing.T) { + t.Setenv("FUSE_NON_INTERACTIVE", "1") + _, _, err := PromptUser(t.Context(), "rm -rf /", "dangerous", false, false) + if err != errNonInteractive { + t.Errorf("expected errNonInteractive, got %v", err) + } +} + +// TestRenderPromptPlain_RendersContent verifies that the plain prompt renderer +// outputs the command, reason, and header. +func TestRenderPromptPlain_RendersContent(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "prompt-test-*") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + defer func() { _ = f.Close() }() + + renderPromptPlain(f, "echo hello", "test reason") + + content, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatalf("read temp file: %v", err) + } + got := string(content) + + for _, want := range []string{"echo hello", "test reason", "fuse: approval required"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q:\n%s", want, got) + } + } +} diff --git a/internal/cli/doctor_live_windows.go b/internal/cli/doctor_live_windows.go index 9b5b6d8..845ea60 100644 --- a/internal/cli/doctor_live_windows.go +++ b/internal/cli/doctor_live_windows.go @@ -2,19 +2,82 @@ package cli +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + func checkLiveTTYAccess() checkResult { + f, err := os.OpenFile("CONIN$", os.O_RDWR, 0) + if err != nil { + return checkResult{ + name: "Live console CONIN$ access", + status: "WARN", + detail: fmt.Sprintf("cannot open CONIN$: %v", err), + } + } + defer func() { _ = f.Close() }() + + var mode uint32 + if err := windows.GetConsoleMode(windows.Handle(f.Fd()), &mode); err != nil { + return checkResult{ + name: "Live console CONIN$ access", + status: "WARN", + detail: fmt.Sprintf("CONIN$ is not a console: %v", err), + } + } return checkResult{ - name: "Live terminal access", - status: "SKIP", - detail: "not yet supported on Windows (planned: Phase 3)", + name: "Live console CONIN$ access", + status: "PASS", + detail: "CONIN$ opened and verified as console", } } func checkLiveRawMode() checkResult { + f, err := os.OpenFile("CONIN$", os.O_RDWR, 0) + if err != nil { + return checkResult{ + name: checkNameLiveRawMode, + status: "WARN", + detail: fmt.Sprintf("cannot open CONIN$: %v", err), + } + } + defer func() { _ = f.Close() }() + + handle := windows.Handle(f.Fd()) + + var origMode uint32 + if err := windows.GetConsoleMode(handle, &origMode); err != nil { + return checkResult{ + name: checkNameLiveRawMode, + status: "WARN", + detail: fmt.Sprintf("raw mode not available: %v", err), + } + } + + rawMode := origMode &^ (windows.ENABLE_LINE_INPUT | windows.ENABLE_ECHO_INPUT) + if err := windows.SetConsoleMode(handle, rawMode); err != nil { + return checkResult{ + name: checkNameLiveRawMode, + status: "WARN", + detail: fmt.Sprintf("enter raw mode: %v", err), + } + } + + if err := windows.SetConsoleMode(handle, origMode); err != nil { + return checkResult{ + name: checkNameLiveRawMode, + status: "WARN", + detail: fmt.Sprintf("restore console mode: %v", err), + } + } + return checkResult{ name: checkNameLiveRawMode, - status: "SKIP", - detail: "not yet supported on Windows (planned: Phase 3)", + status: "PASS", + detail: "entered and restored raw mode on CONIN$", } } @@ -22,6 +85,6 @@ func checkLiveForegroundProcessGroup() checkResult { return checkResult{ name: checkNameLiveForegroundHandoff, status: "SKIP", - detail: "not yet supported on Windows (planned: Phase 4)", + detail: "Windows job object support not yet implemented (planned: Phase 4)", } } diff --git a/internal/cli/doctor_termios_windows.go b/internal/cli/doctor_termios_windows.go deleted file mode 100644 index 5a48f5e..0000000 --- a/internal/cli/doctor_termios_windows.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build windows - -package cli - -const ( - doctorIoctlGetTermios = 0 - doctorIoctlSetTermios = 0 -) diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 291ce57..8e91638 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -428,7 +428,9 @@ func TestRunDoctorSecurity_WarnsWhenClaudeMCPDownstreamNameIsMissingOrUnknown(t func TestRunDoctorLive_ReportsTerminalCapabilityChecks(t *testing.T) { if runtime.GOOS == "windows" { - t.Skip("terminal capability checks not yet supported on Windows (planned: Phase 3)") + // Windows console checks require a real console (CONIN$). + // CI runners typically don't have one — skip if unavailable. + t.Skip("Windows terminal checks require interactive console (not available in CI)") } tmpDir := t.TempDir() t.Setenv("FUSE_HOME", tmpDir) diff --git a/internal/cli/help.go b/internal/cli/help.go index a3f7a22..3edeb07 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -54,7 +54,8 @@ func initHelp() { rootCmd.SetUsageTemplate(usageTemplate) } -// shouldColorize returns true when stdout is a terminal and color is not suppressed. +// shouldColorize returns true when stdout is a terminal that supports ANSI +// escape sequences and color is not suppressed. func shouldColorize() bool { if os.Getenv("NO_COLOR") != "" { return false @@ -62,7 +63,7 @@ func shouldColorize() bool { if os.Getenv("TERM") == "dumb" { return false } - return isTerminal(int(os.Stdout.Fd())) + return isTerminal(int(os.Stdout.Fd())) && supportsANSI() } // helpRenderer applies optional ANSI styling to help output. diff --git a/internal/cli/help_width_unix.go b/internal/cli/help_width_unix.go index 8c20263..7037291 100644 --- a/internal/cli/help_width_unix.go +++ b/internal/cli/help_width_unix.go @@ -20,3 +20,9 @@ func isTerminal(fd int) bool { _, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) return err == nil } + +// supportsANSI returns true if the terminal supports ANSI escape sequences. +// All modern Unix terminals support ANSI. +func supportsANSI() bool { + return true +} diff --git a/internal/cli/help_width_windows.go b/internal/cli/help_width_windows.go index ab643d7..3b45f85 100644 --- a/internal/cli/help_width_windows.go +++ b/internal/cli/help_width_windows.go @@ -2,14 +2,44 @@ package cli -// terminalWidth returns a default width on Windows. -// Real terminal width detection not yet supported on Windows (planned: Phase 3). +import "golang.org/x/sys/windows" + func terminalWidth() int { + conOut, err := windows.GetStdHandle(windows.STD_OUTPUT_HANDLE) + if err != nil || conOut == windows.InvalidHandle { + return 80 + } + var info windows.ConsoleScreenBufferInfo + if err := windows.GetConsoleScreenBufferInfo(conOut, &info); err != nil { + return 80 + } + width := int(info.Window.Right - info.Window.Left + 1) + if width > 0 { + return width + } return 80 } -// isTerminal returns false on Windows as a conservative default. -// Real terminal detection not yet supported on Windows (planned: Phase 3). -func isTerminal(_ int) bool { - return false +func isTerminal(fd int) bool { + var mode uint32 + return windows.GetConsoleMode(windows.Handle(fd), &mode) == nil +} + +// supportsANSI probes whether the console supports ANSI/VT escape sequences +// by attempting to enable ENABLE_VIRTUAL_TERMINAL_PROCESSING. Legacy conhost +// (pre-Windows 10 1511) does not support this flag and the call fails. +func supportsANSI() bool { + conOut, err := windows.GetStdHandle(windows.STD_OUTPUT_HANDLE) + if err != nil || conOut == windows.InvalidHandle { + return false + } + var mode uint32 + if err := windows.GetConsoleMode(conOut, &mode); err != nil { + return false + } + if err := windows.SetConsoleMode(conOut, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING); err != nil { + return false + } + _ = windows.SetConsoleMode(conOut, mode) // restore original + return true }