Guard subcommand TUI entry against non-interactive

Amolith created

When a wrapped subcommand is invoked with no args, the code now checks
isInteractive() before entering the TUI session. In non-interactive
environments (stdin not a tty or KELD_NONINTERACTIVE set), the command
runs directly with resolved config instead of crashing with "could not
open TTY".

This fixes the systemd timer regression where backup invocations would
fail because root's TraverseChildren consumes --preset, leaving the
subcommand with an empty args slice that incorrectly triggered the TUI.

Task: td-B2KD251

Change summary

cmd/subcommands.go      |   4 
cmd/subcommands_test.go | 115 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 117 insertions(+), 2 deletions(-)

Detailed changes

cmd/subcommands.go 🔗

@@ -48,8 +48,8 @@ func registerSubcommands() {
 				// When invoked with no flags or arguments and the command
 				// is wrapped (has TUI screens), enter the interactive
 				// session with the command pre-selected, skipping the
-				// menu screen.
-				if len(args) == 0 && isWrappedCommand(commandName) {
+				// menu screen. Only do this when running interactively.
+				if len(args) == 0 && isWrappedCommand(commandName) && isInteractive() {
 					cmdName, preset, overrides, err := runInteractive(commandName)
 					if err != nil {
 						return err

cmd/subcommands_test.go 🔗

@@ -0,0 +1,115 @@
+package cmd
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestSubcommandNoArgsNonInteractiveRunsDirectly(t *testing.T) {
+	// Not parallel: mutates package-level isStdinTerminal.
+
+	// Override stdin terminal check to simulate non-interactive environment.
+	original := isStdinTerminal
+	t.Cleanup(func() {
+		isStdinTerminal = original
+	})
+	isStdinTerminal = func() bool { return false }
+
+	// Create a config with backup paths defined so the command can run.
+	configFile := setupConfigWithBackupPaths(t)
+	setRootFlagValuesForTest(t, "home@cloud", true, configFile)
+	t.Setenv("KELD_CONFIG_FILE", "")
+	t.Setenv("KELD_DRYRUN", "")
+
+	backup := lookupSubcommand(t, "backup")
+	out, err := captureStdout(t, func() error {
+		return backup.RunE(backup, []string{})
+	})
+
+	// Should NOT get a bubbletea TTY error. Instead, should either:
+	// - Succeed with dry-run output (if config has paths), or
+	// - Fail with a sensible error about missing inputs.
+	if err != nil {
+		errMsg := err.Error()
+		// The old bug would produce "bubbletea: error opening TTY" here.
+		if strings.Contains(errMsg, "bubbletea") || strings.Contains(errMsg, "TTY") {
+			t.Fatalf("non-interactive mode incorrectly tried to open TTY: %v", err)
+		}
+		// A different error is acceptable (e.g., missing paths).
+		// For now we just verify no TTY error occurred.
+		t.Logf("command returned error (expected for missing paths): %v", err)
+		return
+	}
+
+	// If no error, we should see dry-run output.
+	if !strings.Contains(out, `"backup"`) {
+		t.Fatalf("expected dry-run output to contain backup command, got:\n%s", out)
+	}
+}
+
+// setupConfigWithBackupPaths creates a config file with a preset that has
+// backup paths defined, allowing the backup command to run without args.
+func setupConfigWithBackupPaths(t *testing.T) string {
+	t.Helper()
+
+	dir := t.TempDir()
+	cfg := filepath.Join(dir, "config.toml")
+	err := os.WriteFile(cfg, []byte(`
+[global]
+repo = "/repos/default"
+
+["home@"]
+tag = "home"
+
+["home@".backup]
+_arguments = "/src /dst"
+
+["@cloud"]
+repo = "/repos/cloud"
+`), 0o600)
+	if err != nil {
+		t.Fatalf("writing fixture: %v", err)
+	}
+	t.Setenv("HOME", dir)
+
+	return cfg
+}
+
+func TestSubcommandNoArgsInteractiveEntersSession(t *testing.T) {
+	// Not parallel: mutates package-level isStdinTerminal.
+
+	// Override stdin terminal check to simulate interactive terminal.
+	original := isStdinTerminal
+	t.Cleanup(func() {
+		isStdinTerminal = original
+	})
+	isStdinTerminal = func() bool { return true }
+
+	configFile := setupConfigWithBackupPaths(t)
+	setRootFlagValuesForTest(t, "", true, configFile)
+	t.Setenv("KELD_CONFIG_FILE", "")
+	t.Setenv("KELD_DRYRUN", "")
+
+	backup := lookupSubcommand(t, "backup")
+
+	// In interactive mode with no args, runInteractive is called.
+	// Since there's no real TTY attached during test, bubbletea will
+	// try to open /dev/tty and fail. We assert this behavior is happening
+	// (rather than the command running directly).
+	_, err := captureStdout(t, func() error {
+		return backup.RunE(backup, []string{})
+	})
+
+	// The expected behavior: bubbletea tries to open TTY and fails.
+	if err == nil {
+		// If it succeeded, either we're not in interactive mode or
+		// the test environment has a TTY (unlikely in CI).
+		t.Fatal("expected bubbletea TTY error when simulating interactive mode, got success")
+	}
+	errMsg := err.Error()
+	if !strings.Contains(errMsg, "TTY") && !strings.Contains(errMsg, "tty") {
+		t.Fatalf("expected bubbletea TTY error in interactive mode, got: %v", err)
+	}
+}