Skip to content

Fail fast on interactive prompts in non-interactive shells#209

Merged
rgarcia merged 5 commits into
mainfrom
raf/fail-fast-non-interactive
Jul 24, 2026
Merged

Fail fast on interactive prompts in non-interactive shells#209
rgarcia merged 5 commits into
mainfrom
raf/fail-fast-non-interactive

Conversation

@rgarcia

@rgarcia rgarcia commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Interactive pterm prompts (confirm / select / text input) never return when stdin is not a terminal. The underlying keyboard listener (atomicgo.dev/keyboard) swallows both the "provided file is not a console" init error and the subsequent nil-file read errors, so it spins forever re-rendering the prompt.

Empirically: kernel create < /dev/null produced ~13 MB of ANSI output in 10 seconds at 100% CPU and never exited. Any AI agent or CI script that hits a prompting code path hangs until killed — and floods the agent's context with escape codes on the way down.

Fix

pkg/interactive owns every prompt. Confirm, Select, and TextInput execute the pterm prompts behind a stdin TTY gate; command code supplies only the action, hint, and options. No direct pterm interactive calls exist outside the package, so the no-hang invariant is enforced by the boundary, not a call-site convention. In a non-interactive shell each primitive fails fast with a typed, actionable error:

Confirmation prompts → direct the caller to --yes:

ERROR: Cannot prompt for confirmation to delete deployment 'fake-id': stdin is not an interactive terminal; re-run with --yes to skip the confirmation prompt

Gated: api-keys/app/auth connections/credential-providers/credentials/deploy/extensions/profiles/proxies delete, the browsers create pool-conflict confirm, and the create overwrite confirm.

Text/select prompts → name the exact flag(s) and valid values to pass instead. kernel create (the only command with multiple promptable inputs) resolves everything up front and reports all problems in a single error — one problem per line, flag tokens never split by terminal-width re-wrapping:

ERROR: Cannot prompt for input: stdin is not an interactive terminal; fix all of the following and re-run:
  - --name is required (e.g. --name my-kernel-app)
  - --language 'ruby' is invalid: must be one of: typescript (ts), python (py)
  - --template is required (run 'kernel create --help' for the full list)

An existing target directory (which would need the overwrite confirm) is folded into the same error as a pass --yes ... item. Single-input prompts stay single-line:

ERROR: Cannot prompt for viewport selection: stdin is not an interactive terminal; pass --viewport instead of --viewport-interactive (e.g. --viewport 1920x1080@25)

All errors exit non-zero (previously, cancelled confirms exited 0, and prompt errors were silently dropped via ok, _ :=).

