Skip to content

feat(ui): add huh-backed prompts + detector.ResolveInteractive#118

Merged
dipto0321 merged 1 commit into
mainfrom
feat/ui/huh-prompts-resolve-interactive
Jul 5, 2026
Merged

feat(ui): add huh-backed prompts + detector.ResolveInteractive#118
dipto0321 merged 1 commit into
mainfrom
feat/ui/huh-prompts-resolve-interactive

Conversation

@dipto0321

Copy link
Copy Markdown
Owner

Summary

Adds huh-backed interactive prompts (internal/ui/prompt.go) and detector.ResolveInteractive to handle the "multiple Node version managers detected, no preference given" case. The post-upgrade cleanup decision point is migrated from raw stdin line-reads (promptAllOrNothing / promptPerVersion) to the new ui.Prompt abstraction so FancyMode gets huh's arrow-key navigation and PlainMode gets a numbered-line fallback.

This is PR3 of 3 for #105 (PR1 was the scaffold via #74; PR2 was #117 spinners via bubbletea; PR4 will follow).

What this PR delivers

internal/ui/prompt.go (new)

  • Prompt interface: Confirm(question, defaultYes), Select(prompt, options, defaultLabel), Mode().
  • NewPrompt(mode, in, out) — FancyMode + non-nil streams → fancyPrompt (huh.Form per call); otherwise plainPrompt.
  • plainPrompt reads one line per question. EOF / empty input fall back to the default (Confirm → defaultYes; Select → defaultLabel / options[0]). I/O errors propagate.
  • fancyPrompt builds a fresh huh.Form per call (no cached state) and pipes through huh.ErrUserAborted → "use the default" mapping.
  • prompt_test.go covers Confirm y/n/Y/yes/empty/EOF/case-insensitive/variants, Select numeric/label/empty/EOF/no-options, bufio.Reader passthrough, nil-stream degradation under FancyMode, and I/O error surfacing. All tests pass under -race.

internal/detector/interactive.go (new)

  • ResolveManagerAuto(reg, preferred) — wrapper around ResolveManager that returns a new ErrInteractiveRequired sentinel when multiple managers are detected and no preference is set. Existing ResolveManager semantics preserved.
  • ResolveInteractive(reg, prompt, nonInteractive, in, out) — for the multi-manager case. Single-manager registries short-circuit (no prompt). nonInteractive=true returns ErrInteractiveRequired immediately (the --yes / CI path) so the caller can surface "use --manager". Order is stable via Priority.
  • interactive_test.go covers zero/single/multi/no-pref paths, the fallback plain-prompt against supplied in/out streams (EOF → first option), the nonInteractive shortcut, and a stubPrompt for future test mocks.

internal/cli/cleanup.go (rewritten prompts)

  • runCleanupPrompt(prompt, writer, cfg, toInstall, installed, active, m) — new signature threads a ui.Prompt + ui.Writer through. Plain-mode tests pass a ui.NewPrompt(PlainMode, ...) from internal/ui; production code calls ui.NewPrompt(writer.Mode(), cmd.InOrStdin(), cmd.OutOrStdout()) once per upgrade.
  • promptAllOrNothing now constructs a Select with two top-level options ("Delete all of the above", "Skip cleanup") plus one "Delete vX.Y.Z" entry per candidate. The "delete one specific version" path is now a labeled option rather than the old "type a version string" pattern — same UX, more discoverable.
  • promptPerVersion uses ui.Prompt.Confirm with defaultYes=false. Empty input defaults to "no" — the safe direction for cleanup prompts.
  • All progress lines ("No old versions to clean up", "Cleanup skipped", "Deleted vX", "Failed to delete") route through ui.Writer.{Info,Success,Warn} so Plain-mode gets unstyled text and Fancy-mode gets colored prefixes (✓/⚠/•).
  • The cleanupIO struct (was: bufio.Reader + io.Writer) is gone — that's the migrated surface.

internal/cli/cleanup_test.go (rewired)

  • newCleanupIO (cleaned up; replaced by newTestPrompt and promptWithInput).
  • The text-input tests ("y\n", "20.18.0\n") become numeric-input tests ("1\n" for "Delete all", "2\n" for "Skip cleanup", "3\n"–"5\n" for per-version picks). This is a deliberate UX improvement: the new prompt asks a multiple-choice question with a labeled option list, not a free-form text field.
  • All existing regression coverage retained: #76 "sticky-up after y at all-or-nothing", #58 ForcePerVersion downgrade, excludes-new-and-active, no-candidates, prefiltered-only, uninstall-error-collected.

