diff --git a/cmd/entry.go b/cmd/entry.go index 91927c7..7f65c6c 100644 --- a/cmd/entry.go +++ b/cmd/entry.go @@ -63,6 +63,13 @@ func dispatchPersistent(hasTmuxSession bool, leaseState session.LeaseState) disp } func defaultRunWorkspaceEntry(opts WorkspaceEntryOptions) error { + // v0.6: if the resolver downgraded a configured persistent mode to + // direct via the native-Windows platform override, surface the fact + // to the user once before any launch work happens. attach + // (AlwaysPersistent=true) is excluded — it has no direct-mode + // fallback and refuses on native Windows via preflightPersistentEntry. + emitPlatformOverrideWarningIfNeeded(opts.AlwaysPersistent) + mode := config.ResolveSessionMode() persistent := opts.AlwaysPersistent || (mode == "persistent" && !opts.Direct) diff --git a/cmd/persistent.go b/cmd/persistent.go index 3d46f03..b94a324 100644 --- a/cmd/persistent.go +++ b/cmd/persistent.go @@ -7,6 +7,7 @@ import ( "runtime" "time" + "github.com/warrenronsiek/ctask/internal/config" "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" ) @@ -17,6 +18,38 @@ import ( // defaultIsTTYCheck. var isTTYCheck = defaultIsTTYCheck +// platformOverrideWarning is the exact stderr line emitted when the v0.6 +// session_mode platform override (persistent → direct on native Windows) +// kicks in. Kept as a package-level string so tests can reference it +// without re-typing the user-facing copy. +const platformOverrideWarning = "[ctask] warning: persistent session mode is not supported on native Windows; using direct mode. Use WSL for persistent sessions." + +// emitPlatformOverrideWarningIfNeeded prints the platform-override +// warning once per invocation when: +// 1. the workspace entry is NOT marked AlwaysPersistent (attach has no +// direct-mode fallback and refuses via preflight rather than +// downgrading), AND +// 2. the resolver's session_mode value resolved through the +// PlatformOverride source — i.e. the user asked for persistent mode +// via config or env but the host is native Windows. +// +// Doctor and info must NOT call this helper. They render source +// attribution explicitly in their own output; emitting an additional +// stderr line from a diagnostic command would be noise. +// +// "Once per invocation" is provided implicitly by the call site in +// defaultRunWorkspaceEntry, which runs at most once per ctask command. +// No warn-once subsystem is needed in v0.6. +func emitPlatformOverrideWarningIfNeeded(alwaysPersistent bool) { + if alwaysPersistent { + return + } + s := config.LoadResolver().SessionMode() + if s.Source == config.PlatformOverride && s.Value == "direct" { + fmt.Fprintln(os.Stderr, platformOverrideWarning) + } +} + func defaultIsTTYCheck() bool { return shell.IsTTY(os.Stdin) && shell.IsTTY(os.Stdout) } diff --git a/cmd/platform_warning_test.go b/cmd/platform_warning_test.go new file mode 100644 index 0000000..0d07703 --- /dev/null +++ b/cmd/platform_warning_test.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/warrenronsiek/ctask/internal/config" +) + +// captureStderr swaps os.Stderr for a pipe, runs fn, and returns +// what was written. Mirrors captureStdout in doctor_test.go. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + prev := os.Stderr + os.Stderr = w + defer func() { os.Stderr = prev }() + fn() + w.Close() + data, _ := io.ReadAll(r) + return string(data) +} + +// writeSessionModeConfig plants a config.yaml with the given +// session_mode value and points the resolver at it. +func writeSessionModeConfig(t *testing.T, mode string) { + t.Helper() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + body := "session_mode: " + mode + "\n" + if err := os.WriteFile(cfgPath, []byte(body), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + config.SetConfigPathForTest(t, cfgPath) +} + +// TestPlatformOverrideWarningEmittedOnLaunch — a launch/entry path +// (AlwaysPersistent=false) running on simulated native Windows with +// a configured persistent session_mode receives the one-line stderr +// warning describing the downgrade. +func TestPlatformOverrideWarningEmittedOnLaunch(t *testing.T) { + clearResolverEnv(t) + writeSessionModeConfig(t, "persistent") + config.SetIsNativeWindowsForTest(t, func() bool { return true }) + + out := captureStderr(t, func() { + emitPlatformOverrideWarningIfNeeded(false) + }) + if !strings.Contains(out, "persistent session mode is not supported on native Windows") { + t.Errorf("expected platform-override warning, got %q", out) + } + if !strings.Contains(out, "using direct mode") { + t.Errorf("expected 'using direct mode' wording, got %q", out) + } + if !strings.Contains(out, "Use WSL") { + t.Errorf("expected 'Use WSL' recommendation, got %q", out) + } +} + +// TestPlatformOverrideWarningNotEmittedWhenDirect — direct mode (no +// override) emits no warning even on simulated native Windows. +func TestPlatformOverrideWarningNotEmittedWhenDirect(t *testing.T) { + clearResolverEnv(t) + writeSessionModeConfig(t, "direct") + config.SetIsNativeWindowsForTest(t, func() bool { return true }) + + out := captureStderr(t, func() { + emitPlatformOverrideWarningIfNeeded(false) + }) + if out != "" { + t.Errorf("expected no warning in direct mode, got %q", out) + } +} + +// TestPlatformOverrideWarningNotEmittedWithoutConfig — builtin +// default (direct) on any platform emits no warning. +func TestPlatformOverrideWarningNotEmittedWithoutConfig(t *testing.T) { + clearResolverEnv(t) + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + config.SetIsNativeWindowsForTest(t, func() bool { return true }) + + out := captureStderr(t, func() { + emitPlatformOverrideWarningIfNeeded(false) + }) + if out != "" { + t.Errorf("expected no warning when session_mode is builtin direct, got %q", out) + } +} + +// TestPlatformOverrideWarningNotEmittedOnNonWindows — even with a +// persistent config, when isNativeWindows reports false the override +// never fires and no warning surfaces. +func TestPlatformOverrideWarningNotEmittedOnNonWindows(t *testing.T) { + clearResolverEnv(t) + writeSessionModeConfig(t, "persistent") + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + + out := captureStderr(t, func() { + emitPlatformOverrideWarningIfNeeded(false) + }) + if out != "" { + t.Errorf("expected no warning when not native Windows, got %q", out) + } +} + +// TestPlatformOverrideWarningSkippedForAlwaysPersistent — `ctask +// attach` sets AlwaysPersistent=true and has no direct-mode +// fallback; the warning must not fire for that path. attach's +// actual refusal on native Windows continues through +// preflightPersistentEntry (TestPreflightRefusesNativeWindows). +func TestPlatformOverrideWarningSkippedForAlwaysPersistent(t *testing.T) { + clearResolverEnv(t) + writeSessionModeConfig(t, "persistent") + config.SetIsNativeWindowsForTest(t, func() bool { return true }) + + out := captureStderr(t, func() { + emitPlatformOverrideWarningIfNeeded(true) + }) + if out != "" { + t.Errorf("attach path (AlwaysPersistent) must not emit downgrade warning; got %q", out) + } +}