Design

  • pkg/interactive (new): IsInteractive() + Confirm/Select/TextInput prompt primitives + typed PromptError. Error() is single-line (Go convention, problems numbered inline); Display() renders one problem per line. The root error handler renders PromptError with fang's ErrorText width and transform unset — the width-based word-wrap split flag tokens (-- + newline + template) and the transform (strings.Fields + Join) collapsed newlines. Prompts execute through a Prompter that carries its terminal capability per value (zero value = ambient stdin detection; NewPrompterWithTerminal(false) for deterministic tests) — no package-level mutable state. Command structs carry an injected prompter field; plain-function commands use the default Prompter.
  • create.ResolveInput (new): the single canonical resolver from raw flags to a normalized CreateInput plus typed Problems (name, language, template, overwrite). Both modes consume the same problem model: the interactive flow prompts to resolve one problem at a time and re-resolves (so the template list reflects the chosen language, and invalid provided values warn before re-prompting); the non-interactive flow reports every problem in one fail-fast error. A --template that exists for no language is reported even when --language is also invalid, to avoid a hidden extra retry. Prompts in pkg/create are thin per-field wrappers with no validation logic, and every emitted Problem carries its own interactive resolution step, so the resolver/dispatcher contract is exhaustive by construction (no field switch to drift; a step-less Problem fails with an internal error naming the field instead of looping).
  • --yes stays the single skip-confirmation pattern (no --force/--confirm mixing; existing --force flags all mean something semantically different and are untouched). Two confirms had no bypass and now gain one:
    • kernel create --yes/-y — overwrite an existing directory without asking
    • kernel browsers create --yes/-y — proceed past the pool-conflict confirm (yes added to poolLeaseAllowedFlags so it doesn't count as a conflicting flag itself)
  • Updated kernel create long help to document the fail-fast behavior.

Intentionally out of scope

kernel login, kernel browsers ssh, and kernel upgrade are left alone — agents can use them as-is. (Possible follow-up: a two-step kernel login that prints the URL and confirms separately. Another possible follow-up: a forbidigo lint rule banning pterm.DefaultInteractive* outside pkg/interactive; the repo has no golangci config today.)

Testing

  • make test passes. Gating tests inject the terminal capability (interactive.NewPrompterWithTerminal(false)) instead of relying on the harness's ambient stdin — no global state, parallel-safe (verified with -race -shuffle=on -count=2 and by running the compiled test binaries under a PTY); the detector is tested against an explicit os.Pipe() fd.
  • End-to-end subprocess test: TestMain re-execs the test binary as the real CLI, TestCreateNonInteractiveE2E runs it with stdin explicitly /dev/null under a 30s timeout and asserts on final rendered stderr — exit 1, every flag token intact, each problem on its own line.
  • Resolver tests cover aggregation, per-language template validation, and dir-exists ± --yes; delete-gating tests assert the API is never called without confirmation.
  • Interactive flows verified end-to-end under a real PTY (name → language select → template select → scaffold; invalid-value warn-then-reprompt), plus < /dev/null smoke tests of every gated path.
  • Pre-existing lint warnings only (golangci-lint).

Note

Medium Risk
Touches many delete/confirm code paths and changes create’s input flow; behavior change is intentional but broad enough that a missed call site could still hang or skip confirmation handling.

Overview
Introduces pkg/interactive as the only place that runs pterm confirm/select/text prompts, gated on stdin being a TTY. When stdin is not a terminal, prompts return PromptError with actionable fixes (typically --yes for confirmations or explicit flags/values for inputs) instead of hanging.

Delete and confirm paths across api-keys, apps, auth connections, credentials, credential providers, deploy, extensions, profiles, and proxies now go through Prompter.Confirm (or package interactive.Confirm) and propagate errors instead of ignoring them with ok, _ :=.

kernel create is reworked around create.ResolveInput: one resolver builds normalized input plus ordered **Problem**s for name, language, template, and overwrite. Interactive mode resolves one problem at a time and re-resolves; non-interactive mode returns every problem in a single error. Adds --yes for overwrite without prompting and updates long help for fail-fast behavior.

kernel browsers create adds --yes for the pool-conflict confirm and routes viewport selection through interactive.Select with a non-interactive hint to use --viewport.

The root error handler renders PromptError via Display() without fang’s width wrap/transform so flag tokens stay intact on separate lines.

Tests cover injected non-TTY prompters, resolver aggregation, and an E2E subprocess of kernel create with /dev/null stdin.

Reviewed by Cursor Bugbot for commit 954f364. Bugbot is set up for automated code reviews on this repo. Configure here.

Interactive pterm prompts (confirm/select/text input) never return when
stdin is not a terminal: the underlying keyboard listener swallows read
errors and spins forever, re-rendering the prompt in a tight loop. AI
agents and CI scripts that invoke a prompting command hang until killed
(verified: 'kernel create < /dev/null' produced ~13MB of ANSI output in
10s and never exited).

Every prompt call site is now gated on a stdin TTY check and returns a
specific, actionable error instead:

- Confirmation prompts (all delete commands, the browsers-create pool
  conflict confirm, and the create overwrite confirm) direct the caller
  to re-run with --yes.
- Text/select prompts (kernel create name/language/template, browsers
  create --viewport-interactive) name the exact flag and valid values
  to pass instead.

New in this change:
- pkg/interactive: IsInteractive() plus the two shared error builders.
- kernel create --yes/-y: skip the overwrite-existing-directory confirm
  (the only confirm that had no --yes bypass, along with the pool
  conflict confirm in browsers create, which also gains --yes).
- Invalid --name/--language/--template values now error in
  non-interactive shells instead of silently falling back to a prompt.

kernel login, browsers ssh, and upgrade are intentionally left alone;
they are usable by agents as-is.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Create command wraps prompt errors
    • Updated runCreateApp to return PromptForAppName/Language/Template errors directly (without wrapper prefixes) and added a command-level regression test to enforce the contract.

Create PR

Or push these changes by commenting:

@cursor push 98875e9f2c
Preview (98875e9f2c)
diff --git a/cmd/create.go b/cmd/create.go
--- a/cmd/create.go
+++ b/cmd/create.go
@@ -152,17 +152,17 @@
 
 	appName, err := create.PromptForAppName(appName)
 	if err != nil {
-		return fmt.Errorf("failed to get app name: %w", err)
+		return err
 	}
 
 	language, err = create.PromptForLanguage(language)
 	if err != nil {
-		return fmt.Errorf("failed to get language: %w", err)
+		return err
 	}
 
 	template, err = create.PromptForTemplate(template, language)
 	if err != nil {
-		return fmt.Errorf("failed to get template: %w", err)
+		return err
 	}
 
 	c := CreateCmd{}

diff --git a/cmd/create_test.go b/cmd/create_test.go
--- a/cmd/create_test.go
+++ b/cmd/create_test.go
@@ -10,6 +10,7 @@
 
 	"github.com/kernel/cli/pkg/create"
 	"github.com/pterm/pterm"
+	"github.com/spf13/cobra"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -93,6 +94,61 @@
 	}
 }
 
