From 16e012539918f43b1920ab4dba841fd3476b0cae Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Mon, 7 Sep 2026 23:14:32 -0700 Subject: [PATCH] fix(config): stop `config create` spinning forever on closed stdin `promptNoDefault` swallowed io.EOF and returned an empty string, so the "New profile name" loop in `promptExistingFileAction` rejected the empty answer and asked again, forever. Running `cinc config create < /dev/null` against a credentials file that already had profiles pinned a core and flooded stdout: a probe measured 57 million prompts in 20 seconds. The action prompt defaults to "Add a new profile", so nothing unusual is needed to reach it: any non-interactive invocation with an existing credentials file hangs. `promptNoDefault` now separates the two cases a bare `""` used to conflate. An empty line is still a real answer (the collision and replace prompts treat Enter as their default), but a reader with nothing left to give returns errStdinExhausted, which names the flags that configure a profile without prompting. `promptWithDefault` is deliberately unchanged: falling back to the default on EOF is what lets the onboarding flow accept defaults, and it cannot loop. Signed-off-by: Tim Smith --- apps/cinc/cmd/create.go | 13 +++++++++++ apps/cinc/cmd/create_test.go | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/apps/cinc/cmd/create.go b/apps/cinc/cmd/create.go index e43b906..cead7f7 100644 --- a/apps/cinc/cmd/create.go +++ b/apps/cinc/cmd/create.go @@ -2,6 +2,7 @@ package cmd import ( "bufio" + "errors" "fmt" "io" "net/url" @@ -386,12 +387,24 @@ func splitChefServerURL(raw string) (host, org string) { return u.Host, "" } +// errStdinExhausted is returned by promptNoDefault when there is no input +// left to read. The prompts that have no sensible default re-ask until they +// get an answer, so without this the flow would spin forever against a +// closed stdin (`cinc config create < /dev/null`, or a CI run). +var errStdinExhausted = errors.New("we ran out of input while waiting for an answer. `cinc config create` needs an interactive terminal; to configure without prompts, pass --client-name, --client-key, and --server-url") + +// promptNoDefault asks for an answer that has no default. An empty line is a +// valid (if usually rejected) answer, so it is reported as one; only a reader +// with nothing left to give yields errStdinExhausted. func promptNoDefault(reader *bufio.Reader, out io.Writer, label string) (string, error) { fmt.Fprintf(out, "%s: ", label) answer, err := reader.ReadString('\n') if err != nil && err != io.EOF { return "", err } + if err == io.EOF && answer == "" { + return "", errStdinExhausted + } return strings.TrimSpace(answer), nil } diff --git a/apps/cinc/cmd/create_test.go b/apps/cinc/cmd/create_test.go index 84bb650..02d9a02 100644 --- a/apps/cinc/cmd/create_test.go +++ b/apps/cinc/cmd/create_test.go @@ -6,10 +6,53 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/cinc-project/cinc-cli/cli/config" ) +// TestConfigureStopsWhenStdinIsExhausted covers `cinc config create` run +// without an interactive terminal (for example `cinc config create < +// /dev/null`, or from CI) against a credentials file that already holds +// profiles. The action prompt defaults to "Add a new profile", which then +// asks for a name it will not accept as empty. With no more input to read +// the command has to give up with a clear error rather than re-asking forever. +func TestConfigureStopsWhenStdinIsExhausted(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "credentials") + if err := config.WriteProfile(cfgPath, "default", config.Profile{ + ServerURL: "https://old.example.test", + Org: "old", + ClientName: "old", + KeyPath: "/keys/old.pem", + }); err != nil { + t.Fatal(err) + } + + root := newRootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(new(bytes.Buffer)) + root.SetIn(strings.NewReader("")) + root.SetArgs([]string{"config", "create", "--config", cfgPath}) + + done := make(chan error, 1) + go func() { done <- root.Execute() }() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected an error when stdin is exhausted, got nil") + } + if !strings.Contains(err.Error(), "ran out of input") { + t.Fatalf("error = %v, want it to explain that input ran out", err) + } + case <-time.After(10 * time.Second): + t.Fatalf("config create never returned; it asked for a profile name %d times", + strings.Count(out.String(), "New profile name")) + } +} + func TestConfigureCommandWritesTOMLCredentialsProfile(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "credentials")