internal/cli/upgrade.go

  • detector.ResolveManagerdetector.ResolveManagerAuto + detector.ResolveInteractive handoff. The ErrInteractiveRequired branch hands off with yes mapped to nonInteractive so a CI script with -y but no --manager errors out with the "use --manager" hint instead of hanging on stdin.
  • runCleanupPrompt call site now constructs both prompt and writer once and threads them through.

go.mod / go.sum

  • Adds github.com/charmbracelet/huh v1.0.0 + transitive deps (charmbracelet/bubbles, charmbracelet/x/exp/strings, dustin/go-humanize, mitchellh/hashstructure/v2, atotto/clipboard, catppuccin/go, mattn/go-localereader was already a bubbletea transitive, plus a few more — see go.sum).

Edge cases I considered before opening

  • EOF / empty input on Confirm: Plain prompt returns defaultYes so a piped echo "" | nodeup upgrade without -y doesn't accidentally mass-delete. Fancy prompt pre-fills the huh toggle with the default. prompt_test.go pins both paths.
  • EOF / empty input on Select: Plain prompt returns defaultLabel or options[0]. Fancy prompt's abort handler returns the default. Closed-stdin CI scripts still pick something — the safest available — and don't hang.
  • Single manager detected: ResolveInteractive short-circuits in len(reg.Found) == 1 before any prompt call. No spinner, no huh build, no cost.
  • Zero managers detected: returns ErrNoManager (existing sentinel). Caller already handles this on the upgrade path.
  • Multi-manager + nonInteractive: returns ErrInteractiveRequired without reading stdin. The UI layer can surface "use --manager " cleanly. interactive_test.go pins this.
  • Multi-manager + interactive + EOF: falls back to options[0] per the Prompt.Select contract. User probably expected "fnm" (top-priority manager) anyway — matches the platform's Priority() order.
  • Multi-manager + interactive + garbage answer: Plain prompt falls back to default after an unrecognized answer; Fancy prompt gives huh one re-prompt cycle. Same UX as Confirm.
  • What about huh form lifecycle: Each fancyPrompt.{Confirm,Select} builds a fresh huh.Form and runs .Run() to completion. No global state, no caching, no signal-handler installation (huh's default SIGINT handler is left in place because we trust the bubbletea stream handler we set in PR2 to never race against plain prompt paths).
  • Prompt printed in cleanup + Writer printed in upgrade.go: Both print to the same sink (cmd.OutOrStdout() for the prompt line, separate for the writer). Different files = no buffered-output race; tests confirm with bytes.Buffer shared between both.
  • Empty options to Select: Returns an error from both Plain and Fancy paths. Defensive — callers always pre-validate.
  • The #76 regression test under the new prompt flow: A user who selects "Delete all" (option index 1) no longer gets per-version [y/N] re-prompts. The sticky-up logic still lives in runCleanupPrompt Step 4 (cfg.PerVersion = false unless ForcePerVersion). The test for this is TestCleanupPrompt_DeleteAllSkipsPerVersionPrompt and was updated to check for the absence of "[y/N]" in the output rather than the old "Delete v" string (which now legitimately appears in the Select option list and isn't the regression marker).

Verification

$ make ci
go mod tidy
gofmt -s -w .
go vet ./...
golangci-lint run ./...
0 issues.
go test -race -coverprofile=coverage.out -covermode=atomic ./...
ok  	github.com/dipto0321/nodeup/internal/cli	coverage: 33.3%
ok  	github.com/dipto0321/nodeup/internal/detector	coverage: 80.2%
ok  	github.com/dipto0321/nodeup/internal/ui	coverage: 67.4%
total: 62.4%

Plus a manual make build && ./bin/nodeup upgrade --help confirming the binary still launches cleanly.

Out of scope (deferred to PR4 per #105 phasing)

  • Migrating cmd.Printf calls in check.go, list.go, packages.go, config.go, and the remaining upgrade.go lines to ui.Writer.{Info,Success,Warn,Error,Println}. PR4 is purely mechanical at this point: the Prompt and Writer surfaces are stable, so PR4 doesn't need to revisit this PR's code, just swap call sites in adjacent files.
  • A new file internal/ui/report.go for the end-of-upgrade summary block (some of those lines go through ui.Writer together).
  • After PR4, SPEC V2 flips from aspirational to enforced (every user-facing string in internal/cli/* flows through internal/ui).

Part of #105. PR4 to follow in this same issue.

Wraps huh in a small Prompt interface so internal/cli's
post-upgrade cleanup can prompt the user via huh.Form in
FancyMode and a numbered-line fallback in PlainMode. Adds
detector.ResolveInteractive + detector.ResolveManagerAuto so
the upgrade flow can pick a manager when multiple are detected
without the user pre-supplying a preference.

Adds github.com/charmbracelet/huh v1.0.0 to go.mod. The huh
dependency is contained to internal/ui/prompt.go — internal/cli
calls ui.NewPrompt + ui.Prompt.{Confirm,Select} and never imports
huh directly (same constraint as lipgloss / bubbletea per #105).

Implementation notes:

- prompt.go: Prompt interface (Confirm/Select/Mode);
  plainPrompt uses line reads against io.Reader; fancyPrompt
  builds a fresh huh.Form per call. Confirm supports a
  defaultYes pre-fill so huh's toggle starts on the right side;
  empty input / EOF fall back to the default for Confirm
  (true) and the default label / first option for Select.

- prompt_test.go covers: y/n/empty/EOF/case-insensitive
  Confirm paths, numeric/label/EOF Select paths, no-options
  error path, bufio.Reader passthrough, nil-stream degradation,
  and I/O error surfacing.

- detector/interactive.go: ResolveManagerAuto wraps ResolveManager
  with an explicit ErrInteractiveRequired sentinel for the
  multi-manager case. ResolveInteractive prompts the user (via
  ui.Prompt or a fallback plain prompt against cmd streams)
  with a single nonInteractive shortcut for CI / --yes runs.
  single-manager registries short-circuit without prompting.

- detector/interactive_test.go covers: zero/single/multi/no-pref
  paths, fallback plain prompt with numeric + EOF input,
  nonInteractive ErrInteractiveRequired surfacing, and the
  stubPrompt pattern that production code paths would use if
  they ever need to mock the picker.

- cleanup.go: runCleanupPrompt signature now takes
  (ui.Prompt, ui.Writer, ...); the old bufio-based prompts
  (promptAllOrNothing, promptPerVersion) are rewritten on top
  of the new abstraction. The "delete one specific version"
  path is now a labeled Select option ("Delete v22.11.0")
  instead of the old "type a version string" pattern — same
  per-version confirm UX but a discoverable option list.

- cleanup_test.go rewires newCleanupIO into a
  (prompt, writer, buf) triple-return helper. The number
  inputs change from text ("y\n", "20.18.0\n") to numeric
  indexes ("1\n" for "Delete all", "2\n" for "Skip cleanup",
  "3\n" / "4\n" / "5\n" for per-version picks) since the new
  prompt flow uses Select rather than free-form input.

- upgrade.go: ResolveManagerAuto + ResolveInteractive replace
  the bare ResolveManager call. Resolved manager still goes
  through the existing "Using manager: X" log line. The
  ErrInteractiveRequired path hands off to ResolveInteractive
  with yes=nonInteractive (a CI script that ran with --yes
  surfaces "use --manager" rather than hanging on stdin).

Per #105 phasing, this is PR3 of 3. PR4 (next) migrates the
remaining call sites (check, list, packages, config) to
ui.Writer. The Prompt abstraction is now stable so PR4 can
focus on the Writer migrations without revisiting this PR's
surface.

Part of #105.

Co-Authored-By: puku-ai-2.8 <noreply@puku.sh>
@cocogitto-bot

cocogitto-bot Bot commented Jul 5, 2026

Copy link
Copy Markdown

✔️ d99215e - Conventional commits check succeeded.

@dipto0321 dipto0321 merged commit 3548aad into main Jul 5, 2026
10 checks passed
@dipto0321 dipto0321 deleted the feat/ui/huh-prompts-resolve-interactive branch July 5, 2026 07:54
dipto0321 added a commit that referenced this pull request Jul 5, 2026
Closes #121. body-max-line-length raised to 200 to match footer; retroactive fix for the four historical lint failures (#117/#118/#119/#120) caused by admin squash-merge prose over 100 cols.
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.

1 participant