+func TestRunCreateApp_DoesNotWrapPromptErrors(t *testing.T) {
+	tests := []struct {
+		name             string
+		flagValues       map[string]string
+		expectedContains string
+		unexpectedWrap   string
+	}{
+		{
+			name:             "missing app name returns direct prompt error",
+			flagValues:       map[string]string{},
+			expectedContains: "cannot prompt for app name",
+			unexpectedWrap:   "failed to get app name:",
+		},
+		{
+			name: "invalid language returns direct validation error",
+			flagValues: map[string]string{
+				"name": "my-app",
+				// template is intentionally omitted because language validation fails first.
+				"language": "ruby",
+			},
+			expectedContains: "invalid --language 'ruby'",
+			unexpectedWrap:   "failed to get language:",
+		},
+		{
+			name: "invalid template returns direct validation error",
+			flagValues: map[string]string{
+				"name":     "my-app",
+				"language": "typescript",
+				"template": "nonexistent-template",
+			},
+			expectedContains: "invalid --template 'nonexistent-template'",
+			unexpectedWrap:   "failed to get template:",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cmd := &cobra.Command{}
+			cmd.Flags().String("name", "", "")
+			cmd.Flags().String("language", "", "")
+			cmd.Flags().String("template", "", "")
+			cmd.Flags().Bool("yes", false, "")
+
+			for flag, value := range tt.flagValues {
+				require.NoError(t, cmd.Flags().Set(flag, value))
+			}
+
+			err := runCreateApp(cmd, nil)
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.expectedContains)
+			assert.NotContains(t, err.Error(), tt.unexpectedWrap)
+		})
+	}
+}
+
 // TestAllTemplatesWithDependencies tests all available templates and verifies dependencies are installed
 func TestAllTemplatesWithDependencies(t *testing.T) {
 	if testing.Short() {

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit f722ba2. Configure here.

Comment thread cmd/create.go
rgarcia added 2 commits July 24, 2026 14:08
Addresses Bugbot review on #209: runCreateApp wrapped the fail-fast
prompt errors with 'failed to get app name/language/template' prefixes,
so CLI output didn't match the documented non-interactive messages and
was inconsistent with the unwrapped overwrite-confirm error. Return
them directly and add a regression test.
Previously kernel create failed on the first promptable input it
encountered (--name, then --language, then --template), so a
non-interactive caller needed one retry per missing flag. In a
non-interactive shell, all inputs are now validated up front via
create.ValidateNonInteractive and every problem is reported in a single
error, including an existing target directory that would need --yes:

  Error: cannot prompt for input: stdin is not an interactive terminal;
  fix all of the following and re-run: (1) --name is required (e.g.
  --name my-kernel-app); (2) --language is required: one of: typescript
  (ts), python (py); (3) --template is required (run 'kernel create
  --help' for the full list)

Problems are numbered inline rather than newline-separated because the
fang/lipgloss error renderer reflows text and collapses line breaks.
A template that exists for no language is reported even when --language
is also invalid, to avoid a second retry. The per-prompt guards remain
as backstops for the interactive flow.
@rgarcia

rgarcia commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

f525658: non-interactive errors for kernel create are now aggregated — instead of failing on the first promptable input per invocation, all inputs are validated up front and every problem is reported in one error so a single retry can fix everything:

Error: cannot prompt for input: stdin is not an interactive terminal; fix all of the following and re-run: (1) --name is required (e.g. --name my-kernel-app); (2) --language is required: one of: typescript (ts), python (py); (3) --template is required (run 'kernel create --help' for the full list)

Details:

  • New create.ValidateNonInteractive(name, language, template, skipConfirm) runs in runCreateApp when stdin isn't a TTY; the interactive prompt flow is unchanged.
  • The existing-directory/--yes case is folded into the same error when the name is valid, so it doesn't surface as a second-round failure.
  • A --template value that exists for no language is reported even when --language is also invalid (avoids another retry); per-language validation applies when the language is known.
  • Problems are numbered inline ((1) ...; (2) ...) rather than newline-separated because the fang/lipgloss error renderer reflows text and collapses line breaks.
  • kernel create was the only command with multiple promptable inputs; the delete confirms and browsers create --viewport-interactive are single-input and already one-shot.

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core fail-fast behavior works, but this needs structural changes before merge. The current design leaves the no-hang invariant as a scattered call-site convention, forks kernel create into separate interactive/non-interactive validation pipelines, and tests the raw error rather than the rendered CLI boundary. No file crosses the 1k-line threshold in this PR, but this adds more policy branching to the already 3,838-line cmd/browsers.go.

make test passes and /dev/null smoke tests exit immediately, but the blockers below keep this below the maintainability bar.

Comment thread pkg/interactive/interactive.go Outdated
// Interactive prompts (pterm confirm/select/text input) read keystrokes from
// the terminal. In a non-interactive shell — an AI agent's bash tool, CI, or
// any piped stdin — they never return (the underlying keyboard listener spins
// forever), so every prompt call site must gate on IsInteractive first and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this makes the critical invariant — every prompt must be gated — an unenforced call-site convention. There are now 15 prompt invocations spread across 12 files, so one future direct pterm prompt can recreate the hang. This package should own the actual confirm/select/text execution, with command code supplying only the action, hint, and options; direct interactive pterm calls should not remain outside that boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ba28842. pkg/interactive now owns prompt execution: Confirm/Select/TextInput run the pterm prompts behind the TTY gate, and command code supplies only the action, hint, and options. Zero pterm.DefaultInteractive* calls remain outside the package (grep-verified). Two side benefits: prompt errors are no longer silently dropped (ok, _ :=), and the confirms stop mutating pterm's global DefaultInteractiveConfirm. Happy to add a forbidigo rule banning pterm.DefaultInteractive outside pkg/interactive as mechanical enforcement, but the repo has no golangci config today and make lint is || true, so I left that for a separate change.

Comment thread pkg/interactive/interactive.go Outdated
}
fmt.Fprintf(&b, "(%d) %s", i+1, p)
}
return fmt.Errorf("cannot prompt for input: stdin is not an interactive terminal; fix all of the following and re-run: %s", b.String())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this is tested below the real rendering boundary and the documented actionable output does not survive it. Running kernel --no-color create </dev/null renders the third problem as (3) -- followed by template is required on the next line, so final stderr does not contain --template. The substring assertions against err.Error() cannot catch that. Please use a typed multi-problem error with explicit rendering (or otherwise preserve tokens), and add an end-to-end assertion against the final CLI stderr.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ba28842 — good catch, and the root cause was worse than wrapping: fang's ErrorText style applies a Transform that does strings.Fields + Join(" ") (collapsing any newlines) and a width-based word-wrap that breaks on hyphens (producing the -- / template split you saw).

Now there's a typed interactive.PromptError carrying Problems []string: Error() stays single-line per Go convention (numbered inline), and Display() renders one problem per line. The root error handler errors.As-matches it and renders Display() with UnsetWidth().UnsetTransform(), so flag tokens are never split:

ERROR: Cannot prompt for input: stdin is not an interactive terminal; fix all of the following and re-run:
  - --name is required (e.g. --name my-kernel-app)
  - --language is required: one of: typescript (ts), python (py)
  - --template is required (run 'kernel create --help' for the full list)

The new e2e test (TestCreateNonInteractiveE2E) asserts against final rendered CLI stderr from a real subprocess: exit 1, every flag token intact, and each problem starting its own line.

Comment thread cmd/create.go Outdated
// In a non-interactive shell, validate every input up front so a single
// error reports everything the caller must fix, instead of failing on one
// missing input per invocation.
if !interactive.IsInteractive() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this forks create into two resolution pipelines. ValidateNonInteractive separately owns normalization/validation and even cwd-dependent os.Stat, while the interactive path continues through PromptForAppName / PromptForLanguage / PromptForTemplate. Every future field or validation rule now has multiple owners and can drift. Please collapse this into one canonical resolver from raw flags to normalized CreateInput plus typed problems; interactive mode can resolve those problems through a prompter, while non-interactive mode renders the same problem model.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ba28842. There is now one canonical resolver: create.ResolveInput(name, language, template, skipConfirm) → normalized CreateInput + typed []Problem (fields: name, language, template, overwrite — including the cwd-dependent dir check). Both modes consume the same problem model:

  • Interactive: runCreateApp loops — resolve, prompt for the first remaining problem's field, re-resolve — so the template list always reflects the resolved language, and invalid provided values surface the resolver's message as a warning before the prompt. Prompts (PromptName/PromptLanguage/PromptTemplate/PromptOverwrite) are thin per-field wrappers over pkg/interactive with no validation logic.
  • Non-interactive: the same []Problem renders as a single fail-fast error via ErrInputsRequired.

ValidateNonInteractive and the validating PromptFor* pipeline are deleted; every field/rule now has exactly one owner. Verified the interactive flow end-to-end under a real PTY (name → language select → template select → scaffold, plus the invalid-value warn-then-reprompt path).

Comment thread cmd/noninteractive_test.go Outdated
"github.com/stretchr/testify/require"
)

