diff --git a/cmd/install.go b/cmd/install.go index 1afc982..353d2fe 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,7 +1,6 @@ package cmd import ( - "bufio" "errors" "fmt" "os" @@ -35,12 +34,12 @@ func promptInitConfig() error { return err } - reader := bufio.NewReader(os.Stdin) + if !isPromptTTY() { + fmt.Fprintln(os.Stderr, output.WarnStderr("bight: no config file found and stdin is not a TTY; skipping interactive setup. Create .bight.yml manually or re-run from a terminal.")) + return nil + } - fmt.Print("bight: no config file found. Create .bight.yml? [Y/n] ") - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(answer) - if answer != "" && answer != "y" && answer != "Y" { + if !confirm("bight: no config file found. Create .bight.yml?", true) { return nil } @@ -51,26 +50,24 @@ func promptInitConfig() error { defaultProject := filepath.Base(cwd) fmt.Printf(" Project name [%s]: ", defaultProject) - project, _ := reader.ReadString('\n') + project, _ := promptReader.ReadString('\n') project = strings.TrimSpace(project) if project == "" { project = defaultProject } fmt.Print(" Env file path [.env]: ") - envFile, _ := reader.ReadString('\n') + envFile, _ := promptReader.ReadString('\n') envFile = strings.TrimSpace(envFile) if envFile == "" { envFile = ".env" } var copySource string - fmt.Print(" Seed this file from another path on first worktree init? [y/N] ") - seedAnswer, _ := reader.ReadString('\n') - if v := strings.TrimSpace(seedAnswer); v == "y" || v == "Y" { + if confirm(" Seed this file from another path on first worktree init?", false) { defaultSource := envFile fmt.Printf(" Source path [%s]: ", defaultSource) - src, _ := reader.ReadString('\n') + src, _ := promptReader.ReadString('\n') copySource = strings.TrimSpace(src) if copySource == "" { copySource = defaultSource @@ -78,13 +75,11 @@ func promptInitConfig() error { } var vars []config.Var - fmt.Print(" Add env vars to track? [Y/n] ") - addVars, _ := reader.ReadString('\n') - if v := strings.TrimSpace(addVars); v == "" || v == "y" || v == "Y" { + if confirm(" Add env vars to track?", true) { fmt.Println(" (blank name to finish)") for { fmt.Print(" Var name: ") - name, _ := reader.ReadString('\n') + name, _ := promptReader.ReadString('\n') name = strings.TrimSpace(name) if name == "" { break @@ -93,7 +88,7 @@ func promptInitConfig() error { fmt.Println(" 1) template - interpolate branch/project name (default)") fmt.Println(" 2) random - fresh random value on each checkout") fmt.Print(" Choice [1]: ") - choice, _ := reader.ReadString('\n') + choice, _ := promptReader.ReadString('\n') strategy := "template" if strings.TrimSpace(choice) == "2" { strategy = "random" diff --git a/cmd/install_test.go b/cmd/install_test.go new file mode 100644 index 0000000..25e93b2 --- /dev/null +++ b/cmd/install_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "os" + "strings" + "testing" +) + +func TestPromptInitConfig_NonTTYBailsOut(t *testing.T) { + // Isolate from any real ~/.bight.yml and from the worktree's .bight.yml + // so config.Load() returns os.ErrNotExist. + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + + withStubPrompt(t, false, "") + + if err := promptInitConfig(); err != nil { + t.Fatalf("promptInitConfig: %v", err) + } + + if _, err := os.Stat(".bight.yml"); !os.IsNotExist(err) { + t.Errorf("expected no .bight.yml to be written under non-TTY, stat err=%v", err) + } +} + +func TestPromptInitConfig_TTYCreatesConfig(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + + // Accept "Create .bight.yml?" (Y), accept default project, default env + // file, decline seed, decline add vars. + withStubPrompt(t, true, strings.Join([]string{ + "y", // Create .bight.yml? + "", // Project name (default) + "", // Env file path (default) + "n", // Seed from another path? + "n", // Add env vars? + "", + }, "\n")) + + if err := promptInitConfig(); err != nil { + t.Fatalf("promptInitConfig: %v", err) + } + + if _, err := os.Stat(".bight.yml"); err != nil { + t.Errorf("expected .bight.yml to be written under TTY: %v", err) + } +} diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 0000000..02b3704 --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" +) + +// promptReader is the source confirm() and other prompts read answers +// from. Tests swap it via setPromptReader. +var promptReader = bufio.NewReader(os.Stdin) + +// promptOut is where the question is written. Tests swap it. +var promptOut io.Writer = os.Stdout + +// isPromptTTY reports whether stdin is connected to a terminal. Tests +// swap it to force the TTY / non-TTY branch independently of promptReader. +var isPromptTTY = defaultIsPromptTTY + +func defaultIsPromptTTY() bool { + stat, err := os.Stdin.Stat() + if err != nil { + return false + } + return (stat.Mode() & os.ModeCharDevice) != 0 +} + +// confirm prints question with a [Y/n] / [y/N] suffix and reads a y/n +// answer from stdin. If stdin is not a TTY, the question is echoed for +// visibility and def is returned without blocking. An empty line, EOF, +// or an unrecognized answer also yields def. +func confirm(question string, def bool) bool { + suffix := "[Y/n]" + if !def { + suffix = "[y/N]" + } + + if !isPromptTTY() { + fmt.Fprintf(promptOut, "%s %s\n", question, suffix) + return def + } + + fmt.Fprintf(promptOut, "%s %s ", question, suffix) + line, err := promptReader.ReadString('\n') + if err != nil && line == "" { + return def + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true + case "n", "no": + return false + default: + return def + } +} diff --git a/cmd/prompt_test.go b/cmd/prompt_test.go new file mode 100644 index 0000000..71eb030 --- /dev/null +++ b/cmd/prompt_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "bufio" + "bytes" + "strings" + "testing" +) + +// withStubPrompt swaps the package-level prompt I/O for the duration of a +// test and restores it on cleanup. tty controls whether isPromptTTY reports +// a terminal, and input is the bytes confirm/promptReader will read. +func withStubPrompt(t *testing.T, tty bool, input string) *bytes.Buffer { + t.Helper() + origIn := promptReader + origOut := promptOut + origTTY := isPromptTTY + + var out bytes.Buffer + promptReader = bufio.NewReader(strings.NewReader(input)) + promptOut = &out + isPromptTTY = func() bool { return tty } + + t.Cleanup(func() { + promptReader = origIn + promptOut = origOut + isPromptTTY = origTTY + }) + return &out +} + +func TestConfirm_NonTTYReturnsDefault(t *testing.T) { + out := withStubPrompt(t, false, "n\n") + if !confirm("Seed?", true) { + t.Errorf("non-TTY: expected default=true to be returned") + } + if !strings.Contains(out.String(), "Seed?") { + t.Errorf("non-TTY: expected question to be echoed, got %q", out.String()) + } +} + +func TestConfirm_EmptyLineReturnsDefault(t *testing.T) { + withStubPrompt(t, true, "\n") + if !confirm("Seed?", true) { + t.Errorf("empty input with def=true should yield true") + } +} + +func TestConfirm_ParsesYesNo(t *testing.T) { + cases := []struct { + input string + def bool + want bool + }{ + {"y\n", false, true}, + {"Y\n", false, true}, + {"yes\n", false, true}, + {"YES\n", false, true}, + {"n\n", true, false}, + {"N\n", true, false}, + {"no\n", true, false}, + {" y \n", false, true}, + {"garbage\n", true, true}, // unrecognized → def + {"garbage\n", false, false}, // unrecognized → def + } + for _, c := range cases { + t.Run(strings.TrimSpace(c.input), func(t *testing.T) { + withStubPrompt(t, true, c.input) + got := confirm("?", c.def) + if got != c.want { + t.Errorf("input=%q def=%v: got %v, want %v", c.input, c.def, got, c.want) + } + }) + } +} + +func TestConfirm_EOFReturnsDefault(t *testing.T) { + withStubPrompt(t, true, "") + if !confirm("?", true) { + t.Errorf("EOF with def=true should yield true") + } +}