// Under `go test` stdin is not a terminal, so any command path that would

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking test isolation: these tests do not arrange the condition they claim to test; they rely on ambient stdin being non-TTY. Running the compiled test binary under a PTY sends TestCreateFailsFastAggregatedWhenNonInteractive into the pterm prompt and it times out — the test can reproduce the exact hang this PR prevents depending on the harness. Inject the terminal capability, test the detector with an explicit pipe/file descriptor, and keep one CLI subprocess test with stdin explicitly set to /dev/null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ba28842, on all three counts:

  1. Injected capability: interactive.ForceTerminal(isTTY) (restore func()) swaps the package's terminal check; every gating test (cmd + pkg/create + pkg/interactive) arranges t.Cleanup(interactive.ForceTerminal(false)) explicitly instead of relying on ambient stdin, so the suite behaves identically under a PTY harness.
  2. Detector tested against an explicit fd: TestTerminalCheckReportsFalseForPipe asserts the real detector on both ends of an os.Pipe().
  3. Subprocess CLI test: TestMain re-execs the test binary as the real CLI (cmd.Execute) when KERNEL_CLI_E2E_EXEC=1; TestCreateNonInteractiveE2E runs it with stdin explicitly opened from /dev/null, a 30s context timeout (so a regression to the hang fails the test instead of wedging the suite), and asserts exit code 1 plus intact tokens in the final rendered stderr.

Addresses masnwilliams' blocking review on #209:

1. pkg/interactive now owns prompt execution. Confirm/Select/TextInput
   run the pterm prompts behind the TTY gate; command code supplies only
   the action, hint, and options. No direct pterm interactive calls
   remain outside the package, so the no-hang invariant is enforced by
   the boundary rather than a per-call-site convention. Prompt errors
   are no longer silently dropped, and the confirm primitives stop
   mutating pterm's global DefaultInteractiveConfirm.

2. Typed PromptError with explicit rendering. Error() stays a single
   line (Go convention, numbered problems); Display() renders one
   problem per line. The root error handler renders PromptError with
   fang's ErrorText width and transform unset: the width-based
   word-wrap split flag tokens ('--' + newline + 'template') and the
   transform (strings.Fields + Join) collapsed newlines. Rendered
   stderr now contains every flag token intact.

3. One canonical resolver for kernel create. create.ResolveInput is
   the single path from raw flags to a normalized CreateInput plus
   typed Problems (name, language, template, overwrite). The
   interactive flow prompts to resolve one problem at a time and
   re-resolves; the non-interactive flow reports the same problem model
   in a single fail-fast error. The forked
   ValidateNonInteractive/PromptFor* pipelines are gone; prompts are
   thin per-field wrappers with no validation logic.

4. Test isolation. Gating tests inject the terminal capability via
   interactive.ForceTerminal instead of relying on ambient stdin, the
   detector is tested against an explicit pipe fd, and a subprocess e2e
   test (TestMain re-exec) runs the real CLI with stdin=/dev/null and
   asserts on final rendered stderr: exit 1, intact flag tokens, one
   problem per line.

Interactive flows verified end-to-end under a real PTY (name ->
language select -> template select -> scaffold; invalid-value warning
before re-prompt).
@rgarcia

rgarcia commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@masnwilliams all four blockers addressed in ba28842 (replies inline on each thread):

  1. Prompt ownershippkg/interactive now executes all prompts (Confirm/Select/TextInput) behind the TTY gate; zero direct pterm interactive calls remain outside it.
  2. Rendering boundary — typed PromptError with Display(); root handler unsets fang's ErrorText width and transform (the transform's strings.Fields+Join was collapsing newlines, the width wrap was splitting --template). E2E test asserts intact tokens on final rendered stderr.
  3. Single create pipelinecreate.ResolveInput is the one resolver → CreateInput + typed Problems; interactive mode prompts per problem and re-resolves, non-interactive renders the same model. ValidateNonInteractive and the validating PromptFor* fork are deleted.
  4. Test isolationForceTerminal injection in every gating test, detector tested against an explicit pipe fd, and a TestMain re-exec subprocess test with stdin explicitly /dev/null (30s timeout so a hang regression fails instead of wedging the suite).

Re: cmd/browsers.go growth — the migration to interactive.Confirm/Select net-removed the inline gate branching there (the policy lives in pkg/interactive now). PR description updated to match. Ready for another look.

@rgarcia
rgarcia requested a review from masnwilliams July 24, 2026 19:35

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous four blockers are substantively addressed: prompt execution is centralized, rendered stderr preserves flag tokens, create has one resolver/problem model, and the new subprocess test covers the real /dev/null boundary. make test, targeted race tests, shuffled repeats, and the PTY harness all pass.

Two maintainability blockers remain in the follow-up: the claimed terminal injection is actually a process-global production test hook, and the create problem dispatcher can silently spin forever on an unhandled field.

Comment thread pkg/interactive/interactive.go Outdated
// ForceTerminal overrides terminal detection for tests, making IsInteractive
// report isTTY regardless of the ambient stdin. It returns a function that
// restores the previous behavior; callers should register it with t.Cleanup.
func ForceTerminal(isTTY bool) (restore func()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this is not isolated dependency injection; it is an exported, process-wide mutable test switch. Two parallel tests that need opposite terminal states race and contaminate one another, and production code can change the terminal policy globally. That keeps the suite serial by convention and exposes test machinery in the runtime API. Please carry the capability on a prompter/context (or rely on subprocess tests with explicit stdin) so each invocation owns its terminal state without mutating package state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 954f364. ForceTerminal and the mutable terminalCheck package variable are gone. The capability now lives on interactive.Prompter: the zero value / NewPrompter() detects from stdin at prompt time (immutable, read-only), and NewPrompterWithTerminal(false) gives a test a fixed capability scoped to that value — opposite terminal states coexist across parallel tests (the pkg tests are now t.Parallel() and pass with -race -shuffle=on -count=2), and nothing in the runtime API can change terminal policy globally.

Confirm/Select/TextInput are Prompter methods; command structs that confirm carry an injected prompter field (zero value = ambient, so no construction sites changed), and the package-level helpers remain only as delegation to the default Prompter for plain-function commands — those paths are covered by the subprocess test with explicit /dev/null stdin. Verified the compiled test binaries pass under a PTY harness (script -qec cmd.test ... → all green).

Comment thread cmd/create.go Outdated
}
v, err := create.PromptName()
if err != nil {
return err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: this dispatcher has no failure path for an unknown Problem.Field. If ResolveInput ever adds or accidentally emits a field that is not handled here, the loop makes no state change and spins forever — exactly the failure class this PR is meant to remove. At minimum, add a default that returns an internal error naming the unsupported field; ideally make the resolver/dispatcher contract exhaustive by construction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 954f364, taking the 'ideally' path: the field switch is deleted. ResolveInput now emits every Problem with its own interactive resolution step (a resolve closure that prompts for the field and applies the answer to RawInput), so a Problem cannot exist without a handler — the dispatcher just calls problems[0].Resolve(prompter, &raw) and re-resolves. Exhaustive by construction, with two backstops:

  • Problem.Resolve returns an internal error naming the field if the step is somehow nil (hand-constructed Problem), rather than letting the loop spin.
  • TestEveryProblemCarriesResolutionStep drives ResolveInput across raw-input shapes that produce all four fields (missing, invalid, per-language template, overwrite) and asserts each emitted problem carries its step.

Problem.Invalid now expresses the warn-before-reprompt behavior instead of the cmd layer inspecting raw values. Interactive flow re-verified end-to-end under a real PTY (invalid-language warn → language select → template select → scaffold).

…ustive

Addresses masnwilliams' second review round on #209:

1. No process-global test hook. ForceTerminal and the mutable
   terminalCheck package variable are gone. interactive.Prompter now
   carries the terminal capability per value: the zero value /
   NewPrompter() detects from stdin at prompt time, and
   NewPrompterWithTerminal(false) gives tests a fixed capability
   without mutating package state, so opposite terminal states coexist
   across parallel tests and nothing in the runtime API can flip
   terminal policy globally. Confirm/Select/TextInput are Prompter
   methods; the package-level helpers delegate to the default
   (ambient, immutable) Prompter for plain-function commands. Command
   structs that confirm (api-keys, auth connections, credentials,
   credential providers, extensions, profiles, proxies, create) carry
   an injected prompter field whose zero value preserves existing
   construction.

2. Exhaustive-by-construction problem dispatch. create.ResolveInput
   now emits every Problem with its own interactive resolution step
   (resolve closure applying the prompt answer to RawInput), so the
   cmd-side field switch is gone and a Problem cannot exist without a
   handler. Problem.Resolve also defends against a hand-constructed
   Problem with an internal error naming the field instead of letting
   the loop spin. Problem.Invalid replaces the raw-value inspection
   for the warn-before-reprompt behavior.

Tests: gating tests inject NewPrompterWithTerminal(false) (no global
state; marked parallel where possible), a regression test asserts every
ResolveInput problem carries a resolution step across all field shapes,
and the nil-step internal error is covered. Verified the compiled test
binaries pass under a PTY harness, with -race -shuffle=on -count=2, and
the interactive flow end-to-end under a real PTY (invalid-language warn
-> language select -> template select -> scaffold).
@rgarcia

rgarcia commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@masnwilliams both follow-up blockers addressed in 954f364 (details inline):

  1. Global test hook → per-value PrompterForceTerminal and the mutable package var are deleted. interactive.Prompter carries the capability per value (zero value = ambient stdin, immutable; NewPrompterWithTerminal(false) for tests). Command structs take an injected prompter field (zero-value compatible, no construction changes); gating tests are parallel-safe and pass with -race -shuffle=on -count=2 and when the compiled test binaries run under a PTY harness.
  2. Dispatcher exhaustive by construction — the field switch is gone: every Problem from ResolveInput carries its own resolution step, the loop calls problems[0].Resolve(prompter, &raw), and a step-less Problem returns an internal error naming the field instead of spinning. Regression test asserts all four field shapes emit problems with steps.

Full make test green; interactive flow re-verified end-to-end under a real PTY. PR description updated.

@rgarcia
rgarcia requested a review from masnwilliams July 24, 2026 20:03

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remaining blockers are addressed. Terminal capability now lives on a per-value Prompter with no mutable package state, and create problems carry guarded resolution behavior so an unsupported problem returns an explicit internal error rather than spinning. The earlier prompt-ownership, rendering-boundary, single-resolver, and test-isolation fixes remain intact.

Verified locally: make test; targeted -race -shuffle=on -count=2 runs for the changed packages and command paths; compiled-test execution under a PTY; and real CLI /dev/null rendering. Good to merge.

@rgarcia
rgarcia merged commit 574158c into main Jul 24, 2026
7 checks passed
@rgarcia
rgarcia deleted the raf/fail-fast-non-interactive branch July 24, 2026 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants