diff --git a/go.mod b/go.mod index 7795c3f..e7a9c0b 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/google/uuid v1.6.0 - github.com/open-cli-collective/cli-common v0.3.3-0.20260616043623-eb0e0b7e5097 + github.com/open-cli-collective/cli-common v0.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/sys v0.46.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 721778b..fbdd19e 100644 --- a/go.sum +++ b/go.sum @@ -111,8 +111,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/noamcohen97/touchid-go v0.3.0 h1:fcXxVCizysD7KHRR6hrURt3nyNIs5JBGSbOIidD/3wo= github.com/noamcohen97/touchid-go v0.3.0/go.mod h1:X9MRNIBGEmPqwpDm1G3fQOAQX7fwBlhzUbnkDTxuta0= -github.com/open-cli-collective/cli-common v0.3.3-0.20260616043623-eb0e0b7e5097 h1:/E313QdIks8CGA6IPCecQoflhBjGnBgonXIDsCj3S0I= -github.com/open-cli-collective/cli-common v0.3.3-0.20260616043623-eb0e0b7e5097/go.mod h1:3AzjCT0V8xgslHlGi1+rUkcV+Vf5wONGwISQkufLZLQ= +github.com/open-cli-collective/cli-common v0.4.0 h1:YkffqDW2OmFMWPqrT7m/yC3VoUKxGRAp26i2QZMYIWo= +github.com/open-cli-collective/cli-common v0.4.0/go.mod h1:3AzjCT0V8xgslHlGi1+rUkcV+Vf5wONGwISQkufLZLQ= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/cmd/configcmd/configcmd.go b/internal/cmd/configcmd/configcmd.go index 69bd5bb..81bfb4a 100644 --- a/internal/cmd/configcmd/configcmd.go +++ b/internal/cmd/configcmd/configcmd.go @@ -519,10 +519,10 @@ func Register(rootCmd *cobra.Command, opts *root.Options) { return cmderr.Credential(err) } result := view.ConfigClear{ - Backend: string(backend), - BackendSource: string(source), + Backend: string(backend), + BackendSource: string(source), ActiveSecretsProfile: resolvedSecretsProfileViewPtr(resolvedSecretsProfile), - DryRun: clearDryRun, + DryRun: clearDryRun, } for _, profile := range profiles { keys, err := clearCredentialBundle(store, profile.Profile, clearDryRun) diff --git a/internal/cmd/credentialcmd/credentialcmd.go b/internal/cmd/credentialcmd/credentialcmd.go index 12a4741..ea84889 100644 --- a/internal/cmd/credentialcmd/credentialcmd.go +++ b/internal/cmd/credentialcmd/credentialcmd.go @@ -461,6 +461,7 @@ type initWorkspaceDraft struct { type initSessionPlan struct { path string + originalCfg config.File cfg config.File profileNames []string profileRefs map[string][]config.CredentialRef @@ -830,6 +831,9 @@ func runInteractiveInit(cmd *cobra.Command, opts *root.Options, flags initOption if err != nil { return err } + if deps.finalizePrompter == nil { + return applyInteractiveInitSessionPlan(opts, deps, plan) + } action, err := chooseInteractiveInitFinalizeAction(opts, deps, plan) if errors.Is(err, errInitNavigateBack) { continue @@ -903,12 +907,14 @@ type huhInitLLMRuntimePrompter struct { stderr io.Writer checker func(initLLMRuntimePreset) string inventoryRunner initInventoryRunner + editorRunner initLLMRuntimeEditorRunner } type huhInitReviewerEntityPrompter struct { stdin io.Reader stderr io.Writer inventoryRunner initInventoryRunner + editorRunner initReviewerEntityEditorRunner } type huhInitFinalizePrompter struct { @@ -978,6 +984,7 @@ type huhInitKeyringBackendPrompter struct { stdin io.Reader stderr io.Writer inventoryRunner initInventoryRunner + editorRunner initSecretsManagementEditorRunner } const ( @@ -1023,10 +1030,6 @@ func newHuhInitRoutesPrompter(opts *root.Options) initRoutesPrompter { return huhInitRoutesPrompter{stdin: opts.Stdin, stderr: opts.Stderr} } -func newHuhInitRetentionPrompter(opts *root.Options) initRetentionPrompter { - return huhInitRetentionPrompter{stdin: opts.Stdin, stderr: opts.Stderr} -} - func newHuhInitKeyringBackendPrompter(opts *root.Options) initKeyringBackendPrompter { return huhInitKeyringBackendPrompter{stdin: opts.Stdin, stderr: opts.Stderr} } @@ -1423,7 +1426,7 @@ func editInteractiveInitLLMRuntimeStep(cmd *cobra.Command, opts *root.Options, f } } } - return session, true, nil + return session, false, nil } func loopInteractiveInitReviewerEntity(cmd *cobra.Command, opts *root.Options, flags initOptions, deps initDeps, session initSessionDraft) (initSessionDraft, error) { @@ -1500,7 +1503,7 @@ func editInteractiveInitReviewerEntityStep(cmd *cobra.Command, opts *root.Option } } } - return session, true, nil + return session, false, nil } func propagateSharedReviewerEntityChanges(priorCfg config.File, updatedCfg config.File, activeProfileName string, previousEntity initReviewerEntityDraft, nextEntity initReviewerEntityDraft) config.File { @@ -1569,13 +1572,10 @@ func editInteractiveInitSecretsManagement(_ *cobra.Command, opts *root.Options, } func (p huhInitMenuPrompter) ChooseAction(prompt initMenuPrompt) (initMenuAction, error) { - action := initMenuActionReviewProfiles - if prompt.CanSave { - action = initMenuActionSave - } + action := initMenuInitialAction(prompt) options := []huh.Option[initMenuAction]{ - huh.NewOption(fmt.Sprintf("Configure LLM runtimes (%d)", prompt.LLMRuntimeCount), initMenuActionLLMRuntimes), huh.NewOption(fmt.Sprintf("Configure reviewer entities (%d)", prompt.ReviewerEntityCount), initMenuActionReviewerEntities), + huh.NewOption(fmt.Sprintf("Configure LLM runtimes (%d)", prompt.LLMRuntimeCount), initMenuActionLLMRuntimes), huh.NewOption(fmt.Sprintf("Configure review profiles (%d)", prompt.ReviewProfileCount), initMenuActionReviewProfiles), huh.NewOption("Configure global settings", initMenuActionGlobalSettings), huh.NewOption("Configure secrets management", initMenuActionSecretsManagement), @@ -1615,6 +1615,16 @@ func (p huhInitMenuPrompter) ChooseAction(prompt initMenuPrompt) (initMenuAction return action, nil } +func initMenuInitialAction(prompt initMenuPrompt) initMenuAction { + if prompt.CanConfigureReviewer { + return initMenuActionReviewerEntities + } + if prompt.CanConfigureLLM { + return initMenuActionLLMRuntimes + } + return initMenuActionReviewProfiles +} + func initMenuDescription(prompt initMenuPrompt) string { if prompt.HasWorkspace && strings.TrimSpace(prompt.ActiveProfileName) != "" { return fmt.Sprintf("Active profile: %s", prompt.ActiveProfileName) @@ -1685,6 +1695,13 @@ func initFinalizeDescription(prompt initFinalizePrompt) string { } func (p huhInitLLMRuntimePrompter) EditLLMRuntime(prompt initLLMRuntimePrompt) (initDraft, error) { + if p.inventoryRunner == nil { + return p.editLLMRuntimeLinear(prompt) + } + return p.editLLMRuntimeInventory(prompt) +} + +func (p huhInitLLMRuntimePrompter) editLLMRuntimeInventory(prompt initLLMRuntimePrompt) (initDraft, error) { draft := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) for { result, err := p.runInventory(initInventoryPrompt{ @@ -1744,60 +1761,7 @@ func (p huhInitLLMRuntimePrompter) editSelectedLLMRuntime(ctx initPromptContext, } func (p huhInitLLMRuntimePrompter) editLLMRuntimeDetails(seed initDraft) (initDraft, bool, error) { - draft := seed - runtime := initLLMRuntimeDraftFromSeedDraft(draft) - detailTitle := "LLM Runtime Details" - detailDescription := initLLMRuntimeSelectionDescription(runtime, p.runtimeAvailabilityNote(runtime.Preset)) - editAction := initDetailActionEdit - editForm := huh.NewForm( - huh.NewGroup( - huh.NewNote(). - Title("Runtime details"). - Description(detailDescription), - huh.NewSelect[string](). - Title("Runtime detail action"). - Options( - huh.NewOption("Stage these runtime details", initDetailActionEdit), - huh.NewOption("Back without staging", initDetailActionBack), - ). - Value(&editAction), - huh.NewSelect[string](). - Title("LLM provider"). - Options( - huh.NewOption("Anthropic", string(config.LLMProviderAnthropic)), - huh.NewOption("OpenAI", string(config.LLMProviderOpenAI)), - huh.NewOption("Pi", string(config.LLMProviderPi)), - ). - Value(&draft.LLMProvider), - huh.NewSelect[string](). - Title("LLM auth mode"). - Options( - huh.NewOption("Subscription", string(config.LLMAuthSubscription)), - huh.NewOption("API key", string(config.LLMAuthAPIKey)), - ). - Value(&draft.LLMAuth), - huh.NewSelect[string](). - Title("LLM adapter"). - Options( - huh.NewOption("Claude CLI", string(config.LLMAdapterClaudeCLI)), - huh.NewOption("Anthropic API", string(config.LLMAdapterAnthropicAPI)), - huh.NewOption("Codex CLI", string(config.LLMAdapterCodexCLI)), - huh.NewOption("OpenAI API", string(config.LLMAdapterOpenAIAPI)), - huh.NewOption("Pi RPC", string(config.LLMAdapterPiRPC)), - ). - Value(&draft.LLMAdapter), - ).Title(detailTitle), - ) - back, err := runBackableInitForm(editForm, p.stdin, p.stderr) - if err != nil { - return initDraft{}, false, err - } - if back || editAction == initDetailActionBack { - return initDraft{}, true, nil - } - resolvedRuntimePreset := string(initLLMRuntimeDraftFromSeedDraft(draft).Preset) - applyLLMRuntimeSelection(&draft, resolvedRuntimePreset) - return draft, false, nil + return p.editLLMRuntimeDetailsLinear(seed) } func (p huhInitLLMRuntimePrompter) chooseLLMRuntimeDeleteReplacement(prompt initLLMRuntimePrompt, deletedRuntimeName string, seed initDraft) (initDraft, error) { @@ -1942,6 +1906,13 @@ func (p huhInitLLMRuntimePrompter) runtimeAvailabilityNote(preset initLLMRuntime } func (p huhInitReviewerEntityPrompter) EditReviewerEntity(prompt initReviewerEntityPrompt) (initDraft, error) { + if p.inventoryRunner == nil { + return p.editReviewerEntityLinear(prompt) + } + return p.editReviewerEntityInventory(prompt) +} + +func (p huhInitReviewerEntityPrompter) editReviewerEntityInventory(prompt initReviewerEntityPrompt) (initDraft, error) { draft := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) for { result, err := p.runInventory(initInventoryPrompt{ @@ -2009,80 +1980,7 @@ func (p huhInitReviewerEntityPrompter) editNewReviewerEntity(kind initReviewerEn } func (p huhInitReviewerEntityPrompter) editReviewerEntityFields(entity initReviewerEntityDraft, seed initDraft, preserveCurrentLocation bool) (initDraft, bool, error) { - editDraft := seed - kind := entity.Kind - applyReviewerEntitySelection(&editDraft, string(kind)) - if kind == initReviewerEntityKindUseGitIdentity { - action := initDetailActionEdit - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Reviewer detail action"). - Options( - huh.NewOption("Stage reviewer settings", initDetailActionEdit), - huh.NewOption("Back without staging", initDetailActionBack), - ). - Value(&action), - ).Title("Reviewer Entity Details"), - ) - back, err := runBackableInitForm(form, p.stdin, p.stderr) - if err != nil { - return initDraft{}, false, err - } - if back || action == initDetailActionBack { - return initDraft{}, true, nil - } - return editDraft, false, nil - } - - standardReviewerRef, err := standardReviewerCredentialRef(editDraft.ProfileName) - if err != nil { - return initDraft{}, false, err - } - labelInput, explicitDisplayName, fallbackLabelSeed := reviewerEntityEditorLabelSeed(entity) - if !preserveCurrentLocation { - // New entities start from a blank editable label even when the kind has a fallback display shape. - labelInput = "" - } - reviewerSecretLocation := "" - if preserveCurrentLocation { - if currentRef := strings.TrimSpace(editDraft.ReviewerCredentialRef); currentRef != "" { - reviewerSecretLocation = currentRef - } else { - reviewerSecretLocation = standardReviewerRef - } - } - action := initDetailActionEdit - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Entity label"). - Description("Choose a human-friendly name for this reviewer entity. Leave blank to clear any existing custom label."). - Value(&labelInput). - Validate(validateOptionalDisplayName), - huh.NewInput(). - Title("Reviewer secret location"). - Description("Leave blank to use the standard reviewer secret location for this profile. Replace the value only if you need a custom location."). - Value(&reviewerSecretLocation). - Validate(validateOptionalCredentialRef), - huh.NewSelect[string](). - Title("Reviewer detail action"). - Options( - huh.NewOption("Stage reviewer settings", initDetailActionEdit), - huh.NewOption("Back without staging", initDetailActionBack), - ). - Value(&action), - ).Title("Reviewer Entity Details"), - ) - back, err := runBackableInitForm(form, p.stdin, p.stderr) - if err != nil { - return initDraft{}, false, err - } - if back || action == initDetailActionBack { - return initDraft{}, true, nil - } - finalizeReviewerEntityEditorDraft(&editDraft, explicitDisplayName, fallbackLabelSeed, labelInput, reviewerSecretLocation, standardReviewerRef, preserveCurrentLocation) - return editDraft, false, nil + return p.editReviewerEntityFieldsLinear(entity, seed, preserveCurrentLocation) } func finalizeReviewerEntityEditorDraft(editDraft *initDraft, explicitDisplayName string, fallbackLabelSeed string, labelInput string, reviewerSecretLocation string, standardReviewerRef string, preserveCurrentLocation bool) { @@ -3295,15 +3193,19 @@ func defaultInitLLMRuntimeAvailabilityNote(preset initLLMRuntimePreset) string { } func profileDeletePendingLabel(name string) string { - return fmt.Sprintf("Restore %s (staged for deletion)", name) + return initPendingDeleteLabel(name) } func reviewerEntityDeletePendingLabel(name string) string { - return fmt.Sprintf("Restore reviewer entity %s (staged for deletion)", name) + return initPendingDeleteLabel(name) } func llmRuntimeDeletePendingLabel(name string) string { - return fmt.Sprintf("Restore LLM runtime %s (staged for deletion)", name) + return initPendingDeleteLabel(name) +} + +func initPendingDeleteLabel(label string) string { + return fmt.Sprintf("%s (Staged for deletion)", strings.TrimSpace(label)) } func applyLLMRuntimeInventorySelection(draft *initDraft, selection string, runtimes map[string]initLLMRuntimeDraft) { @@ -4400,6 +4302,7 @@ func buildInteractiveInitSessionPlan(opts *root.Options, session initSessionDraf entries = refreshInteractiveCredentialPlan(entries, projectInitPlannedWriteKeys(writes), satisfiedRefs) return initSessionPlan{ path: session.path, + originalCfg: cloneInitConfigFile(session.originalCfg), cfg: cloneInitConfigFile(session.cfg), profileNames: profileNames, profileRefs: profileRefs, @@ -5520,7 +5423,7 @@ func parseInitRoutePRURL(raw string) (gitprovider.PRRef, error) { func collectInteractiveInitRetentionConfig(opts *root.Options, deps initDeps, cfg config.File) (config.File, error) { prompter := deps.retentionPrompter if prompter == nil { - prompter = newHuhInitRetentionPrompter(opts) + prompter = newBubbleTeaInitRetentionPrompter(opts) } edit, err := prompter.EditRetention(initRetentionPrompt{ Retention: cfg.Data.Retention, @@ -6291,7 +6194,7 @@ func applyInteractiveInitSessionPlan(opts *root.Options, deps initDeps, plan ini } return err } - if _, err := fmt.Fprintf(opts.Stdout, "Initialized %d profile(s)\n", len(plan.profileNames)); err != nil { + if err := writeInteractiveInitSessionSummary(opts.Stdout, plan); err != nil { return err } for _, readiness := range buildInteractiveInitProfileReadiness(plan) { @@ -6316,6 +6219,72 @@ func applyInteractiveInitSessionPlan(opts *root.Options, deps initDeps, plan ini return writeErr } +func writeInteractiveInitSessionSummary(w io.Writer, plan initSessionPlan) error { + if _, err := fmt.Fprintln(w, "Saved staged init changes"); err != nil { + return err + } + for _, line := range interactiveInitSessionSummaryLines(plan) { + if _, err := fmt.Fprintf(w, "- %s\n", line); err != nil { + return err + } + } + return nil +} + +func interactiveInitSessionSummaryLines(plan initSessionPlan) []string { + lines := []string{} + if len(plan.profileNames) > 0 { + lines = append(lines, fmt.Sprintf("review profiles: %d", len(plan.profileNames))) + } + if !reflect.DeepEqual(plan.originalCfg.RepositoryProfiles, plan.cfg.RepositoryProfiles) { + lines = append(lines, fmt.Sprintf("repository routes: %d", len(plan.cfg.RepositoryProfiles))) + } + if !reflect.DeepEqual(plan.originalCfg.Secrets, plan.cfg.Secrets) { + if changed := changedInitSecretsManagementProfileCount(plan.originalCfg.Secrets, plan.cfg.Secrets); changed > 0 { + lines = append(lines, fmt.Sprintf("secrets management: %d %s", changed, pluralizeInitSummary(changed, "profile", "profiles"))) + } else { + lines = append(lines, "secrets management") + } + } + if !reflect.DeepEqual(plan.originalCfg.Keyring, plan.cfg.Keyring) { + lines = append(lines, "default credential store") + } + if !reflect.DeepEqual(plan.originalCfg.Data.Retention, plan.cfg.Data.Retention) { + lines = append(lines, "global settings") + } + if len(plan.writes) > 0 { + lines = append(lines, fmt.Sprintf("credential secrets: %d %s", len(plan.writes), pluralizeInitSummary(len(plan.writes), "ref", "refs"))) + } + if len(lines) == 0 { + lines = append(lines, "configuration saved") + } + return lines +} + +func changedInitSecretsManagementProfileCount(before, after config.SecretsConfig) int { + ids := map[string]struct{}{} + for id := range before.Profiles { + ids[id] = struct{}{} + } + for id := range after.Profiles { + ids[id] = struct{}{} + } + changed := 0 + for id := range ids { + if !reflect.DeepEqual(before.Profiles[id], after.Profiles[id]) { + changed++ + } + } + return changed +} + +func pluralizeInitSummary(count int, singular string, plural string) string { + if count == 1 { + return singular + } + return plural +} + func hasDeferredLLMCredential(entries []initCredentialPlanEntry) bool { for _, entry := range entries { if entry.Ref.Purpose != "llm" { diff --git a/internal/cmd/credentialcmd/credentialcmd_test.go b/internal/cmd/credentialcmd/credentialcmd_test.go index 647a6d4..ea8f9da 100644 --- a/internal/cmd/credentialcmd/credentialcmd_test.go +++ b/internal/cmd/credentialcmd/credentialcmd_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "reflect" "runtime" + "slices" "sort" "strings" "testing" @@ -534,6 +535,7 @@ func TestInitNonInteractiveWritesReviewerCredential(t *testing.T) { reviewer := cfg.Profiles["default"].ReviewerCredentials if reviewer == nil { t.Fatal("reviewer credentials missing") + return } if reviewer.AuthMode != config.GitAuthModePAT || reviewer.CredentialRef != "codereview/default-reviewer" { t.Fatalf("reviewer credentials = %#v, want pat codereview/default-reviewer", reviewer) @@ -1122,6 +1124,7 @@ func TestBuildInteractiveInitSessionPlanUsesOriginalProfileForRenamedTouchedProf } if reviewerEntry == nil { t.Fatal("reviewer credential entry missing from session plan") + return } if reviewerEntry.State != initCredentialPlanStateKeepExisting { t.Fatalf("reviewer entry state = %s, want keep_existing for label-only renamed profile edit", reviewerEntry.State) @@ -2110,6 +2113,7 @@ func TestInitReviewerEntityDraftExportClearsIdentityCacheWhenShapeChanges(t *tes if exported == nil { t.Fatal("exportConfig = nil, want separate reviewer credentials") + return } if exported.IdentityCache != "" { t.Fatalf("IdentityCache = %q, want cleared on reviewer entity change", exported.IdentityCache) @@ -2535,8 +2539,8 @@ func TestEditInteractiveInitReviewerEntityStepPropagatesSharedDisplayName(t *tes if err != nil { t.Fatalf("editInteractiveInitReviewerEntityStep: %v", err) } - if !stayInCategory { - t.Fatal("stayInCategory = false, want focused reviewer flow to stay active") + if stayInCategory { + t.Fatal("stayInCategory = true, want focused reviewer flow to return to main menu after stage") } for _, profileName := range []string{"home", "work"} { profile := next.cfg.Profiles[profileName] @@ -2604,8 +2608,8 @@ func TestEditInteractiveInitReviewerEntityStepPropagatesConcreteSharedReviewerRe if err != nil { t.Fatalf("editInteractiveInitReviewerEntityStep: %v", err) } - if !stayInCategory { - t.Fatal("stayInCategory = false, want focused reviewer flow to stay active") + if stayInCategory { + t.Fatal("stayInCategory = true, want focused reviewer flow to return to main menu after stage") } for _, profileName := range []string{"home", "work"} { profile := next.cfg.Profiles[profileName] @@ -2670,8 +2674,8 @@ func TestEditInteractiveInitReviewerEntityStepSelectingFallbackDoesNotPropagateS if err != nil { t.Fatalf("editInteractiveInitReviewerEntityStep: %v", err) } - if !stayInCategory { - t.Fatal("stayInCategory = false, want focused reviewer flow to stay active") + if stayInCategory { + t.Fatal("stayInCategory = true, want focused reviewer flow to return to main menu after stage") } if got := next.cfg.Profiles["home"].ReviewerCredentials; got != nil { t.Fatalf("home reviewer credentials = %#v, want cleared fallback reviewer", got) @@ -3843,6 +3847,13 @@ func TestHuhInitPrompterAccessiblePrefillsExistingProfile(t *testing.T) { "", }, "\n")), stderr: &stderr, + llmRuntimePrompter: initLLMRuntimePrompterFunc(func(initLLMRuntimePrompt) (initDraft, error) { + return initDraft{ + LLMProvider: string(config.LLMProviderOpenAI), + LLMAuth: string(config.LLMAuthSubscription), + LLMAdapter: string(config.LLMAdapterCodexCLI), + }, nil + }), inventoryRunner: func(prompt initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, prompt.Description+"\n") _, _ = io.WriteString(out, "work\n") @@ -3988,6 +3999,13 @@ func TestHuhInitPrompterAccessibleOmitsSecretsManagementFromProfileEditor(t *tes "", }, "\n")), stderr: &stderr, + llmRuntimePrompter: initLLMRuntimePrompterFunc(func(initLLMRuntimePrompt) (initDraft, error) { + return initDraft{ + LLMProvider: string(config.LLMProviderOpenAI), + LLMAuth: string(config.LLMAuthSubscription), + LLMAdapter: string(config.LLMAdapterCodexCLI), + }, nil + }), inventoryRunner: func(prompt initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, prompt.Description+"\n") _, _ = io.WriteString(out, "work\n") @@ -4502,6 +4520,13 @@ func TestHuhInitPrompterAccessibleCreateNewProfileDefaultsToMakeDefaultWhenNoDef "", }, "\n")), stderr: &stderr, + llmRuntimePrompter: initLLMRuntimePrompterFunc(func(initLLMRuntimePrompt) (initDraft, error) { + return initDraft{ + LLMProvider: string(config.LLMProviderOpenAI), + LLMAuth: string(config.LLMAuthSubscription), + LLMAdapter: string(config.LLMAdapterCodexCLI), + }, nil + }), inventoryRunner: func(prompt initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, prompt.Description+"\n") _, _ = io.WriteString(out, "Create new profile\n") @@ -4678,6 +4703,13 @@ func TestHuhInitPrompterAccessibleNewProfileFlowShowsBackOption(t *testing.T) { var stderr bytes.Buffer prompter := huhInitPrompter{ stderr: &stderr, + llmRuntimePrompter: initLLMRuntimePrompterFunc(func(initLLMRuntimePrompt) (initDraft, error) { + return initDraft{ + LLMProvider: string(config.LLMProviderOpenAI), + LLMAuth: string(config.LLMAuthSubscription), + LLMAdapter: string(config.LLMAdapterCodexCLI), + }, nil + }), inventoryRunner: func(prompt initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, prompt.Description+"\n") _, _ = io.WriteString(out, "Create new profile\nBack to main menu\n") @@ -4709,12 +4741,12 @@ func TestHuhInitPrompterAccessibleCanRestorePendingDeletedProfile(t *testing.T) prompter := huhInitPrompter{ stderr: &stderr, inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { - _, _ = io.WriteString(out, "Restore work (staged for deletion)\n") + _, _ = io.WriteString(out, "work (Staged for deletion)\n") return initInventoryResult{ Action: initInventoryActionRestore, Row: initInventoryRow{ ID: "work", - Title: "Restore work (staged for deletion)", + Title: "work (Staged for deletion)", }, }, nil }, @@ -4740,7 +4772,7 @@ func TestHuhInitPrompterAccessibleCanRestorePendingDeletedProfile(t *testing.T) if draft.Action != initDraftActionUndoDeleteProfile || draft.ActionTarget != "work" { t.Fatalf("draft undo action = %#v, want restore work", draft) } - if !strings.Contains(stderr.String(), "Restore work (staged for deletion)") { + if !strings.Contains(stderr.String(), "work (Staged for deletion)") { t.Fatalf("stderr = %q, want restore label", stderr.String()) } } @@ -4795,12 +4827,12 @@ func TestHuhInitReviewerEntityPrompterAccessibleCanRestorePendingDeletedEntity(t prompter := huhInitReviewerEntityPrompter{ stderr: &stderr, inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { - _, _ = io.WriteString(out, "Restore reviewer entity reviewer-github-app (staged for deletion)\n") + _, _ = io.WriteString(out, "reviewer-github-app (Staged for deletion)\n") return initInventoryResult{ Action: initInventoryActionRestore, Row: initInventoryRow{ ID: "reviewer-github-app", - Title: "Restore reviewer entity reviewer-github-app (staged for deletion)", + Title: "reviewer-github-app (Staged for deletion)", }, }, nil }, @@ -4825,7 +4857,7 @@ func TestHuhInitReviewerEntityPrompterAccessibleCanRestorePendingDeletedEntity(t if draft.Action != initDraftActionUndoDeleteReviewerEntity || draft.ActionTarget != "reviewer-github-app" { t.Fatalf("draft undo reviewer action = %#v, want reviewer-github-app restore", draft) } - if !strings.Contains(stderr.String(), "Restore reviewer entity reviewer-github-app (staged for deletion)") { + if !strings.Contains(stderr.String(), "reviewer-github-app (Staged for deletion)") { t.Fatalf("stderr = %q, want reviewer restore label", stderr.String()) } } @@ -4849,11 +4881,8 @@ func TestHuhInitReviewerEntityPrompterAccessibleKeepsFallbackSelectedInMixedInve reviewerEntities, profileReviewerEntities := buildInitReviewerEntityInventory(cfg) var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Stage reviewer settings - "", - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, nil, ""), inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, "Post as rianjs (GitHub PAT)\n") return initInventoryResult{ @@ -4907,13 +4936,8 @@ func TestHuhInitReviewerEntityPrompterAccessibleConfiguredReviewerRoundTripsInMi reviewerEntities, profileReviewerEntities := buildInitReviewerEntityInventory(cfg) var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Entity label - "", // Keep current reviewer secret location - "", // Stage reviewer settings - "", - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, nil, ""), inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, "custom-work-reviewer (PAT reviewer)\n") return initInventoryResult{ @@ -4962,6 +4986,13 @@ func TestHuhInitPrompterAccessibleRequestedNewProfilePreservesExplicitName(t *te "", }, "\n")), stderr: &stderr, + llmRuntimePrompter: initLLMRuntimePrompterFunc(func(initLLMRuntimePrompt) (initDraft, error) { + return initDraft{ + LLMProvider: string(config.LLMProviderOpenAI), + LLMAuth: string(config.LLMAuthSubscription), + LLMAdapter: string(config.LLMAdapterCodexCLI), + }, nil + }), inventoryRunner: func(prompt initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, prompt.Description+"\n") _, _ = io.WriteString(out, "Create new profile\nBack to main menu\n") @@ -5012,6 +5043,13 @@ func TestHuhInitPrompterAccessibleCreateNewProfilePreservesExplicitRequestedName "", }, "\n")), stderr: &stderr, + llmRuntimePrompter: initLLMRuntimePrompterFunc(func(initLLMRuntimePrompt) (initDraft, error) { + return initDraft{ + LLMProvider: string(config.LLMProviderOpenAI), + LLMAuth: string(config.LLMAuthSubscription), + LLMAdapter: string(config.LLMAdapterCodexCLI), + }, nil + }), inventoryRunner: func(prompt initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, prompt.Description+"\n") _, _ = io.WriteString(out, "Create new profile\n") @@ -5105,13 +5143,8 @@ func TestHuhInitLLMRuntimePrompterAccessibleConfiguredRuntimeShowsDetails(t *tes llmRuntimes, profileLLMRuntimes := buildInitLLMRuntimeInventory(cfg) var stderr bytes.Buffer prompter := huhInitLLMRuntimePrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Stage these runtime details - "", // Keep Anthropic provider - "", // Keep subscription auth - "", // Keep Claude CLI adapter - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageLLMRuntimeEditorRunner(t, nil, ""), inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, "Configured: Claude CLI subscription (claude-cli)\n") return initInventoryResult{ @@ -5158,13 +5191,8 @@ func TestHuhInitLLMRuntimePrompterAccessibleTemplateShowsDetails(t *testing.T) { llmRuntimes, profileLLMRuntimes := buildInitLLMRuntimeInventory(cfg) var stderr bytes.Buffer prompter := huhInitLLMRuntimePrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Stage these runtime details - "", // Keep OpenAI provider default - "", // Keep subscription auth default - "", // Keep Codex CLI adapter default - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageLLMRuntimeEditorRunner(t, nil, ""), inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, "Template: Codex CLI subscription\n") return initInventoryResult{ @@ -5210,13 +5238,8 @@ func TestHuhInitLLMRuntimePrompterAccessibleTemplateShowsAvailabilityNote(t *tes var stderr bytes.Buffer checkerCalled := false prompter := huhInitLLMRuntimePrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Stage these runtime details - "", // Keep OpenAI provider default - "", // Keep subscription auth default - "", // Keep Codex CLI adapter default - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageLLMRuntimeEditorRunner(t, nil, ""), checker: func(preset initLLMRuntimePreset) string { if preset == initLLMRuntimePresetCodexCLISubscription { checkerCalled = true @@ -5249,7 +5272,9 @@ func TestHuhInitLLMRuntimePrompterAccessibleTemplateShowsAvailabilityNote(t *tes if !checkerCalled { t.Fatal("checkerCalled = false, want template selection to consult runtime availability checker") } - if !strings.Contains(stderr.String(), "Runtime detail action") || !strings.Contains(stderr.String(), "Codex CLI check: codex-cli 0.139.0 installed.") { + if !strings.Contains(stderr.String(), "Runtime detail action") || + !strings.Contains(stderr.String(), "Codex CLI") || + !strings.Contains(stderr.String(), "codex-cli 0.139.0 installed.") { t.Fatalf("stderr = %q, want flattened runtime detail screen with runtime availability note", stderr.String()) } } @@ -5286,13 +5311,12 @@ func TestHuhInitLLMRuntimePrompterAccessibleCustomRuntimeShowsCustomFields(t *te } var stderr bytes.Buffer prompter := huhInitLLMRuntimePrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Stage these runtime details - "2", // OpenAI provider - "2", // API key auth - "4", // OpenAI API adapter - }, "\n")), stderr: &stderr, + editorRunner: stageLLMRuntimeEditorRunner(t, map[initLinearFieldID]string{ + initLLMRuntimeFieldProvider: string(config.LLMProviderOpenAI), + initLLMRuntimeFieldAuth: string(config.LLMAuthAPIKey), + initLLMRuntimeFieldAdapter: string(config.LLMAdapterOpenAIAPI), + }, ""), inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, "Custom compatible runtime\n") return initInventoryResult{ @@ -5378,13 +5402,8 @@ func TestHuhInitLLMRuntimeDetailsBackDoesNotMutateDraft(t *testing.T) { want := draft var stderr bytes.Buffer prompter := huhInitLLMRuntimePrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "2", // Back without staging - "", // Keep provider - "", // Keep auth - "", // Keep adapter - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageLLMRuntimeEditorRunner(t, nil, initDetailActionBack), } _, back, err := prompter.editLLMRuntimeDetails(draft) @@ -5543,12 +5562,12 @@ func TestHuhInitLLMRuntimePrompterAccessibleCanRestorePendingDeletedRuntime(t *t prompter := huhInitLLMRuntimePrompter{ stderr: &stderr, inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { - _, _ = io.WriteString(out, "Restore LLM runtime claude-cli (staged for deletion)\n") + _, _ = io.WriteString(out, "claude-cli (Staged for deletion)\n") return initInventoryResult{ Action: initInventoryActionRestore, Row: initInventoryRow{ ID: "claude-cli", - Title: "Restore LLM runtime claude-cli (staged for deletion)", + Title: "claude-cli (Staged for deletion)", }, }, nil }, @@ -5571,11 +5590,154 @@ func TestHuhInitLLMRuntimePrompterAccessibleCanRestorePendingDeletedRuntime(t *t if draft.Action != initDraftActionUndoDeleteLLMRuntime || draft.ActionTarget != "claude-cli" { t.Fatalf("draft undo runtime action = %#v, want claude-cli restore", draft) } - if !strings.Contains(stderr.String(), "Restore LLM runtime claude-cli (staged for deletion)") { + if !strings.Contains(stderr.String(), "claude-cli (Staged for deletion)") { t.Fatalf("stderr = %q, want runtime restore label", stderr.String()) } } +func TestHuhInitLLMRuntimePrompterDefaultUsesLinearRuntimeFlow(t *testing.T) { + existing := basicProfile("work") + cfg := config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{"work": existing}, + } + llmRuntimes, profileLLMRuntimes := buildInitLLMRuntimeInventory(cfg) + var stderr bytes.Buffer + prompter := huhInitLLMRuntimePrompter{ + stderr: &stderr, + editorRunner: func(editor initLinearEditor, _ io.Reader, out io.Writer) (initLinearEditorModel, error) { + model := newInitLinearEditorModel(editor, 180, 32) + _, _ = io.WriteString(out, model.layout.Content) + model = focusInitLinearField(t, model, initLLMRuntimeFieldAction) + model = selectInitLinearFieldValue(t, model, initLLMRuntimeFieldAction, initDetailActionEdit) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + }, + } + + draft, err := prompter.EditLLMRuntime(initLLMRuntimePrompt{Context: initPromptContext{ + RequestedProfileName: "work", + ExistingProfileName: "work", + ExistingProfile: &existing, + DefaultProfileName: "work", + ExistingConfig: cfg, + LLMRuntimes: llmRuntimes, + ProfileLLMRuntimes: profileLLMRuntimes, + }}) + if err != nil { + t.Fatalf("EditLLMRuntime: %v", err) + } + if draft.LLMProvider != string(config.LLMProviderAnthropic) || draft.LLMAdapter != string(config.LLMAdapterClaudeCLI) { + t.Fatalf("draft = %#v, want default claude runtime", draft) + } + out := stderr.String() + for _, want := range []string{ + "LLM runtime", + "Runtime", + "Configured: Claude CLI subscription (claude-cli)", + "Runtime details", + "LLM provider", + "LLM auth mode", + "LLM adapter", + "Runtime action", + "Stage these runtime details", + } { + if !strings.Contains(out, want) { + t.Fatalf("stderr missing %q:\n%s", want, out) + } + } + assertContentOrder(t, out, "Runtime", "Runtime details", "LLM provider", "LLM auth mode", "LLM adapter", "Runtime action") + if strings.Contains(out, "Back to main menu") { + t.Fatalf("stderr = %q, want action-local Back without staging instead of inventory Back", out) + } +} + +func TestHuhInitLLMRuntimePrompterDefaultCanDeleteWithInlineReplacement(t *testing.T) { + existing := basicProfile("work") + cfg := config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{"work": existing}, + } + llmRuntimes, profileLLMRuntimes := buildInitLLMRuntimeInventory(cfg) + var stderr bytes.Buffer + prompter := huhInitLLMRuntimePrompter{ + stderr: &stderr, + editorRunner: func(editor initLinearEditor, _ io.Reader, out io.Writer) (initLinearEditorModel, error) { + model := newInitLinearEditorModel(editor, 160, 60) + model = selectInitLinearFieldValue(t, model, initLLMRuntimeFieldReplacement, string(initLLMRuntimePresetCodexCLISubscription)) + model = focusInitLinearField(t, model, initLLMRuntimeFieldSelection) + _, _ = io.WriteString(out, model.View()) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + }, + } + + draft, err := prompter.EditLLMRuntime(initLLMRuntimePrompt{Context: initPromptContext{ + RequestedProfileName: "work", + ExistingProfileName: "work", + ExistingProfile: &existing, + DefaultProfileName: "work", + ExistingConfig: cfg, + LLMRuntimes: llmRuntimes, + ProfileLLMRuntimes: profileLLMRuntimes, + }}) + if err != nil { + t.Fatalf("EditLLMRuntime: %v", err) + } + if draft.Action != initDraftActionDeleteLLMRuntime || draft.ActionTarget != "claude-cli" { + t.Fatalf("draft action = %#v, want delete claude-cli", draft) + } + if draft.LLMProvider != string(config.LLMProviderOpenAI) || draft.LLMAdapter != string(config.LLMAdapterCodexCLI) { + t.Fatalf("draft replacement = %#v, want codex-cli replacement", draft) + } + if !strings.Contains(stderr.String(), "d delete") { + t.Fatalf("stderr = %q, want delete shortcut help", stderr.String()) + } +} + +func TestHuhInitLLMRuntimePrompterDefaultPreservesConfiguredAPIKeyRuntimeRef(t *testing.T) { + existing := basicProfile("work") + existing.LLM = config.LLMConfig{ + Provider: config.LLMProviderOpenAI, + Auth: config.LLMAuthAPIKey, + Adapter: config.LLMAdapterOpenAIAPI, + CredentialRef: "codereview/custom-openai", + } + cfg := config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{"work": existing}, + } + llmRuntimes, profileLLMRuntimes := buildInitLLMRuntimeInventory(cfg) + prompter := huhInitLLMRuntimePrompter{ + stderr: &bytes.Buffer{}, + editorRunner: stageLLMRuntimeEditorRunner(t, nil, ""), + } + + draft, err := prompter.EditLLMRuntime(initLLMRuntimePrompt{Context: initPromptContext{ + RequestedProfileName: "work", + ExistingProfileName: "work", + ExistingProfile: &existing, + DefaultProfileName: "work", + ExistingConfig: cfg, + LLMRuntimes: llmRuntimes, + ProfileLLMRuntimes: profileLLMRuntimes, + }}) + if err != nil { + t.Fatalf("EditLLMRuntime: %v", err) + } + if draft.LLMCredentialRef != "codereview/custom-openai" { + t.Fatalf("LLMCredentialRef = %q, want configured runtime ref", draft.LLMCredentialRef) + } +} + func TestHuhInitReviewerEntityPrompterAccessibleBackReturnsNavigateBack(t *testing.T) { t.Setenv("TERM", "dumb") existing := basicProfile("work") @@ -5615,12 +5777,8 @@ func TestHuhInitReviewerEntityPrompterAccessibleChoiceShowsDetails(t *testing.T) existing := basicProfile("work") var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Entity label - "", // Keep the derived reviewer secret location - "", // Stage reviewer settings - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, nil, ""), inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, "Configure new personal access token (PAT) reviewer\n") return initInventoryResult{ @@ -5651,7 +5809,7 @@ func TestHuhInitReviewerEntityPrompterAccessibleChoiceShowsDetails(t *testing.T) if !strings.Contains(out, "Reviewer detail action") || !strings.Contains(out, "Stage reviewer settings") || !strings.Contains(out, "Back without staging") || !strings.Contains(out, "Entity label") || !strings.Contains(out, "Reviewer secret location") { t.Fatalf("stderr = %q, want reviewer details screen", out) } - if strings.Contains(out, "Reviewer entity type") || strings.Contains(out, "Reviewer label action") || strings.Contains(out, "Use this reviewer label") || strings.Contains(out, "Reviewer secret location action") || strings.Contains(out, "Use this reviewer secret location") || strings.Contains(out, "Custom reviewer secret location") || strings.Contains(out, "Use the standard reviewer secret location (recommended)") || strings.Contains(out, "Use a custom reviewer secret location (advanced)") { + if strings.Contains(out, "Reviewer label action") || strings.Contains(out, "Use this reviewer label") || strings.Contains(out, "Reviewer secret location action") || strings.Contains(out, "Use this reviewer secret location") || strings.Contains(out, "Custom reviewer secret location") || strings.Contains(out, "Use the standard reviewer secret location (recommended)") || strings.Contains(out, "Use a custom reviewer secret location (advanced)") { t.Fatalf("stderr = %q, want flattened reviewer editor", out) } } @@ -5666,12 +5824,8 @@ func TestHuhInitReviewerEntityPrompterNewTemplateDoesNotInheritCustomSecretLocat } var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Entity label - "", // Keep the derived reviewer secret location - "", // Stage reviewer settings - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, nil, ""), inventoryRunner: func(_ initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, "Configure new GitHub App reviewer\n") return initInventoryResult{ @@ -5775,12 +5929,8 @@ func TestHuhInitReviewerEntityPrompterExistingReviewerCustomSecretLocationPersis draft := seedInteractiveInitDraft("work", "work", "work", &existing) var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Keep the seeded reviewer entity label. - "", // Keep the current reviewer secret location. - "", // Stage reviewer settings. - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, nil, ""), } nextDraft, back, err := prompter.editExistingReviewerEntity(initReviewerEntityDraftFromConfig(existing), draft) @@ -5814,12 +5964,8 @@ func TestHuhInitReviewerEntityPrompterExistingReviewerFallbackSeedDoesNotPersist draft := seedInteractiveInitDraft("work", "work", "work", &existing) var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Keep the seeded fallback reviewer entity label. - "", // Keep the current reviewer secret location. - "", // Stage reviewer settings. - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, nil, ""), } nextDraft, back, err := prompter.editExistingReviewerEntity(initReviewerEntityDraftFromConfig(existing), draft) @@ -5849,12 +5995,8 @@ func TestHuhInitReviewerEntityPrompterAccessibleShowsSeededDisplayNamePrompt(t * draft.ReviewerDisplayName = "Old label" var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Keep the seeded reviewer entity label. - "", // Keep this reviewer entity's current secret location. - "", // Stage reviewer settings. - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, nil, ""), } nextDraft, back, err := prompter.editExistingReviewerEntity(initReviewerEntityDraftFromConfig(existing), draft) @@ -5882,12 +6024,10 @@ func TestHuhInitReviewerEntityPrompterExistingReviewerCanEditLabel(t *testing.T) draft := seedInteractiveInitDraft("work", "work", "work", &existing) var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "OC Collective bot", // Edit reviewer entity label. - "", // Keep the current reviewer secret location. - "", // Stage reviewer settings. - }, "\n")), stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, map[initLinearFieldID]string{ + initReviewerEntityFieldLabel: "OC Collective bot", + }, ""), } nextDraft, back, err := prompter.editExistingReviewerEntity(initReviewerEntityDraftFromConfig(existing), draft) @@ -5911,17 +6051,68 @@ func TestHuhInitReviewerEntityPrompterExistingReviewerCanEditLabel(t *testing.T) } } +func TestHuhInitReviewerEntityPrompterDefaultUsesLinearReviewerFlow(t *testing.T) { + existing := basicProfile("work") + var stderr bytes.Buffer + prompter := huhInitReviewerEntityPrompter{ + stderr: &stderr, + editorRunner: func(editor initLinearEditor, _ io.Reader, out io.Writer) (initLinearEditorModel, error) { + model := newInitLinearEditorModel(editor, 160, 24) + model = selectInitLinearFieldValue(t, model, initReviewerEntityFieldSelection, string(initReviewerEntityKindPAT)) + model = focusInitLinearField(t, model, initReviewerEntityFieldAction) + model = selectInitLinearFieldValue(t, model, initReviewerEntityFieldAction, initDetailActionEdit) + _, _ = io.WriteString(out, model.View()) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + }, + } + + draft, err := prompter.EditReviewerEntity(initReviewerEntityPrompt{Context: initPromptContext{ + RequestedProfileName: "work", + ExistingProfileName: "work", + ExistingProfile: &existing, + DefaultProfileName: "work", + ExistingConfig: config.File{Profiles: map[string]config.Profile{"work": existing}}, + }}) + if err != nil { + t.Fatalf("EditReviewerEntity: %v", err) + } + if !draft.ReviewerEnabled || draft.ReviewerAuth != string(config.GitAuthModePAT) { + t.Fatalf("draft = %#v, want PAT reviewer", draft) + } + out := stderr.String() + for _, want := range []string{ + "Reviewer entity", + "Configure new personal access token (PAT) reviewer", + "Reviewer details", + "Personal access token (PAT) reviewer", + "Entity label", + "Reviewer secret location", + "Reviewer action", + "Stage reviewer settings", + } { + if !strings.Contains(out, want) { + t.Fatalf("stderr missing %q:\n%s", want, out) + } + } + assertContentOrder(t, out, "Reviewer entity", "Reviewer details", "Entity label", "Reviewer secret location", "Reviewer action") + if strings.Contains(out, "Back to main menu") { + t.Fatalf("stderr = %q, want action-local Back without staging instead of inventory Back", out) + } +} + func TestHuhInitReviewerEntityDetailsBackDoesNotMutateDraft(t *testing.T) { t.Setenv("TERM", "dumb") draft := seedInteractiveInitDraft("work", "work", "work", nil) want := draft var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "2", // Back without staging. - "", - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, nil, initDetailActionBack), } nextDraft, back, err := prompter.editNewReviewerEntity(initReviewerEntityKindUseGitIdentity, draft) @@ -5944,11 +6135,8 @@ func TestHuhInitReviewerEntityDetailsAccessibleHidesSecretLocationForGitIdentity draft := seedInteractiveInitDraft("work", "work", "work", nil) var stderr bytes.Buffer prompter := huhInitReviewerEntityPrompter{ - stdin: strings.NewReader(strings.Join([]string{ - "", // Stage reviewer settings. - "", - }, "\n")), - stderr: &stderr, + stderr: &stderr, + editorRunner: stageReviewerEntityEditorRunner(t, nil, ""), } nextDraft, back, err := prompter.editNewReviewerEntity(initReviewerEntityKindUseGitIdentity, draft) @@ -7183,6 +7371,13 @@ func TestHuhInitPrompterAccessibleShowsExistingProfileHealthWarnings(t *testing. "", }, "\n")), stderr: &stderr, + llmRuntimePrompter: initLLMRuntimePrompterFunc(func(initLLMRuntimePrompt) (initDraft, error) { + return initDraft{ + LLMProvider: string(config.LLMProviderOpenAI), + LLMAuth: string(config.LLMAuthSubscription), + LLMAdapter: string(config.LLMAdapterCodexCLI), + }, nil + }), inventoryRunner: func(prompt initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, prompt.Description+"\n") _, _ = io.WriteString(out, "work\n") @@ -7233,6 +7428,13 @@ func TestHuhInitPrompterAccessibleHidesReviewerEntityLabelForProfileGitAccount(t "", }, "\n")), stderr: &stderr, + llmRuntimePrompter: initLLMRuntimePrompterFunc(func(initLLMRuntimePrompt) (initDraft, error) { + return initDraft{ + LLMProvider: string(config.LLMProviderOpenAI), + LLMAuth: string(config.LLMAuthSubscription), + LLMAdapter: string(config.LLMAdapterCodexCLI), + }, nil + }), inventoryRunner: func(prompt initInventoryPrompt, _ io.Reader, out io.Writer) (initInventoryResult, error) { _, _ = io.WriteString(out, prompt.Description+"\n") _, _ = io.WriteString(out, "Create new profile\n") @@ -8945,44 +9147,307 @@ func TestHuhInitRetentionPrompterBackReturnsNavigateBack(t *testing.T) { } } -func TestValidateRetentionMaxAgeDaysUsesCurrentFieldCopy(t *testing.T) { - tests := []struct { - name string - value string - want string - }{ - { - name: "non-number", - value: "abc", - want: "maximum run-data age in days must be a whole number", - }, - { - name: "negative", - value: "-1", - want: "maximum run-data age in days must be non-negative", - }, - } +func TestBubbleTeaInitRetentionEditorShowsLinearGlobalSettingsFlow(t *testing.T) { + editor := initRetentionEditor(config.RetentionConfig{}) + model := newInitLinearEditorModel(editor, 160, 24) + view := model.View() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateRetentionMaxAgeDays(tt.value) - if err == nil { - t.Fatalf("validateRetentionMaxAgeDays(%q) error = nil, want %q", tt.value, tt.want) + for _, want := range []string{ + "Global settings", + "Configure behavior that applies across review profiles.", + "Run data", + "Maximum run-data age in days", + "> 90", + "Global settings action", + "Stage global settings", + "Back without staging", + } { + if !strings.Contains(view, want) { + t.Fatalf("view missing %q:\n%s", want, view) + } + } + assertContentOrder := func(parts ...string) { + t.Helper() + previous := -1 + for _, part := range parts { + index := strings.Index(view, part) + if index < 0 { + t.Fatalf("view missing %q:\n%s", part, view) } - if err.Error() != tt.want { - t.Fatalf("validateRetentionMaxAgeDays(%q) error = %q, want %q", tt.value, err.Error(), tt.want) + if index <= previous { + t.Fatalf("view order wrong for %q:\n%s", part, view) } - }) + previous = index + } } + assertContentOrder("Global settings", "Run data", "Maximum run-data age in days", "Global settings action") } -func TestHuhInitKeyringBackendPrompterAccessibleShowsField(t *testing.T) { +func TestBubbleTeaInitRetentionPrompterStagesEditedValue(t *testing.T) { + thirty := 30 + prompter := bubbleTeaInitRetentionPrompter{ + editorRunner: func(editor initLinearEditor, _ io.Reader, _ io.Writer) (initLinearEditorModel, error) { + model := newInitLinearEditorModel(editor, 160, 24) + model = focusInitLinearField(t, model, initRetentionFieldMaxAge) + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlU}) + model = typeInitLinearText(t, model, "45") + model = focusInitLinearField(t, model, initRetentionFieldAction) + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("Update returned nil command, want quit command after staging") + } + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + }, + } + + edit, err := prompter.EditRetention(initRetentionPrompt{ + Retention: config.RetentionConfig{ + MaxAgeDays: &thirty, + Enforcement: config.RetentionManualOnly, + }, + }) + if err != nil { + t.Fatalf("EditRetention: %v", err) + } + if !edit.Apply { + t.Fatal("edit.Apply = false, want true") + } + if edit.Retention.MaxAgeDaysValue() != 45 { + t.Fatalf("MaxAgeDaysValue = %d, want 45", edit.Retention.MaxAgeDaysValue()) + } + if edit.Retention.Enforcement != config.RetentionManualOnly { + t.Fatalf("Enforcement = %q, want preserved manual_only", edit.Retention.Enforcement) + } +} + +func TestBubbleTeaInitRetentionPrompterBlankResetsToDefault(t *testing.T) { + thirty := 30 + prompter := bubbleTeaInitRetentionPrompter{ + editorRunner: func(editor initLinearEditor, _ io.Reader, _ io.Writer) (initLinearEditorModel, error) { + model := newInitLinearEditorModel(editor, 160, 24) + model = focusInitLinearField(t, model, initRetentionFieldMaxAge) + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlU}) + model = focusInitLinearField(t, model, initRetentionFieldAction) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + }, + } + + edit, err := prompter.EditRetention(initRetentionPrompt{ + Retention: config.RetentionConfig{MaxAgeDays: &thirty}, + }) + if err != nil { + t.Fatalf("EditRetention: %v", err) + } + if edit.Retention.MaxAgeDaysValue() != config.DefaultRetentionConfig().MaxAgeDaysValue() { + t.Fatalf("MaxAgeDaysValue = %d, want default %d", edit.Retention.MaxAgeDaysValue(), config.DefaultRetentionConfig().MaxAgeDaysValue()) + } +} + +func TestBubbleTeaInitRetentionPrompterBackReturnsNavigateBack(t *testing.T) { + prompter := bubbleTeaInitRetentionPrompter{ + editorRunner: func(editor initLinearEditor, _ io.Reader, _ io.Writer) (initLinearEditorModel, error) { + model := newInitLinearEditorModel(editor, 160, 24) + model = focusInitLinearField(t, model, initRetentionFieldAction) + model = selectInitLinearFieldValue(t, model, initRetentionFieldAction, initDetailActionBack) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + }, + } + + _, err := prompter.EditRetention(initRetentionPrompt{Retention: config.RetentionConfig{}}) + if !errors.Is(err, errInitNavigateBack) { + t.Fatalf("EditRetention error = %v, want errInitNavigateBack", err) + } +} + +func TestBubbleTeaInitRetentionActionKeepsEditorOpenOnValidationError(t *testing.T) { + editor := initRetentionEditor(config.RetentionConfig{}) + model := newInitLinearEditorModel(editor, 160, 24) + model = focusInitLinearField(t, model, initRetentionFieldMaxAge) + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlU}) + model = typeInitLinearText(t, model, "abc") + model = focusInitLinearField(t, model, initRetentionFieldAction) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + if cmd != nil { + t.Fatal("Update returned quit command, want editor to stay open on validation error") + } + if next.resultAction != "" { + t.Fatalf("resultAction = %q, want empty", next.resultAction) + } + actionIndex := next.document.fieldIndexByID(initRetentionFieldAction) + if actionIndex < 0 || !strings.Contains(next.document[actionIndex].Error, "whole number") { + t.Fatalf("action error = %q, want whole-number validation", next.document[actionIndex].Error) + } +} + +func TestInitLinearEditorActionQuitClearsFinalView(t *testing.T) { + editor := initRetentionEditor(config.RetentionConfig{}) + model := newInitLinearEditorModel(editor, 160, 24) + model = focusInitLinearField(t, model, initRetentionFieldAction) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + if cmd == nil { + t.Fatal("Update returned nil command, want quit command after staging") + } + if !next.quitting { + t.Fatal("quitting = false, want action quit to clear final rendered frame") + } + if got := next.View(); got != "" { + t.Fatalf("View after action quit = %q, want empty", got) + } +} + +func TestInitLinearEditorArrowKeysChangeSelectNotFocus(t *testing.T) { + const choiceField initLinearFieldID = "choice" + const inputField initLinearFieldID = "input" + var document initLinearDocument + document.addEditableSelect(choiceField, "Choice", "", []huh.Option[string]{ + huh.NewOption("Alpha", "alpha"), + huh.NewOption("Beta", "beta"), + }, "alpha") + document.addEditableInput(inputField, "Input", "", "value", nil) + model := newInitLinearEditorModel(initLinearEditor{Document: document}, 120, 12) + choiceIndex := model.document.fieldIndexByID(choiceField) + + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) + if got := model.document.selectedValue(choiceField); got != "beta" { + t.Fatalf("selected value after down = %q, want beta", got) + } + if model.focused != choiceIndex { + t.Fatalf("focused after select down = %d, want unchanged choice index %d", model.focused, choiceIndex) + } + + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyUp}) + if got := model.document.selectedValue(choiceField); got != "alpha" { + t.Fatalf("selected value after up = %q, want alpha", got) + } + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyTab}) + inputIndex := model.document.fieldIndexByID(inputField) + if model.focused != inputIndex { + t.Fatalf("focused after tab = %d, want input index %d", model.focused, inputIndex) + } + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) + if model.focused != inputIndex { + t.Fatalf("focused after input down = %d, want unchanged input index %d", model.focused, inputIndex) + } +} + +func TestInitLinearEditorArrowKeysDoNotScrollFocusedInput(t *testing.T) { + const inputField initLinearFieldID = "input" + var document initLinearDocument + document.addEditableInput(inputField, "Input", "", "value", nil) + for i := 0; i < 20; i++ { + document.addSection(fmt.Sprintf("Section %02d", i), "Context line") + } + model := newInitLinearEditorModel(initLinearEditor{Document: document}, 120, 5) + model.viewport.SetYOffset(1) + + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) + if got := model.viewport.YOffset; got != 1 { + t.Fatalf("viewport YOffset after input down = %d, want unchanged 1", got) + } + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyUp}) + if got := model.viewport.YOffset; got != 1 { + t.Fatalf("viewport YOffset after input up = %d, want unchanged 1", got) + } +} + +func TestInitLinearEditorOnlyFocusedSelectedFieldShowsCaret(t *testing.T) { + const firstField initLinearFieldID = "first" + const secondField initLinearFieldID = "second" + var document initLinearDocument + document.addEditableSelect(firstField, "First", "", []huh.Option[string]{ + huh.NewOption("Alpha", "alpha"), + huh.NewOption("Beta", "beta"), + }, "alpha") + document.addEditableSelect(secondField, "Second", "", []huh.Option[string]{ + huh.NewOption("Gamma", "gamma"), + huh.NewOption("Delta", "delta"), + }, "delta") + + model := newInitLinearEditorModel(initLinearEditor{Document: document}, 120, 12) + if got := strings.Count(model.layout.Content, "> "); got != 1 { + t.Fatalf("initial caret count = %d, want 1:\n%s", got, model.layout.Content) + } + if !strings.Contains(model.layout.Content, "> Alpha") { + t.Fatalf("initial content missing focused selected option:\n%s", model.layout.Content) + } + if strings.Contains(model.layout.Content, "> Delta") { + t.Fatalf("initial content shows caret on unfocused selected option:\n%s", model.layout.Content) + } + + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyTab}) + if got := strings.Count(model.layout.Content, "> "); got != 1 { + t.Fatalf("caret count after tab = %d, want 1:\n%s", got, model.layout.Content) + } + if !strings.Contains(model.layout.Content, "> Delta") { + t.Fatalf("content after tab missing focused selected option:\n%s", model.layout.Content) + } + if strings.Contains(model.layout.Content, "> Alpha") { + t.Fatalf("content after tab shows caret on unfocused selected option:\n%s", model.layout.Content) + } +} + +func TestValidateRetentionMaxAgeDaysUsesCurrentFieldCopy(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + { + name: "non-number", + value: "abc", + want: "maximum run-data age in days must be a whole number", + }, + { + name: "negative", + value: "-1", + want: "maximum run-data age in days must be non-negative", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRetentionMaxAgeDays(tt.value) + if err == nil { + t.Fatalf("validateRetentionMaxAgeDays(%q) error = nil, want %q", tt.value, tt.want) + } + if err.Error() != tt.want { + t.Fatalf("validateRetentionMaxAgeDays(%q) error = %q, want %q", tt.value, err.Error(), tt.want) + } + }) + } +} + +func TestHuhInitKeyringBackendPrompterAccessibleShowsField(t *testing.T) { rows := initSecretsManagementInventoryRows(config.File{}) if len(rows) == 0 { t.Fatal("initSecretsManagementInventoryRows returned no rows") } - if rows[0].Title != "Legacy compatibility (Automatic OS default)" { - t.Fatalf("first row title = %q, want legacy compatibility row first", rows[0].Title) + if rows[0].Title != "Default credential store (Automatic OS default)" { + t.Fatalf("first row title = %q, want default credential-store row first", rows[0].Title) } var foundConfigure bool for _, row := range rows { @@ -9000,6 +9465,24 @@ func TestHuhInitKeyringBackendPrompterAccessibleShowsField(t *testing.T) { } } +func TestInitSecretsManagementInventoryRowsUsePreferredBackendOrder(t *testing.T) { + rows := initSecretsManagementInventoryRows(config.File{}) + titles := make([]string, 0, len(rows)) + for _, row := range rows { + titles = append(titles, row.Title) + } + assertContentOrder(t, strings.Join(titles, "\n"), + "Default credential store", + "Configure new macos keychain profile", + "Configure new pass password store profile", + "Configure new encrypted file profile", + "Configure new 1password desktop app profile", + "Configure new 1password service account profile", + "Configure new 1password connect profile", + "Configure new in-memory store profile", + ) +} + func TestInitInteractiveProfileSubflowBackPreservesBuiltWorkspace(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") saveCredentialTestConfig(t, path, config.File{ @@ -9489,7 +9972,7 @@ func TestInitSecretsManagementInventoryRowsDisableUnavailableBackends(t *testing } func TestValidateInitSecretsRequiredSingleLine(t *testing.T) { - if err := validateInitSecretsRequiredSingleLine("", true, "1Password vault id"); err == nil || err.Error() != "1Password vault id is required" { + if err := validateInitSecretsRequiredSingleLine("", true, "1Password vault name or id"); err == nil || err.Error() != "1Password vault name or id is required" { t.Fatalf("required validator error = %v, want required field failure", err) } if err := validateInitSecretsRequiredSingleLine("https://connect.example", true, "1Password Connect host"); err != nil { @@ -9511,6 +9994,67 @@ func TestInitSecretsProfileBackendOptionsExcludeUnavailableChoicesUnlessCurrent( } } } + if !initOnePasswordBackendsAvailable() { + for _, backend := range []credstore.Backend{ + credstore.BackendOPDesktop, + credstore.BackendOP, + credstore.BackendOPConnect, + } { + if slices.Contains(values, string(backend)) { + t.Fatalf("%s should be excluded from selectable backend options when 1Password is compiled out: %v", backend, values) + } + } + } +} + +func TestInitSecretsProfileBackendOptionsUsePreferredOrder(t *testing.T) { + options := initSecretsProfileBackendOptions(config.SecretsBackendKind(credstore.BackendFile)) + values := make([]string, 0, len(options)) + for _, option := range options { + values = append(values, option.Value) + } + joined := "\n" + strings.Join(values, "\n") + "\n" + want := []string{ + "\n" + string(credstore.BackendPass) + "\n", + "\n" + string(credstore.BackendFile) + "\n", + } + if initOnePasswordBackendsAvailable() { + want = append(want, + "\n"+string(credstore.BackendOPDesktop)+"\n", + "\n"+string(credstore.BackendOP)+"\n", + "\n"+string(credstore.BackendOPConnect)+"\n", + ) + } + want = append(want, "\n"+string(credstore.BackendMemory)+"\n") + assertContentOrder(t, joined, want...) +} + +func TestInitSecretsManagementLinearEditorHidesOnePasswordCreateTargetsWhenUnavailable(t *testing.T) { + if initOnePasswordBackendsAvailable() { + t.Skip("1Password create targets are hidden only in keyring_no1password builds") + } + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + } + editor := initSecretsManagementLinearEditor(cfg) + model := newInitLinearEditorModel(editor, 180, 32) + targetIndex := model.document.fieldIndexByID(initSecretsManagementFieldTarget) + if targetIndex < 0 { + t.Fatal("target field missing") + } + for _, backend := range []credstore.Backend{ + credstore.BackendOPDesktop, + credstore.BackendOP, + credstore.BackendOPConnect, + } { + targetValue := initConfigureSecretsProfileSelectionPrefix + string(backend) + for _, option := range model.document[targetIndex].Options { + if option.Value == targetValue { + t.Fatalf("target options include %q in keyring_no1password build: %#v", targetValue, model.document[targetIndex].Options) + } + } + } } func TestHuhInitKeyringBackendPrompterStagesNewSecretsProfileEndToEnd(t *testing.T) { @@ -9548,20 +10092,521 @@ func TestHuhInitKeyringBackendPrompterStagesNewSecretsProfileEndToEnd(t *testing Config: config.File{Profiles: map[string]config.Profile{"default": basicProfile("default")}, DefaultProfile: "default"}, }) if err != nil { - t.Fatalf("EditKeyringBackend: %v", err) + t.Fatalf("EditKeyringBackend: %v", err) + } + if !edit.Apply { + t.Fatalf("edit = %#v, want apply=true", edit) + } + profile, ok := edit.Config.Secrets.Profiles["encrypted-file"] + if !ok { + t.Fatalf("secrets profiles = %#v, want generated encrypted-file profile", edit.Config.Secrets.Profiles) + } + if profile.Backend.Kind != config.SecretsBackendKind(credstore.BackendFile) { + t.Fatalf("backend kind = %q, want file", profile.Backend.Kind) + } + if profile.Label != "Encrypted file" { + t.Fatalf("profile label = %q, want backend-derived label", profile.Label) + } +} + +func TestHuhInitKeyringBackendPrompterDefaultUsesLinearSecretsManagementFlow(t *testing.T) { + var stderr bytes.Buffer + prompter := huhInitKeyringBackendPrompter{ + stderr: &stderr, + editorRunner: func(editor initLinearEditor, _ io.Reader, out io.Writer) (initLinearEditorModel, error) { + model := newInitLinearEditorModel(editor, 180, 32) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsProfileSelectionPrefix+string(credstore.BackendFile)) + _, _ = io.WriteString(out, model.layout.Content) + model = focusInitLinearField(t, model, initSecretsManagementFieldAction) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldAction, initDetailActionEdit) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + }, + } + + edit, err := prompter.EditKeyringBackend(initKeyringBackendPrompt{ + Config: config.File{Profiles: map[string]config.Profile{"default": basicProfile("default")}, DefaultProfile: "default"}, + }) + if err != nil { + t.Fatalf("EditKeyringBackend: %v", err) + } + if !edit.Apply || !edit.HasConfigEdit { + t.Fatalf("edit = %#v, want config edit", edit) + } + profile, ok := edit.Config.Secrets.Profiles["encrypted-file"] + if !ok { + t.Fatalf("secrets profiles = %#v, want generated encrypted-file profile", edit.Config.Secrets.Profiles) + } + if profile.Backend.Kind != config.SecretsBackendKind(credstore.BackendFile) { + t.Fatalf("backend kind = %q, want file", profile.Backend.Kind) + } + out := stderr.String() + for _, want := range []string{ + "Secrets management", + "Secrets-management target", + "Configure new encrypted file profile", + "Secrets-management profile", + "Secrets-management profile label", + "Default secrets-management profile", + "Secrets-management action", + } { + if !strings.Contains(out, want) { + t.Fatalf("stderr missing %q:\n%s", want, out) + } + } + assertContentOrder(t, out, "Secrets-management target", "Secrets-management profile label", "Default secrets-management profile", "Secrets-management action") + if strings.Contains(out, "Back to main menu") { + t.Fatalf("stderr = %q, want action-local Back without staging instead of inventory Back", out) + } + if strings.Contains(out, "1Password vault name or id") { + t.Fatalf("stderr = %q, want file backend to hide 1Password-specific fields", out) + } +} + +func TestHuhInitKeyringBackendPrompterLinearCanDeleteConfiguredSecretsProfile(t *testing.T) { + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + Secrets: config.SecretsConfig{ + DefaultProfile: "personal", + Profiles: map[string]config.SecretsProfile{ + "personal": { + Label: "1Password", + Backend: config.SecretsProfileBackend{ + Kind: config.SecretsBackendKind(credstore.BackendOPDesktop), + OnePassword: &config.SecretsProfileOnePasswordConfig{VaultID: "Personal"}, + }, + }, + "onepasswordfoo": { + Label: "1PasswordFoo", + Backend: config.SecretsProfileBackend{ + Kind: config.SecretsBackendKind(credstore.BackendOPDesktop), + OnePassword: &config.SecretsProfileOnePasswordConfig{VaultID: "Personal"}, + }, + }, + }, + }, + } + var stderr bytes.Buffer + editorCalls := 0 + prompter := huhInitKeyringBackendPrompter{ + stderr: &stderr, + editorRunner: func(editor initLinearEditor, _ io.Reader, out io.Writer) (initLinearEditorModel, error) { + editorCalls++ + model := newInitLinearEditorModel(editor, 180, 32) + switch editorCalls { + case 1: + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, "onepasswordfoo") + model = focusInitLinearField(t, model, initSecretsManagementFieldTarget) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + case 2: + _, _ = io.WriteString(out, model.View()) + if !strings.Contains(model.layout.Content, "1PasswordFoo (1Password desktop app) (Staged for deletion)") { + t.Fatalf("second editor content missing pending row:\n%s", model.layout.Content) + } + targetIndex := model.document.fieldIndexByID(initSecretsManagementFieldTarget) + targetOptions := model.document[targetIndex].Options + if got := targetOptions[len(targetOptions)-1].Value; got != initSecretsManagementRestoreSelectionPrefix+"onepasswordfoo" { + t.Fatalf("last target option = %q, want staged deletion restore option last; options = %#v", got, targetOptions) + } + model = focusInitLinearField(t, model, initSecretsManagementFieldAction) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldAction, initDetailActionEdit) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + default: + t.Fatalf("unexpected editor call %d", editorCalls) + return initLinearEditorModel{}, nil + } + }, + } + + edit, err := prompter.EditKeyringBackend(initKeyringBackendPrompt{Config: cfg}) + if err != nil { + t.Fatalf("EditKeyringBackend: %v", err) + } + if !edit.Apply || !edit.HasConfigEdit { + t.Fatalf("edit = %#v, want config edit", edit) + } + if _, ok := edit.Config.Secrets.Profiles["onepasswordfoo"]; ok { + t.Fatalf("secrets profiles = %#v, want onepasswordfoo removed", edit.Config.Secrets.Profiles) + } + if _, ok := edit.Config.Secrets.Profiles["personal"]; !ok { + t.Fatalf("secrets profiles = %#v, want personal retained", edit.Config.Secrets.Profiles) + } + if edit.Config.Secrets.DefaultProfile != "personal" { + t.Fatalf("default secrets profile = %q, want personal", edit.Config.Secrets.DefaultProfile) + } + if !strings.Contains(stderr.String(), "1PasswordFoo (1Password desktop app) (Staged for deletion)") { + t.Fatalf("stderr = %q, want staged deletion suffix", stderr.String()) + } +} + +func TestInitSecretsManagementTargetOptionsMovesPendingDeletesToBottomInDeletionOrder(t *testing.T) { + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + Secrets: config.SecretsConfig{ + Profiles: map[string]config.SecretsProfile{ + "personal": { + Label: "Personal", + Backend: config.SecretsProfileBackend{Kind: config.SecretsBackendKind(credstore.BackendFile)}, + }, + }, + }, + } + pendingDeletes := map[string]initPendingSecretsManagementDelete{ + "alpha": {ID: "alpha", Profile: config.SecretsProfile{ + Label: "Alpha", + Backend: config.SecretsProfileBackend{Kind: config.SecretsBackendKind(credstore.BackendFile)}, + }}, + "beta": {ID: "beta", Profile: config.SecretsProfile{ + Label: "Beta", + Backend: config.SecretsProfileBackend{Kind: config.SecretsBackendKind(credstore.BackendFile)}, + }}, + } + + options := initSecretsManagementTargetOptions(cfg, pendingDeletes, []string{"alpha", "beta"}) + values := make([]string, 0, len(options)) + for _, option := range options { + values = append(values, option.Value) + } + wantSuffix := []string{ + initSecretsManagementRestoreSelectionPrefix + "alpha", + initSecretsManagementRestoreSelectionPrefix + "beta", + } + if len(values) < len(wantSuffix) || !reflect.DeepEqual(values[len(values)-len(wantSuffix):], wantSuffix) { + t.Fatalf("target option values = %#v, want pending deletes last in staging order %#v", values, wantSuffix) + } +} + +func TestInitSecretsManagementLinearEditorDeleteActionOnlyAppliesToConfiguredProfiles(t *testing.T) { + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + Secrets: config.SecretsConfig{ + DefaultProfile: "personal", + Profiles: map[string]config.SecretsProfile{ + "personal": { + Label: "1Password", + Backend: config.SecretsProfileBackend{Kind: config.SecretsBackendKind(credstore.BackendFile)}, + }, + "unused": { + Label: "Unused", + Backend: config.SecretsProfileBackend{Kind: config.SecretsBackendKind(credstore.BackendFile)}, + }, + }, + }, + } + editor := initSecretsManagementLinearEditor(cfg) + model := newInitLinearEditorModel(editor, 180, 32) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsProfileSelectionPrefix+string(credstore.BackendFile)) + actionIndex := model.document.fieldIndexByID(initSecretsManagementFieldAction) + if actionIndex < 0 { + t.Fatal("action field missing") + } + for _, option := range model.document[actionIndex].Options { + if option.Value == initSecretsManagementActionDelete { + t.Fatalf("create-new target exposes delete action: %#v", model.document[actionIndex].Options) + } + } + + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, "personal") + targetIndex := model.document.fieldIndexByID(initSecretsManagementFieldTarget) + for _, option := range model.document[targetIndex].Options { + if option.Value == "personal" && option.Deletable { + t.Fatalf("default secrets-management profile option is deletable: %#v", option) + } + } + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, "unused") + foundDeletable := false + for _, option := range model.document[targetIndex].Options { + if option.Value == "unused" { + foundDeletable = option.Deletable + } + } + if !foundDeletable { + t.Fatalf("target options = %#v, want non-default configured profile to be deletable", model.document[targetIndex].Options) + } +} + +func TestInitLinearEditorCtrlWDeletesPreviousWord(t *testing.T) { + const inputField initLinearFieldID = "input" + var document initLinearDocument + document.addEditableInput(inputField, "Input", "", "hello brave world", nil) + model := newInitLinearEditorModel(initLinearEditor{Document: document}, 120, 12) + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlW}) + + if got := model.document.fieldValue(inputField); got != "hello brave " { + t.Fatalf("input after ctrl+w = %q, want previous word deleted", got) + } + if got := model.document[model.document.fieldIndexByID(inputField)].Cursor; got != len("hello brave ") { + t.Fatalf("cursor after ctrl+w = %d, want end of remaining text", got) + } +} + +func TestInitLinearEditorAlignsFieldDescriptionsWithFieldTitles(t *testing.T) { + var document initLinearDocument + document.addSection("Section", "Section description") + document.addEditableInput("input", "Input", "Input description wraps with field title alignment.", "value", nil) + model := newInitLinearEditorModel(initLinearEditor{Document: document}, 80, 12) + + if !strings.Contains(model.layout.Content, "\nInput\nInput description") { + t.Fatalf("layout content does not align input description with title:\n%s", model.layout.Content) + } + if strings.Contains(model.layout.Content, "\n Input\n") || strings.Contains(model.layout.Content, "\n Input description") { + t.Fatalf("layout content has stale field-title indentation:\n%s", model.layout.Content) + } +} + +func TestInitSecretsManagementLinearEditorOnlyFocusedSelectChangesAndShowsCaret(t *testing.T) { + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + Secrets: config.SecretsConfig{ + Profiles: map[string]config.SecretsProfile{ + "work-secrets": { + Label: "Work secrets", + Backend: config.SecretsProfileBackend{ + Kind: config.SecretsBackendKind(credstore.BackendFile), + }, + }, + }, + }, + } + editor := initSecretsManagementLinearEditor(cfg) + model := newInitLinearEditorModel(editor, 180, 32) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, "work-secrets") + model = focusInitLinearField(t, model, initSecretsManagementFieldBackend) + + targetBefore := model.document.selectedValue(initSecretsManagementFieldTarget) + defaultBefore := model.document.selectedValue(initSecretsManagementFieldDefault) + actionBefore := model.document.selectedValue(initSecretsManagementFieldAction) + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) + + if got := model.document.selectedValue(initSecretsManagementFieldTarget); got != targetBefore { + t.Fatalf("target selection = %q, want unchanged %q", got, targetBefore) + } + if got := model.document.selectedValue(initSecretsManagementFieldDefault); got != defaultBefore { + t.Fatalf("default selection = %q, want unchanged %q", got, defaultBefore) + } + if got := model.document.selectedValue(initSecretsManagementFieldAction); got != actionBefore { + t.Fatalf("action selection = %q, want unchanged %q", got, actionBefore) + } + if got := strings.Count(model.layout.Content, "> "); got != 1 { + t.Fatalf("caret count = %d, want 1:\n%s", got, model.layout.Content) + } + targetLine := -1 + for index, line := range strings.Split(model.layout.Content, "\n") { + if strings.TrimSpace(line) == "Work secrets (Encrypted file)" { + targetLine = index + break + } + } + if targetLine < 0 { + t.Fatalf("target selected line missing:\n%s", model.layout.Content) + } + if !model.layout.SelectedLines[targetLine] { + t.Fatalf("target selected line is not marked selected; selected lines = %#v\n%s", model.layout.SelectedLines, model.layout.Content) + } + for _, unfocusedSelected := range []string{ + "> Work secrets (Encrypted file)", + "> No, keep the current default secrets-management profile", + "> Stage secrets-management settings", + } { + if strings.Contains(model.layout.Content, unfocusedSelected) { + t.Fatalf("content shows active caret on unfocused selected row %q:\n%s", unfocusedSelected, model.layout.Content) + } + } +} + +func TestInitSecretsManagementLinearEditorCreateBackendTargetLocksBackend(t *testing.T) { + if !initOnePasswordBackendsAvailable() { + t.Skip("1Password create targets are not selectable in keyring_no1password builds") + } + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + } + editor := initSecretsManagementLinearEditor(cfg) + model := newInitLinearEditorModel(editor, 180, 32) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsProfileSelectionPrefix+string(credstore.BackendOP)) + + backendIndex := model.document.fieldIndexByID(initSecretsManagementFieldBackend) + if backendIndex < 0 { + t.Fatal("backend field missing") + } + backend := model.document[backendIndex] + if got := model.document.selectedValue(initSecretsManagementFieldBackend); got != string(credstore.BackendOP) { + t.Fatalf("selected backend = %q, want op", got) + } + if !backend.Hidden { + t.Fatalf("backend field hidden = false, want create-new target to hide redundant backend selector") + } + sectionIndex := model.document.fieldIndexByID(initSecretsManagementSectionProfile) + if sectionIndex < 0 { + t.Fatal("profile section missing") + } + if !strings.Contains(model.document[sectionIndex].Description, "Selected target: Configure new 1password service account profile") { + t.Fatalf("profile section description = %q, want selected-target context", model.document[sectionIndex].Description) + } +} + +func TestInitSecretsManagementLinearEditorDesktopTargetSeedsFriendlyLabel(t *testing.T) { + if !initOnePasswordBackendsAvailable() { + t.Skip("1Password create targets are not selectable in keyring_no1password builds") + } + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + } + editor := initSecretsManagementLinearEditor(cfg) + model := newInitLinearEditorModel(editor, 180, 32) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsProfileSelectionPrefix+string(credstore.BackendOPDesktop)) + + if got := model.document.fieldValue(initSecretsManagementFieldLabel); got != "1Password" { + t.Fatalf("profile label = %q, want friendly 1Password default", got) + } + if got := model.document.selectedValue(initSecretsManagementFieldBackend); got != string(credstore.BackendOPDesktop) { + t.Fatalf("selected backend = %q, want op-desktop", got) + } + for _, hidden := range []initLinearFieldID{ + initSecretsManagementFieldBackend, + initSecretsManagementFieldItemTitlePrefix, + initSecretsManagementSectionDesktop, + initSecretsManagementFieldDesktopAccountID, + } { + index := model.document.fieldIndexByID(hidden) + if index < 0 { + t.Fatalf("field %q missing", hidden) + } + if !model.document[index].Hidden { + t.Fatalf("field %q hidden = false, want hidden for create-new desktop profile", hidden) + } + } + out := model.layout.Content + for _, want := range []string{ + "1Password vault name or id", + "1Password secret name", + "1Password item tag", + "1Password request timeout", + } { + if !strings.Contains(out, want) { + t.Fatalf("desktop content missing %q:\n%s", want, out) + } + } + for _, hiddenText := range []string{ + "Secrets-management backend", + "1Password item title prefix", + "1Password desktop", + "1Password desktop account id", + } { + if strings.Contains(out, hiddenText) { + t.Fatalf("desktop content includes hidden advanced field %q:\n%s", hiddenText, out) + } + } + assertContentOrder(t, out, "1Password vault name or id", "1Password secret name", "1Password item tag", "1Password request timeout") +} + +func TestInitSecretsManagementLinearEditorShowsOnePasswordBackendRolloverDescriptions(t *testing.T) { + if !initOnePasswordBackendsAvailable() { + t.Skip("1Password create targets are not selectable in keyring_no1password builds") + } + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + } + tests := []struct { + name string + kind string + want string + }{ + {name: "service account", kind: string(credstore.BackendOP), want: "CI or server environments"}, + {name: "connect", kind: string(credstore.BackendOPConnect), want: "Connect API endpoint"}, + {name: "desktop", kind: string(credstore.BackendOPDesktop), want: "Most common for local use"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + editor := initSecretsManagementLinearEditor(cfg) + model := newInitLinearEditorModel(editor, 180, 32) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsProfileSelectionPrefix+string(tc.kind)) + index := model.document.fieldIndexByID(initSecretsManagementFieldBackend) + if index < 0 { + t.Fatal("backend field missing") + } + if !strings.Contains(model.document[index].Description, tc.want) { + t.Fatalf("backend description = %q, want %q", model.document[index].Description, tc.want) + } + }) + } +} + +func TestInitSecretsManagementEditStoresOnePasswordVaultNameOrIDAsEntered(t *testing.T) { + if !initOnePasswordBackendsAvailable() { + t.Skip("1Password create targets are not selectable in keyring_no1password builds") + } + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + } + editor := initSecretsManagementLinearEditor(cfg) + model := newInitLinearEditorModel(editor, 180, 32) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, initConfigureSecretsProfileSelectionPrefix+string(credstore.BackendOPDesktop)) + model.setFieldValue(initSecretsManagementFieldVaultID, "Employee") + edit, err := initSecretsManagementEditFromDocument(cfg, model.document) + if err != nil { + t.Fatalf("initSecretsManagementEditFromDocument: %v", err) } - if !edit.Apply { - t.Fatalf("edit = %#v, want apply=true", edit) + profile := edit.Config.Secrets.Profiles["1password"] + if profile.Backend.OnePassword == nil { + t.Fatal("saved onepassword config = nil") } - profile, ok := edit.Config.Secrets.Profiles["encrypted-file"] - if !ok { - t.Fatalf("secrets profiles = %#v, want generated encrypted-file profile", edit.Config.Secrets.Profiles) + if got := profile.Backend.OnePassword.VaultID; got != "Employee" { + t.Fatalf("saved vault reference = %q, want entered vault name", got) } - if profile.Backend.Kind != config.SecretsBackendKind(credstore.BackendFile) { - t.Fatalf("backend kind = %q, want file", profile.Backend.Kind) +} + +func TestInitSecretsManagementLinearEditorConfiguredProfileKeepsBackendEditable(t *testing.T) { + cfg := config.File{ + Profiles: map[string]config.Profile{"default": basicProfile("default")}, + DefaultProfile: "default", + Secrets: config.SecretsConfig{ + Profiles: map[string]config.SecretsProfile{ + "work-secrets": { + Label: "Work secrets", + Backend: config.SecretsProfileBackend{ + Kind: config.SecretsBackendKind(credstore.BackendFile), + }, + }, + }, + }, } - if profile.Label != "Encrypted file" { - t.Fatalf("profile label = %q, want backend-derived label", profile.Label) + editor := initSecretsManagementLinearEditor(cfg) + model := newInitLinearEditorModel(editor, 180, 32) + model = selectInitLinearFieldValue(t, model, initSecretsManagementFieldTarget, "work-secrets") + + index := model.document.fieldIndexByID(initSecretsManagementFieldBackend) + if index < 0 { + t.Fatal("backend field missing") + } + if len(model.document[index].Options) <= 1 { + t.Fatalf("configured profile backend options = %#v, want editable backend choices", model.document[index].Options) + } + if strings.Contains(model.document[index].Description, "fixed by the selected create-new target") { + t.Fatalf("configured profile backend description says locked: %q", model.document[index].Description) } } @@ -9675,8 +10720,8 @@ func TestHuhInitMenuPrompterAccessibleShowsMenuEntries(t *testing.T) { } out := stderr.String() for _, want := range []string{ - "Configure LLM runtimes (2)", "Configure reviewer entities (3)", + "Configure LLM runtimes (2)", "Configure review profiles (1)", "Configure global settings", "Configure secrets management", @@ -9693,6 +10738,13 @@ func TestHuhInitMenuPrompterAccessibleShowsMenuEntries(t *testing.T) { if strings.Contains(out, "Configure review profiles v2") { t.Fatalf("stderr = %q, want temporary v2 menu item removed", out) } + assertContentOrder(t, out, + "Configure reviewer entities (3)", + "Configure LLM runtimes (2)", + "Configure review profiles (1)", + "Configure global settings", + "Configure secrets management", + ) } func TestHuhInitMenuPrompterAccessibleSelectsSecretsManagement(t *testing.T) { @@ -9719,6 +10771,49 @@ func TestHuhInitMenuPrompterAccessibleSelectsSecretsManagement(t *testing.T) { } } +func TestHuhInitMenuPrompterDefaultStartsAtTopWhenProfileIsActive(t *testing.T) { + t.Setenv("TERM", "dumb") + var stderr bytes.Buffer + prompter := huhInitMenuPrompter{ + stdin: strings.NewReader("\n"), + stderr: &stderr, + } + action, err := prompter.ChooseAction(initMenuPrompt{ + HasWorkspace: true, + ActiveProfileName: "default", + LLMRuntimeCount: 2, + ReviewerEntityCount: 3, + ReviewProfileCount: 1, + CanConfigureLLM: true, + CanConfigureReviewer: true, + CanSave: true, + }) + if err != nil { + t.Fatalf("ChooseAction: %v", err) + } + if action != initMenuActionReviewerEntities { + t.Fatalf("action = %q, want first main-menu configuration item", action) + } +} + +func TestHuhInitMenuPrompterDefaultStartsAtProfileSetupBeforeWorkspace(t *testing.T) { + t.Setenv("TERM", "dumb") + var stderr bytes.Buffer + prompter := huhInitMenuPrompter{ + stdin: strings.NewReader("\n"), + stderr: &stderr, + } + action, err := prompter.ChooseAction(initMenuPrompt{ + ReviewProfileCount: 1, + }) + if err != nil { + t.Fatalf("ChooseAction: %v", err) + } + if action != initMenuActionReviewProfiles { + t.Fatalf("action = %q, want review profile setup before dependent workflows are enabled", action) + } +} + func TestHuhInitMenuPrompterAccessibleRejectsDisabledSaveUntilProfileExists(t *testing.T) { t.Setenv("TERM", "dumb") var stderr bytes.Buffer @@ -9747,7 +10842,7 @@ func TestHuhInitMenuPrompterAccessibleRejectsDisabledLLMUntilProfileExists(t *te var stderr bytes.Buffer prompter := huhInitMenuPrompter{ stdin: strings.NewReader(strings.Join([]string{ - "1", // Configure LLM runtimes (disabled) + "2", // Configure LLM runtimes (disabled) "7", // Discard staged changes and exit "", }, "\n")), @@ -9856,7 +10951,7 @@ func TestInitProfileV2ReadOnlyContentRendersTargetOrderWithRealData(t *testing.T "github.com/open-cli-collective", "Git scope", "Git scope host", - "> github.enterprise", + "github.enterprise", "Reviewer entity", "OCC reviewer (GitHub App reviewer)", "LLM runtime", @@ -9864,18 +10959,18 @@ func TestInitProfileV2ReadOnlyContentRendersTargetOrderWithRealData(t *testing.T "Minimum reviewer model tier", "Model tier mapping", "large model", - "> claude-opus-4-7", + "claude-opus-4-7", "Additional reviewer-agent directories (optional)", "/opt/codereview/agents", "Review Policy", - "> Request changes", - "> Enable self-approve", - "> Auto-resolve", - "> 24h", + "Request changes", + "Enable self-approve", + "Auto-resolve", + "24h", "Git secrets storage label", - "> codereview/custom-git", + "codereview/custom-git", "Profile action", - "> Stage profile settings", + "Stage profile settings", "Back without staging", } { if !strings.Contains(content, want) { @@ -9932,10 +11027,10 @@ func TestInitProfileV2ReadOnlyModelFocusNavigationPreservesRouteGuidance(t *test key tea.KeyType }{ {name: "enter", key: tea.KeyEnter}, - {name: "down", key: tea.KeyDown}, + {name: "tab", key: tea.KeyTab}, } { t.Run(tc.name, func(t *testing.T) { - model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, 240, 19) + model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, 240, 24) model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tc.key}) if model.focused != routeIndex { @@ -9961,6 +11056,97 @@ func TestInitProfileV2ReadOnlyModelFocusNavigationPreservesRouteGuidance(t *test } } +func TestInitProfileV2ArrowKeysChangeSelectNotFocus(t *testing.T) { + const choiceField initProfileV2FieldID = "choice" + const inputField initProfileV2FieldID = "input" + var document initProfileV2Document + document.addEditableSelect(choiceField, "Choice", "", []huh.Option[string]{ + huh.NewOption("Alpha", "alpha"), + huh.NewOption("Beta", "beta"), + }, "alpha") + document.addEditableInput(inputField, "Input", "", "value", nil) + model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, 120, 12) + choiceIndex := model.document.fieldIndexByID(choiceField) + + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) + if got := model.document.selectedValue(choiceField); got != "beta" { + t.Fatalf("selected value after down = %q, want beta", got) + } + if model.focused != choiceIndex { + t.Fatalf("focused after select down = %d, want unchanged choice index %d", model.focused, choiceIndex) + } + + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyUp}) + if got := model.document.selectedValue(choiceField); got != "alpha" { + t.Fatalf("selected value after up = %q, want alpha", got) + } + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyTab}) + inputIndex := model.document.fieldIndexByID(inputField) + if model.focused != inputIndex { + t.Fatalf("focused after tab = %d, want input index %d", model.focused, inputIndex) + } + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) + if model.focused != inputIndex { + t.Fatalf("focused after input down = %d, want unchanged input index %d", model.focused, inputIndex) + } +} + +func TestInitProfileV2ArrowKeysDoNotScrollFocusedInput(t *testing.T) { + const inputField initProfileV2FieldID = "input" + var document initProfileV2Document + document.addEditableInput(inputField, "Input", "", "value", nil) + for i := 0; i < 20; i++ { + document.addSection(fmt.Sprintf("Section %02d", i), "Context line") + } + model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, 120, 5) + model.viewport.SetYOffset(1) + + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) + if got := model.viewport.YOffset; got != 1 { + t.Fatalf("viewport YOffset after input down = %d, want unchanged 1", got) + } + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyUp}) + if got := model.viewport.YOffset; got != 1 { + t.Fatalf("viewport YOffset after input up = %d, want unchanged 1", got) + } +} + +func TestInitProfileV2OnlyFocusedSelectedFieldShowsCaret(t *testing.T) { + const firstField initProfileV2FieldID = "first" + const secondField initProfileV2FieldID = "second" + var document initProfileV2Document + document.addEditableSelect(firstField, "First", "", []huh.Option[string]{ + huh.NewOption("Alpha", "alpha"), + huh.NewOption("Beta", "beta"), + }, "alpha") + document.addEditableSelect(secondField, "Second", "", []huh.Option[string]{ + huh.NewOption("Gamma", "gamma"), + huh.NewOption("Delta", "delta"), + }, "delta") + + model := newInitProfileV2ReadOnlyModel(initProfileV2Editor{Document: document}, 120, 12) + if got := strings.Count(model.layout.Content, "> "); got != 1 { + t.Fatalf("initial caret count = %d, want 1:\n%s", got, model.layout.Content) + } + if !strings.Contains(model.layout.Content, "> Alpha") { + t.Fatalf("initial content missing focused selected option:\n%s", model.layout.Content) + } + if strings.Contains(model.layout.Content, "> Delta") { + t.Fatalf("initial content shows caret on unfocused selected option:\n%s", model.layout.Content) + } + + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyTab}) + if got := strings.Count(model.layout.Content, "> "); got != 1 { + t.Fatalf("caret count after tab = %d, want 1:\n%s", got, model.layout.Content) + } + if !strings.Contains(model.layout.Content, "> Delta") { + t.Fatalf("content after tab missing focused selected option:\n%s", model.layout.Content) + } + if strings.Contains(model.layout.Content, "> Alpha") { + t.Fatalf("content after tab shows caret on unfocused selected option:\n%s", model.layout.Content) + } +} + func TestInitProfileV2LayoutWrapsAndMeasuresSmallViewport(t *testing.T) { var document initProfileV2Document document.addSection("Profile", "This section has enough words to wrap across multiple lines in a narrow terminal.") @@ -10020,7 +11206,7 @@ func TestInitProfileV2TextInputsDraftProfileNameAndRoutes(t *testing.T) { func TestInitProfileV2TextInputsClearRoutes(t *testing.T) { model := newInitProfileV2ReadOnlyModel(newTestInitProfileV2Editor("monit", "github.com/SignalFT"), 160, 24) - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyTab}) model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlU}) draft, err := model.validatedDraft() @@ -10050,7 +11236,7 @@ func TestInitProfileV2TextInputsShowLocalErrors(t *testing.T) { t.Run("routes", func(t *testing.T) { model := newInitProfileV2ReadOnlyModel(newTestInitProfileV2Editor("monit", "github.com/SignalFT"), 160, 24) - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyDown}) + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyTab}) model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlU}) model = typeInitProfileV2Text(t, model, "not-a-route") @@ -10157,7 +11343,7 @@ func TestInitProfileV2GitScopeCustomEditsDraft(t *testing.T) { model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlU}) model = typeInitProfileV2Text(t, model, "gitlab.com") model = focusInitProfileV2Field(t, model, initProfileV2FieldGitAuth) - model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyLeft}) + model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyUp}) draft, err := model.validatedDraft() if err != nil { @@ -10234,7 +11420,7 @@ func TestInitProfileV2SelectsDraftReviewerRuntimeAndModelTier(t *testing.T) { } func TestInitProfileV2NoRuntimeBootstrapRequestsExistingFlow(t *testing.T) { - model := newInitProfileV2ReadOnlyModel(newTestInitProfileV2EditorWithSelections("monit", "github.com/SignalFT", nil, nil), 160, 24) + model := newInitProfileV2ReadOnlyModel(newTestInitProfileV2EditorWithSelections("monit", "github.com/SignalFT", nil, nil), 160, 40) model = focusInitProfileV2Field(t, model, initProfileV2FieldLLMRuntime) if !strings.Contains(model.View(), "Configure a new LLM runtime first") { t.Fatalf("view missing no-runtime bootstrap option:\n%s", model.View()) @@ -10268,7 +11454,7 @@ func TestInitProfileV2ModelMapInputsDraftOverridesAndClears(t *testing.T) { config.ModelMap{ string(config.ModelTierLarge): "claude-opus-4-7", }, - ), 160, 24) + ), 160, 40) model = focusInitProfileV2Field(t, model, initProfileV2FieldModelMap(config.ModelTierSmall)) if !strings.Contains(model.View(), "> |") { @@ -10362,7 +11548,7 @@ func TestInitProfileV2AgentSourcesTextareaDraftsNormalizedSources(t *testing.T) func TestInitProfileV2AgentSourcesEnterMovesFocusWithoutDestroyingNavigation(t *testing.T) { editor := newTestInitProfileV2EditorWithAgentSources("monit", "github.com/SignalFT", nil) editor.Document.addEditableInput(initProfileV2FieldID("after_agent_sources"), "After agent sources", "", "next", nil) - model := newInitProfileV2ReadOnlyModel(editor, 48, 10) + model := newInitProfileV2ReadOnlyModel(editor, 48, 24) model = focusInitProfileV2Field(t, model, initProfileV2FieldAgentSources) model = typeInitProfileV2Text(t, model, strings.Repeat("/tmp/very-long-agent-source-path/", 5)) @@ -10371,8 +11557,8 @@ func TestInitProfileV2AgentSourcesEnterMovesFocusWithoutDestroyingNavigation(t * if got := model.document[model.focused].Title; got != "After agent sources" { t.Fatalf("focused field = %q, want next field after textarea", got) } - if !strings.Contains(model.View(), "After agent sources") { - t.Fatalf("view missing next field after leaving long textarea:\n%s", model.View()) + if !strings.Contains(model.layout.Content, "After agent sources") { + t.Fatalf("layout missing next field after leaving long textarea:\n%s", model.layout.Content) } } @@ -10385,7 +11571,7 @@ func TestInitProfileV2ReviewPolicyDraftsSelections(t *testing.T) { true, nil, initCustomGitScopeSelection, - ), 160, 24) + ), 160, 40) model = selectInitProfileV2FieldValue(t, model, initProfileV2FieldReviewMajorEvent, string(config.ReviewMajorEventRequestChanges)) model = selectInitProfileV2FieldValue(t, model, initProfileV2FieldSelfApprove, initSelfApproveEnable) model = selectInitProfileV2FieldValue(t, model, initProfileV2FieldResolveThreads, string(config.ResolveThreadsAuto)) @@ -10424,7 +11610,10 @@ func TestInitProfileV2ReviewPolicyRejectsInvalidDuration(t *testing.T) { model = typeInitProfileV2Text(t, model, "tomorrow") if !strings.Contains(model.View(), "invalid duration") { - t.Fatalf("view missing duration validation error:\n%s", model.View()) + index := model.document.fieldIndexByID(initProfileV2FieldResolveAfter) + if index < 0 || !strings.Contains(model.document[index].Error, "invalid duration") { + t.Fatalf("duration field error = %q, want invalid duration", model.document[index].Error) + } } if _, err := model.validatedDraft(); err == nil || !strings.Contains(err.Error(), "invalid duration") { t.Fatalf("validatedDraft error = %v, want duration validation", err) @@ -10447,7 +11636,7 @@ func TestInitProfileV2GitStorageLabelDraftsCustomLabel(t *testing.T) { true, gitScopes, "github-work", - ), 160, 24) + ), 160, 40) model = focusInitProfileV2Field(t, model, initProfileV2FieldGitStorageLabel) model = updateInitProfileV2ReadOnlyModel(t, model, tea.KeyMsg{Type: tea.KeyCtrlU}) model = typeInitProfileV2Text(t, model, "codereview/custom-monit-git") @@ -10479,7 +11668,10 @@ func TestInitProfileV2GitStorageLabelRejectsInvalidCredentialRef(t *testing.T) { model = typeInitProfileV2Text(t, model, "not-a-ref") if !strings.Contains(model.View(), "credential ref") { - t.Fatalf("view missing credential-ref validation error:\n%s", model.View()) + index := model.document.fieldIndexByID(initProfileV2FieldGitStorageLabel) + if index < 0 || !strings.Contains(model.document[index].Error, "credential ref") { + t.Fatalf("git storage label field error = %q, want credential-ref validation", model.document[index].Error) + } } if _, err := model.validatedDraft(); err == nil { t.Fatal("validatedDraft error = nil, want credential-ref validation") @@ -10748,6 +11940,101 @@ func TestBubbleTeaInitProfileV2PrompterBackWithoutStagingReturnsToChooser(t *tes } } +func updateInitLinearEditorModel(t *testing.T, model initLinearEditorModel, msg tea.Msg) initLinearEditorModel { + t.Helper() + updated, _ := model.Update(msg) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next +} + +func typeInitLinearText(t *testing.T, model initLinearEditorModel, text string) initLinearEditorModel { + t.Helper() + for _, r := range text { + model = updateInitLinearEditorModel(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + return model +} + +func focusInitLinearField(t *testing.T, model initLinearEditorModel, id initLinearFieldID) initLinearEditorModel { + t.Helper() + index := model.document.fieldIndexByID(id) + if index < 0 { + t.Fatalf("field %q missing", id) + } + model.focused = index + model.relayout() + model.ensureFocusedVisible() + return model +} + +func selectInitLinearFieldValue(t *testing.T, model initLinearEditorModel, id initLinearFieldID, value string) initLinearEditorModel { + t.Helper() + index := model.document.fieldIndexByID(id) + if index < 0 { + t.Fatalf("field %q missing", id) + } + model.selectFieldValue(id, value) + model.afterFieldChange(index) + model.relayout() + model.ensureFocusedVisible() + if got := model.document.selectedValue(id); got != value { + t.Fatalf("field %q selected value = %q, want %q", id, got, value) + } + return model +} + +func stageReviewerEntityEditorRunner(t *testing.T, edits map[initLinearFieldID]string, action string) initReviewerEntityEditorRunner { + t.Helper() + if action == "" { + action = initDetailActionEdit + } + return func(editor initLinearEditor, _ io.Reader, out io.Writer) (initLinearEditorModel, error) { + model := newInitLinearEditorModel(editor, 160, 60) + for id, value := range edits { + model.setFieldValue(id, value) + index := model.document.fieldIndexByID(id) + if index < 0 { + t.Fatalf("field %q missing", id) + } + model.afterFieldChange(index) + } + model = focusInitLinearField(t, model, initReviewerEntityFieldAction) + model = selectInitLinearFieldValue(t, model, initReviewerEntityFieldAction, action) + _, _ = io.WriteString(out, model.View()) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + } +} + +func stageLLMRuntimeEditorRunner(t *testing.T, selections map[initLinearFieldID]string, action string) initLLMRuntimeEditorRunner { + t.Helper() + if action == "" { + action = initDetailActionEdit + } + return func(editor initLinearEditor, _ io.Reader, out io.Writer) (initLinearEditorModel, error) { + model := newInitLinearEditorModel(editor, 160, 60) + for id, value := range selections { + model = selectInitLinearFieldValue(t, model, id, value) + } + model = focusInitLinearField(t, model, initLLMRuntimeFieldAction) + model = selectInitLinearFieldValue(t, model, initLLMRuntimeFieldAction, action) + _, _ = io.WriteString(out, model.View()) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next, ok := updated.(initLinearEditorModel) + if !ok { + t.Fatalf("Update returned %T, want initLinearEditorModel", updated) + } + return next, nil + } +} + func updateInitProfileV2ReadOnlyModel(t *testing.T, model initProfileV2ReadOnlyModel, msg tea.Msg) initProfileV2ReadOnlyModel { t.Helper() updated, _ := model.Update(msg) @@ -12392,7 +13679,7 @@ func TestInitInteractiveMenuFocusedReviewProfilesDoesNotOpenStoreForPromptContex } } -func TestInitInteractiveMenuFocusedReviewerEntityDeleteUndoStaysInCategoryUntilBack(t *testing.T) { +func TestInitInteractiveMenuFocusedReviewerEntityDeleteUndoReturnsToMenu(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") cfg := config.File{ DefaultProfile: "work", @@ -12458,14 +13745,14 @@ func TestInitInteractiveMenuFocusedReviewerEntityDeleteUndoStaysInCategoryUntilB t.Fatalf("runInitWithDeps: %v", err) } if reviewerCalls != 3 { - t.Fatalf("reviewerCalls = %d, want delete, undo, then Back in-category", reviewerCalls) + t.Fatalf("reviewerCalls = %d, want delete, undo, then back inside reviewer category", reviewerCalls) } if len(menu.prompts) != 2 { - t.Fatalf("menu prompts = %#v, want main menu only before category entry and after explicit Back", menu.prompts) + t.Fatalf("menu prompts = %#v, want main menu before reviewer category and after backing out", menu.prompts) } } -func TestInitInteractiveMenuFocusedReviewerEntityStageStaysInCategoryUntilBack(t *testing.T) { +func TestInitInteractiveMenuFocusedReviewerEntityStageReturnsToMenu(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") saveCredentialTestConfig(t, path, config.File{ DefaultProfile: "work", @@ -12494,11 +13781,6 @@ func TestInitInteractiveMenuFocusedReviewerEntityStageStaysInCategoryUntilBack(t draft.ReviewerEnabled = true draft.ReviewerAuth = string(config.GitAuthModePAT) return draft, nil - case 2: - if got := prompt.Context.ProfileReviewerEntities["work"]; got != "reviewer-pat" { - t.Fatalf("ProfileReviewerEntities[work] = %q, want reviewer-pat after staged reviewer edit", got) - } - return initDraft{}, errInitNavigateBack default: t.Fatalf("unexpected reviewer prompt #%d", reviewerCalls) return initDraft{}, nil @@ -12518,15 +13800,18 @@ func TestInitInteractiveMenuFocusedReviewerEntityStageStaysInCategoryUntilBack(t if err := runInitWithDeps(&cobra.Command{}, opts, initOptions{}, deps); err != nil { t.Fatalf("runInitWithDeps: %v", err) } - if reviewerCalls != 2 { - t.Fatalf("reviewerCalls = %d, want staged reviewer edit then Back in-category", reviewerCalls) + if reviewerCalls != 1 { + t.Fatalf("reviewerCalls = %d, want one staged reviewer edit", reviewerCalls) } if len(menu.prompts) != 2 { - t.Fatalf("menu prompts = %#v, want main menu only before category entry and after explicit Back", menu.prompts) + t.Fatalf("menu prompts = %#v, want main menu before category entry and after stage", menu.prompts) + } + if !menu.prompts[1].CanSave { + t.Fatalf("post-stage menu prompt = %#v, want staged reviewer edit to be saveable", menu.prompts[1]) } } -func TestInitInteractiveMenuFocusedLLMRuntimeStageStaysInCategoryUntilBack(t *testing.T) { +func TestInitInteractiveMenuFocusedLLMRuntimeStageReturnsToMenu(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") saveCredentialTestConfig(t, path, config.File{ DefaultProfile: "work", @@ -12556,11 +13841,6 @@ func TestInitInteractiveMenuFocusedLLMRuntimeStageStaysInCategoryUntilBack(t *te draft.LLMAuth = string(config.LLMAuthSubscription) draft.LLMAdapter = string(config.LLMAdapterCodexCLI) return draft, nil - case 2: - if got := prompt.Context.ProfileLLMRuntimes["work"]; got != "codex-cli" { - t.Fatalf("ProfileLLMRuntimes[work] = %q, want codex-cli after staged runtime edit", got) - } - return initDraft{}, errInitNavigateBack default: t.Fatalf("unexpected LLM prompt #%d", llmCalls) return initDraft{}, nil @@ -12574,15 +13854,18 @@ func TestInitInteractiveMenuFocusedLLMRuntimeStageStaysInCategoryUntilBack(t *te if err := runInitWithDeps(&cobra.Command{}, opts, initOptions{}, deps); err != nil { t.Fatalf("runInitWithDeps: %v", err) } - if llmCalls != 2 { - t.Fatalf("llmCalls = %d, want staged runtime edit then Back in-category", llmCalls) + if llmCalls != 1 { + t.Fatalf("llmCalls = %d, want one staged runtime edit", llmCalls) } if len(menu.prompts) != 2 { - t.Fatalf("menu prompts = %#v, want main menu only before category entry and after explicit Back", menu.prompts) + t.Fatalf("menu prompts = %#v, want main menu before category entry and after stage", menu.prompts) + } + if !menu.prompts[1].CanSave { + t.Fatalf("post-stage menu prompt = %#v, want staged runtime edit to be saveable", menu.prompts[1]) } } -func TestInitInteractiveMenuFocusedLLMRuntimeDeleteUndoStaysInCategoryUntilBack(t *testing.T) { +func TestInitInteractiveMenuFocusedLLMRuntimeDeleteUndoReturnsToMenu(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") saveCredentialTestConfig(t, path, config.File{ DefaultProfile: "work", @@ -12641,10 +13924,10 @@ func TestInitInteractiveMenuFocusedLLMRuntimeDeleteUndoStaysInCategoryUntilBack( t.Fatalf("runInitWithDeps: %v", err) } if llmCalls != 3 { - t.Fatalf("llmCalls = %d, want delete, undo, then Back in-category", llmCalls) + t.Fatalf("llmCalls = %d, want delete, undo, then back inside LLM category", llmCalls) } if len(menu.prompts) != 2 { - t.Fatalf("menu prompts = %#v, want main menu only before category entry and after explicit Back", menu.prompts) + t.Fatalf("menu prompts = %#v, want main menu before LLM category and after backing out", menu.prompts) } } @@ -13100,7 +14383,7 @@ func TestInitInteractiveMenuFinalSaveSummarizesDeferredNonActiveProfile(t *testi t.Fatalf("profile notes = %#v, want deferred git note for %s", profile.Notes, name) } } - if !strings.Contains(stdout.String(), "Initialized 2 profile(s)") || !strings.Contains(stdout.String(), "- home: needs follow-up") || !strings.Contains(stdout.String(), "- work: needs follow-up") { + if !strings.Contains(stdout.String(), "Saved staged init changes") || !strings.Contains(stdout.String(), "- review profiles: 2") || !strings.Contains(stdout.String(), "- home: needs follow-up") || !strings.Contains(stdout.String(), "- work: needs follow-up") { t.Fatalf("stdout = %q, want readiness summary for both profiles", stdout.String()) } if !strings.Contains(stderr.String(), "set-credential --ref codereview/home --key "+credentials.GitTokenKey) || !strings.Contains(stderr.String(), "set-credential --ref codereview/work --key "+credentials.GitTokenKey) { @@ -13186,7 +14469,7 @@ func TestInitInteractiveMenuFinalSaveSetNowWritesCredentialsAndMarksProfileReady if cfg.Profiles["default"].Git.CredentialRef != "codereview/default" { t.Fatalf("default profile git ref = %q, want codereview/default", cfg.Profiles["default"].Git.CredentialRef) } - if !strings.Contains(stdout.String(), "Initialized 1 profile(s)") || !strings.Contains(stdout.String(), "- default: ready") { + if !strings.Contains(stdout.String(), "Saved staged init changes") || !strings.Contains(stdout.String(), "- review profiles: 1") || !strings.Contains(stdout.String(), "- credential secrets: 1 ref") || !strings.Contains(stdout.String(), "- default: ready") { t.Fatalf("stdout = %q, want ready summary for default profile", stdout.String()) } if strings.Contains(stderr.String(), "set-credential --ref") { @@ -13703,6 +14986,52 @@ func TestApplyInteractiveInitSessionPlanConfigSaveFailureAfterKeyringWritesRepor } } +func TestApplyInteractiveInitSessionPlanSummarizesSecretsManagementOnlyChanges(t *testing.T) { + var stdout bytes.Buffer + opts := &root.Options{ + Stdout: &stdout, + Stderr: &bytes.Buffer{}, + } + profile := basicProfile("default") + original := config.File{ + DefaultProfile: "default", + Profiles: map[string]config.Profile{"default": profile}, + } + next := original + next.Secrets = config.SecretsConfig{ + DefaultProfile: "one-password", + Profiles: map[string]config.SecretsProfile{ + "one-password": { + Label: "1Password", + Backend: config.SecretsProfileBackend{ + Kind: config.SecretsBackendKind(credstore.BackendOPDesktop), + OnePassword: &config.SecretsProfileOnePasswordConfig{ + VaultID: "Personal", + }, + }, + }, + }, + } + plan := initSessionPlan{ + path: filepath.Join(t.TempDir(), "config.yml"), + originalCfg: original, + cfg: next, + } + err := applyInteractiveInitSessionPlan(opts, initDeps{ + saveConfig: func(string, config.File) error { return nil }, + }, plan) + if err != nil { + t.Fatalf("applyInteractiveInitSessionPlan: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "Saved staged init changes") || !strings.Contains(out, "- secrets management: 1 profile") { + t.Fatalf("stdout = %q, want secrets-management summary", out) + } + if strings.Contains(out, "Initialized 0 profile(s)") { + t.Fatalf("stdout = %q, want no profile-only initialization summary", out) + } +} + func TestApplyInteractiveInitSessionPlanPartialKeyringWriteFailureReportsCleanup(t *testing.T) { store := newFakeInitStore(nil) store.setBundleFunc = func(profile string, kv map[string]string, _ ...credstore.SetOpt) (credstore.Result, error) { @@ -13867,6 +15196,76 @@ func TestApplyInteractiveInitSessionPlanWritesSeparateSecretsProfilesIndependent } } +func TestApplyInteractiveInitSessionPlanNamedSecretsProfileWriteFailureStopsConfigSave(t *testing.T) { + store := newFakeInitStore(nil) + store.setBundleFunc = func(string, map[string]string, ...credstore.SetOpt) (credstore.Result, error) { + return credstore.Result{}, errors.New("backend vault unreachable") + } + opts := &root.Options{ + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } + cfg := config.File{ + DefaultProfile: "work", + Secrets: config.SecretsConfig{ + DefaultProfile: "work-1password", + Profiles: map[string]config.SecretsProfile{ + "work-1password": { + Label: "Work 1Password", + Backend: config.SecretsProfileBackend{ + Kind: config.SecretsBackendKind(credstore.BackendOPDesktop), + OnePassword: &config.SecretsProfileOnePasswordConfig{ + VaultID: "Not A Real Vault", + }, + }, + }, + }, + }, + Profiles: map[string]config.Profile{ + "work": basicProfile("work"), + }, + } + resolved, err := credentials.ResolveSecretsProfileForProfile(cfg, cfg.Profiles["work"]) + if err != nil { + t.Fatalf("Resolve work secrets profile: %v", err) + } + plan := initSessionPlan{ + path: filepath.Join(t.TempDir(), "config.yml"), + cfg: cfg, + profileNames: []string{"work"}, + writes: map[string]map[string]string{ + "codereview/work": {credentials.GitTokenKey: "work-token"}, + }, + credentialPlan: []initCredentialPlanEntry{{ + Ref: config.CredentialRef{ + Purpose: "git", + Ref: "codereview/work", + Mode: string(config.GitAuthModePAT), + }, + SecretsProfile: resolved, + }}, + } + err = applyInteractiveInitSessionPlan(opts, initDeps{ + openResolvedStore: func(resolved credentials.ResolvedSecretsProfile, _ string, _ bool, _ config.File) (initStore, error) { + if resolved.ID != "work-1password" { + t.Fatalf("opened secrets profile %q, want work-1password", resolved.ID) + } + return store, nil + }, + openStore: func(string, bool, config.File) (initStore, error) { + t.Fatal("legacy openStore called for named secrets-profile write") + return nil, nil + }, + saveConfig: func(string, config.File) error { + t.Fatal("saveConfig called despite backend write failure") + return nil + }, + }, plan) + if err == nil || !strings.Contains(err.Error(), "backend vault unreachable") { + t.Fatalf("applyInteractiveInitSessionPlan error = %v, want backend write failure", err) + } +} + func TestInitNonInteractiveBypassesInteractiveMenuPath(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") opts := &root.Options{ @@ -15373,6 +16772,21 @@ func (f initKeyringBackendPrompterFunc) EditKeyringBackend(prompt initKeyringBac return f(prompt) } +func assertContentOrder(t *testing.T, content string, parts ...string) { + t.Helper() + previous := -1 + for _, part := range parts { + index := strings.Index(content, part) + if index < 0 { + t.Fatalf("content missing %q:\n%s", part, content) + } + if index <= previous { + t.Fatalf("content order wrong for %q:\n%s", part, content) + } + previous = index + } +} + type fakeInitMenuPrompter struct { actions []initMenuAction prompts []initMenuPrompt diff --git a/internal/cmd/credentialcmd/init_global_settings.go b/internal/cmd/credentialcmd/init_global_settings.go new file mode 100644 index 0000000..d4c6a34 --- /dev/null +++ b/internal/cmd/credentialcmd/init_global_settings.go @@ -0,0 +1,131 @@ +package credentialcmd + +import ( + "fmt" + "io" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + + "github.com/open-cli-collective/codereview-cli/internal/cmd/root" + "github.com/open-cli-collective/codereview-cli/internal/config" +) + +type initRetentionEditorRunner func(initLinearEditor, io.Reader, io.Writer) (initLinearEditorModel, error) + +type bubbleTeaInitRetentionPrompter struct { + stdin io.Reader + stderr io.Writer + editorRunner initRetentionEditorRunner +} + +const ( + initRetentionFieldMaxAge initLinearFieldID = "retention_max_age" + initRetentionFieldAction initLinearFieldID = "retention_action" +) + +func newBubbleTeaInitRetentionPrompter(opts *root.Options) initRetentionPrompter { + return bubbleTeaInitRetentionPrompter{stdin: opts.Stdin, stderr: opts.Stderr} +} + +func (p bubbleTeaInitRetentionPrompter) EditRetention(prompt initRetentionPrompt) (initRetentionEdit, error) { + editor := initRetentionEditor(prompt.Retention) + model, err := p.runEditor(editor) + if err != nil { + return initRetentionEdit{}, err + } + switch model.resultAction { + case initDetailActionEdit: + retention, err := initRetentionFromDocument(prompt.Retention, model.document) + if err != nil { + return initRetentionEdit{}, err + } + return initRetentionEdit{Apply: true, Retention: retention}, nil + default: + return initRetentionEdit{}, errInitNavigateBack + } +} + +func (p bubbleTeaInitRetentionPrompter) runEditor(editor initLinearEditor) (initLinearEditorModel, error) { + if p.editorRunner != nil { + return p.editorRunner(editor, p.stdin, p.stderr) + } + program := tea.NewProgram(newInitLinearEditorModel(editor, 100, 28), tea.WithInput(p.stdin), tea.WithOutput(p.stderr)) + finalModel, err := program.Run() + if err != nil { + return initLinearEditorModel{}, err + } + model, ok := finalModel.(initLinearEditorModel) + if !ok { + return initLinearEditorModel{}, fmt.Errorf("global settings editor returned %T", finalModel) + } + return model, nil +} + +func initRetentionEditor(retention config.RetentionConfig) initLinearEditor { + maxAgeDays := fmt.Sprintf("%d", retention.MaxAgeDaysValue()) + if retention.MaxAgeDays == nil { + maxAgeDays = fmt.Sprintf("%d", config.DefaultRetentionConfig().MaxAgeDaysValue()) + } + var document initLinearDocument + document.addSection("Global settings", "Configure behavior that applies across review profiles.") + document.addSection("Run data", "Run data is cr's local record of review runs and related artifacts/logs. Retention controls how long old posted-review run data is kept locally.") + document.addEditableInput( + initRetentionFieldMaxAge, + "Maximum run-data age in days", + "How long cr keeps local run metadata and artifacts from posted reviews. Use 0 to keep posted-review run data indefinitely. Leave blank to reset to 90 days.", + maxAgeDays, + validateInteractiveRetentionMaxAgeDaysField, + ) + document.addEditableSelect(initRetentionFieldAction, "Global settings action", "", []huh.Option[string]{ + huh.NewOption("Stage global settings", initDetailActionEdit), + huh.NewOption("Back without staging", initDetailActionBack), + }, initDetailActionEdit) + return initLinearEditor{ + Document: document, + OnEnter: func(model *initLinearEditorModel) (bool, tea.Cmd) { + if model.focused < 0 || model.focused >= len(model.document) { + return false, nil + } + if model.document[model.focused].ID != initRetentionFieldAction { + return false, nil + } + model.document[model.focused].Error = "" + switch model.document.selectedValue(initRetentionFieldAction) { + case initDetailActionBack: + model.resultAction = initDetailActionBack + return true, tea.Quit + case initDetailActionEdit: + if _, err := initRetentionFromDocument(retention, model.document); err != nil { + model.document[model.focused].Error = err.Error() + model.relayout() + model.ensureFocusedVisible() + return true, nil + } + model.resultAction = initDetailActionEdit + return true, tea.Quit + default: + return true, nil + } + }, + } +} + +func initRetentionFromDocument(retention config.RetentionConfig, document initLinearDocument) (config.RetentionConfig, error) { + next := config.RetentionConfig{ + Enforcement: retention.Enforcement, + } + value := strings.TrimSpace(document.fieldValue(initRetentionFieldMaxAge)) + if value == "" { + defaultDays := config.DefaultRetentionConfig().MaxAgeDaysValue() + next.MaxAgeDays = &defaultDays + return next, nil + } + days, err := parseInteractiveRetentionMaxAgeDays(value) + if err != nil { + return config.RetentionConfig{}, err + } + next.MaxAgeDays = &days + return next, nil +} diff --git a/internal/cmd/credentialcmd/init_inventory.go b/internal/cmd/credentialcmd/init_inventory.go index d7052f4..b308914 100644 --- a/internal/cmd/credentialcmd/init_inventory.go +++ b/internal/cmd/credentialcmd/init_inventory.go @@ -210,8 +210,8 @@ func orderInitInventoryRows(rows []initInventoryRow) []initInventoryRow { } ordered := make([]initInventoryRow, 0, len(rows)) ordered = append(ordered, grouped[initInventoryRowKindActive]...) - ordered = append(ordered, grouped[initInventoryRowKindPending]...) ordered = append(ordered, grouped[initInventoryRowKindCommand]...) + ordered = append(ordered, grouped[initInventoryRowKindPending]...) return ordered } diff --git a/internal/cmd/credentialcmd/init_inventory_test.go b/internal/cmd/credentialcmd/init_inventory_test.go index a3cd644..5fa0642 100644 --- a/internal/cmd/credentialcmd/init_inventory_test.go +++ b/internal/cmd/credentialcmd/init_inventory_test.go @@ -20,7 +20,7 @@ func TestInitInventoryVisibleItemsKeepPendingAndCommandsOrderedDuringFilter(t *t Rows: []initInventoryRow{ {ID: "app", Title: "GitHub App reviewer: org-bot", Kind: initInventoryRowKindActive, Selectable: true, Deletable: true}, {ID: "pat", Title: "PAT reviewer", FilterValue: "default-reviewer", Kind: initInventoryRowKindActive, Selectable: true, Deletable: true}, - {ID: "restore-app", Title: "GitHub App reviewer: old-bot (staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, + {ID: "restore-app", Title: "GitHub App reviewer: old-bot (Staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, {ID: "create-pat", Title: "Configure new personal access token (PAT) reviewer", Kind: initInventoryRowKindCommand, PrimaryAction: initInventoryActionCommand, Selectable: true}, {ID: "back", Title: "Back to main menu", Kind: initInventoryRowKindCommand, PrimaryAction: initInventoryActionBack, Selectable: true}, }, @@ -32,20 +32,20 @@ func TestInitInventoryVisibleItemsKeepPendingAndCommandsOrderedDuringFilter(t *t for _, item := range model.list.VisibleItems() { got = append(got, item.(initInventoryItem).row.ID) } - want := []string{"pat", "restore-app", "create-pat", "back"} + want := []string{"pat", "create-pat", "back", "restore-app"} if !reflect.DeepEqual(got, want) { t.Fatalf("visible item ids = %#v, want %#v", got, want) } } -func TestInitInventoryReordersRowsIntoActivePendingAndCommandGroups(t *testing.T) { +func TestInitInventoryReordersRowsIntoActiveCommandAndPendingGroups(t *testing.T) { model := newInitInventoryModel(initInventoryPrompt{ Title: "Reviewer entity", Width: 80, Height: 20, Rows: []initInventoryRow{ {ID: "back", Title: "Back to main menu", Kind: initInventoryRowKindCommand, PrimaryAction: initInventoryActionBack, Selectable: true}, - {ID: "restore-app", Title: "GitHub App reviewer: old-bot (staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, + {ID: "restore-app", Title: "GitHub App reviewer: old-bot (Staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, {ID: "pat", Title: "PAT reviewer: default-reviewer", Kind: initInventoryRowKindActive, Selectable: true, Deletable: true}, }, }) @@ -54,15 +54,15 @@ func TestInitInventoryReordersRowsIntoActivePendingAndCommandGroups(t *testing.T for _, row := range model.rows { got = append(got, row.ID) } - want := []string{"pat", "restore-app", "back"} + want := []string{"pat", "back", "restore-app"} if !reflect.DeepEqual(got, want) { t.Fatalf("ordered row ids = %#v, want %#v", got, want) } if model.rows[1].Description != "" { - t.Fatalf("pending description = %q, want empty description", model.rows[1].Description) + t.Fatalf("command description = %q, want empty description", model.rows[1].Description) } if model.rows[2].Description != "" { - t.Fatalf("command description = %q, want empty description", model.rows[2].Description) + t.Fatalf("pending description = %q, want empty description", model.rows[2].Description) } } @@ -114,10 +114,11 @@ func TestInitInventoryRestoreKeyRestoresPendingRow(t *testing.T) { Width: 80, Height: 20, Rows: []initInventoryRow{ - {ID: "work", Title: "work (staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, + {ID: "work", Title: "work (Staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, {ID: "back", Title: "Back to main menu", Kind: initInventoryRowKindCommand, PrimaryAction: initInventoryActionBack, Selectable: true}, }, }) + model.list.Select(1) next, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) resultModel := next.(initInventoryModel) @@ -154,7 +155,7 @@ func TestInitInventoryFilterAppliedStillAllowsEnterAndRestore(t *testing.T) { Height: 20, Rows: []initInventoryRow{ {ID: "pat", Title: "PAT reviewer: default-reviewer", Kind: initInventoryRowKindActive, Selectable: true, Deletable: true}, - {ID: "restore-app", Title: "GitHub App reviewer: old-bot (staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, + {ID: "restore-app", Title: "GitHub App reviewer: old-bot (Staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, {ID: "back", Title: "Back to main menu", Kind: initInventoryRowKindCommand, PrimaryAction: initInventoryActionBack, Selectable: true}, }, }) @@ -167,7 +168,7 @@ func TestInitInventoryFilterAppliedStillAllowsEnterAndRestore(t *testing.T) { } model.list.SetFilterText("default") - model.list.Select(1) + model.list.Select(2) restoreNext, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) restoreResult := restoreNext.(initInventoryModel).Result() if restoreResult.Action != initInventoryActionRestore || restoreResult.Row.ID != "restore-app" { @@ -299,7 +300,7 @@ func TestInitInventoryViewClearsAfterQuitActions(t *testing.T) { Title: "Review Profile", Rows: []initInventoryRow{ {ID: "work", Title: "work", Kind: initInventoryRowKindActive, Selectable: true, Deletable: true}, - {ID: "old-work", Title: "Restore work (staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, + {ID: "old-work", Title: "work (Staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, }, }) model.list.Select(tt.selection) @@ -386,7 +387,7 @@ func TestInitLLMRuntimeInventoryRowsSetExpectedCapabilities(t *testing.T) { if got, want := rows[0].Title, "Configured: Claude CLI subscription (claude-cli)"; got != want { t.Fatalf("rows[0].Title = %q, want %q", got, want) } - if got, want := rows[1].Title, "Restore LLM runtime codex-cli (staged for deletion)"; got != want { + if got, want := rows[1].Title, "codex-cli (Staged for deletion)"; got != want { t.Fatalf("rows[1].Title = %q, want %q", got, want) } if !strings.Contains(rows[0].FilterValue, "claude-cli") || !strings.Contains(rows[0].FilterValue, "Claude CLI subscription") { @@ -427,7 +428,7 @@ func TestInitProfileInventoryRowsSetExpectedCapabilities(t *testing.T) { if got, want := rows[0].Title, "home"; got != want { t.Fatalf("rows[0].Title = %q, want %q", got, want) } - if got, want := rows[1].Title, "Restore work (staged for deletion)"; got != want { + if got, want := rows[1].Title, "work (Staged for deletion)"; got != want { t.Fatalf("rows[1].Title = %q, want %q", got, want) } if !strings.Contains(rows[0].FilterValue, "home") || !strings.Contains(rows[0].FilterValue, "github.com") { @@ -544,7 +545,7 @@ func TestInitLLMRuntimeInventoryDeterministicRunnerReturnsRestoreAction(t *testi Height: 20, Mode: initInventoryModeDeterministic, Messages: []tea.Msg{ - tea.KeyMsg{Type: tea.KeyDown}, + tea.KeyMsg{Type: tea.KeyEnd}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}, }, Rows: rows, @@ -574,7 +575,7 @@ func TestInitInventoryViewShowsContextualHelpBindings(t *testing.T) { Height: 20, Rows: []initInventoryRow{ {ID: "pat", Title: "PAT reviewer: default-reviewer", Kind: initInventoryRowKindActive, Selectable: true, Deletable: true}, - {ID: "restore-pat", Title: "PAT reviewer: old-reviewer (staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, + {ID: "restore-pat", Title: "PAT reviewer: old-reviewer (Staged for deletion)", Kind: initInventoryRowKindPending, Restorable: true}, {ID: "back", Title: "Back to main menu", Kind: initInventoryRowKindCommand, PrimaryAction: initInventoryActionBack, Selectable: true}, }, }) @@ -599,7 +600,7 @@ func TestInitInventoryViewShowsContextualHelpBindings(t *testing.T) { } } - model.list.Select(1) + model.list.Select(2) out = model.View() if !strings.Contains(out, "r") || !strings.Contains(out, "restore") { t.Fatalf("view = %q, want restore help for selected restorable row", out) @@ -608,7 +609,7 @@ func TestInitInventoryViewShowsContextualHelpBindings(t *testing.T) { t.Fatalf("view = %q, did not want delete help for selected restorable row", out) } - model.list.Select(2) + model.list.Select(1) out = model.View() for _, unwanted := range []string{"delete", "restore"} { if strings.Contains(out, unwanted) { diff --git a/internal/cmd/credentialcmd/init_linear_editor.go b/internal/cmd/credentialcmd/init_linear_editor.go new file mode 100644 index 0000000..0d95981 --- /dev/null +++ b/internal/cmd/credentialcmd/init_linear_editor.go @@ -0,0 +1,826 @@ +package credentialcmd + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +type initLinearFieldKind string +type initLinearFieldID string + +const ( + initLinearFieldSection initLinearFieldKind = "section" + initLinearFieldInput initLinearFieldKind = "input" + initLinearFieldSelect initLinearFieldKind = "select" + initLinearFieldTextarea initLinearFieldKind = "textarea" +) + +var initLinearTheme = struct { + title lipgloss.Style + selected lipgloss.Style + caret lipgloss.Style + activeTitle lipgloss.Style + error lipgloss.Style + help lipgloss.Style +}{ + title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")), + selected: lipgloss.NewStyle().Foreground(lipgloss.Color("42")), + caret: lipgloss.NewStyle().Foreground(lipgloss.Color("201")), + activeTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")), + error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), + help: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), +} + +type initLinearEnterHandler func(*initLinearEditorModel) (bool, tea.Cmd) +type initLinearChangeHandler func(*initLinearEditorModel, int) + +const ( + initLinearResultActionDelete = "delete" + initLinearResultActionRestore = "restore" +) + +type initLinearEditor struct { + Document initLinearDocument + OnEnter initLinearEnterHandler + OnFieldChange initLinearChangeHandler + Help string + TextareaHelp string +} + +type initLinearEditorModel struct { + viewport viewport.Model + document initLinearDocument + layout initLinearLayout + focused int + quitting bool + resultAction string + onEnter initLinearEnterHandler + onFieldChange initLinearChangeHandler + help string + textareaHelp string +} + +type initLinearDocument []initLinearField + +type initLinearField struct { + ID initLinearFieldID + Kind initLinearFieldKind + Title string + Description string + Value string + Cursor int + Options []initLinearOption + Focusable bool + Editable bool + Hidden bool + Error string + Validate func(string) error +} + +type initLinearFieldOptions struct { + Hidden bool +} + +type initLinearOption struct { + Label string + Value string + Selected bool + Deletable bool + Restorable bool +} + +type initLinearLayout struct { + Content string + Bounds []initLinearFieldBounds + Lines int + SelectedLines map[int]bool +} + +type initLinearFieldBounds struct { + Start int + End int +} + +func newInitLinearEditorModel(editor initLinearEditor, width, height int) initLinearEditorModel { + if width <= 0 { + width = 100 + } + if height <= 0 { + height = 28 + } + help := editor.Help + if help == "" { + help = "tab/enter next - shift+tab previous - up/down change select - esc back" + } + textareaHelp := editor.TextareaHelp + if textareaHelp == "" { + textareaHelp = "tab/enter next - shift+tab previous - ctrl+j newline - esc back" + } + model := initLinearEditorModel{ + viewport: viewport.New(width, max(height-2, 1)), + document: editor.Document, + focused: editor.Document.firstFocusableField(), + onEnter: editor.OnEnter, + onFieldChange: editor.OnFieldChange, + help: help, + textareaHelp: textareaHelp, + } + model.validateAll() + model.relayout() + model.ensureFocusedVisible() + return model +} + +func (m initLinearEditorModel) Init() tea.Cmd { + return nil +} + +func (m initLinearEditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.viewport.Width = max(msg.Width, 1) + m.viewport.Height = max(msg.Height-2, 1) + m.relayout() + m.ensureFocusedVisible() + return m, nil + case tea.KeyMsg: + if m.handleFocusedInputKey(msg) { + m.relayout() + m.ensureFocusedVisible() + return m, nil + } + if handled, cmd := m.handleFocusedSelectKey(msg); handled { + m.relayout() + m.ensureFocusedVisible() + if cmd != nil { + m.quitting = true + } + return m, cmd + } + if m.onEnter != nil && msg.String() == "enter" { + handled, cmd := m.onEnter(&m) + if handled { + if cmd != nil { + m.quitting = true + } + return m, cmd + } + } + switch msg.String() { + case "ctrl+c", "q", "esc": + m.quitting = true + return m, tea.Quit + case "shift+tab": + m.focused = m.document.previousFocusableField(m.focused) + m.relayout() + m.ensureFocusedVisible() + return m, nil + case "tab", "enter": + m.focused = m.document.nextFocusableField(m.focused) + m.relayout() + m.ensureFocusedVisible() + return m, nil + case "pgup", "b": + m.viewport.HalfPageUp() + return m, nil + case "pgdown", "f", " ": + m.viewport.HalfPageDown() + return m, nil + case "up", "down", "j", "k": + // Up/Down only changes the focused select. Inputs should not leak + // these keys to the viewport and scroll the whole form. + return m, nil + case "home", "g": + m.focused = m.document.firstFocusableField() + m.relayout() + m.ensureFocusedVisible() + return m, nil + case "end", "G": + m.focused = m.document.lastFocusableField() + m.relayout() + m.ensureFocusedVisible() + return m, nil + } + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m initLinearEditorModel) View() string { + if m.quitting { + return "" + } + help := m.currentHelp() + return m.styleVisibleViewport() + "\n\n" + initLinearTheme.help.Render(help) +} + +func (m initLinearEditorModel) currentHelp() string { + help := m.help + if m.focused >= 0 && m.focused < len(m.document) && m.document[m.focused].Kind == initLinearFieldTextarea { + help = m.textareaHelp + } + if m.focused >= 0 && m.focused < len(m.document) && m.document[m.focused].Kind == initLinearFieldSelect { + if selected := initLinearSelectedOption(&m.document[m.focused]); selected != nil { + if selected.Deletable && !strings.Contains(help, "d delete") { + help += " - d delete" + } + if selected.Restorable && !strings.Contains(help, "r restore") { + help += " - r restore" + } + } + } + return help +} + +func (d *initLinearDocument) addSection(title, description string) { + d.addSectionField("", title, description) +} + +func (d *initLinearDocument) addSectionField(id initLinearFieldID, title, description string, options ...initLinearFieldOptions) { + *d = append(*d, initLinearField{ + ID: id, + Kind: initLinearFieldSection, + Title: title, + Description: description, + Hidden: mergedInitLinearFieldOptions(options).Hidden, + }) +} + +func (d *initLinearDocument) addInput(title, description, value string) { + d.addInputField(initLinearFieldInput, "", title, description, value, false, nil, initLinearFieldOptions{}) +} + +func (d *initLinearDocument) addEditableInput(id initLinearFieldID, title, description, value string, validate func(string) error, options ...initLinearFieldOptions) { + d.addInputField(initLinearFieldInput, id, title, description, value, true, validate, mergedInitLinearFieldOptions(options)) +} + +func (d *initLinearDocument) addEditableTextarea(id initLinearFieldID, title, description, value string) { + d.addInputField(initLinearFieldTextarea, id, title, description, value, true, nil, initLinearFieldOptions{}) +} + +func (d *initLinearDocument) addInputField(kind initLinearFieldKind, id initLinearFieldID, title, description, value string, editable bool, validate func(string) error, options initLinearFieldOptions) { + *d = append(*d, initLinearField{ + Kind: kind, + ID: id, + Title: title, + Description: description, + Value: value, + Cursor: len([]rune(value)), + Focusable: true, + Editable: editable, + Hidden: options.Hidden, + Validate: validate, + }) +} + +func mergedInitLinearFieldOptions(options []initLinearFieldOptions) initLinearFieldOptions { + var merged initLinearFieldOptions + for _, option := range options { + if option.Hidden { + merged.Hidden = true + } + } + return merged +} + +func initLinearAddSelect[T comparable](document *initLinearDocument, title, description string, options []huh.Option[T], selected T) { + initLinearAddSelectField(document, "", title, description, options, selected, false, initLinearFieldOptions{}) +} + +func (d *initLinearDocument) addEditableSelect(id initLinearFieldID, title, description string, options []huh.Option[string], selected string, fieldOptions ...initLinearFieldOptions) { + initLinearAddSelectField(d, id, title, description, options, selected, true, mergedInitLinearFieldOptions(fieldOptions)) +} + +func initLinearAddSelectField[T comparable](document *initLinearDocument, id initLinearFieldID, title, description string, options []huh.Option[T], selected T, editable bool, fieldOptions initLinearFieldOptions) { + field := initLinearField{ + Kind: initLinearFieldSelect, + ID: id, + Title: title, + Description: description, + Focusable: true, + Editable: editable, + Hidden: fieldOptions.Hidden, + Options: make([]initLinearOption, 0, len(options)), + } + for _, option := range options { + field.Options = append(field.Options, initLinearOption{ + Label: option.Key, + Value: fmt.Sprint(option.Value), + Selected: option.Value == selected, + }) + } + *document = append(*document, field) +} + +func (d initLinearDocument) firstFocusableField() int { + for index, field := range d { + if field.Focusable && !field.Hidden { + return index + } + } + return 0 +} + +func (d initLinearDocument) lastFocusableField() int { + for index := len(d) - 1; index >= 0; index-- { + if d[index].Focusable && !d[index].Hidden { + return index + } + } + return d.firstFocusableField() +} + +func (d initLinearDocument) nextFocusableField(current int) int { + for index := current + 1; index < len(d); index++ { + if d[index].Focusable && !d[index].Hidden { + return index + } + } + return current +} + +func (d initLinearDocument) previousFocusableField(current int) int { + for index := current - 1; index >= 0; index-- { + if d[index].Focusable && !d[index].Hidden { + return index + } + } + return current +} + +func (d initLinearDocument) fieldIndexByTitle(title string) int { + for index, field := range d { + if field.Title == title { + return index + } + } + return -1 +} + +func (d initLinearDocument) fieldIndexByID(id initLinearFieldID) int { + for index, field := range d { + if field.ID == id { + return index + } + } + return -1 +} + +func (d initLinearDocument) fieldValue(id initLinearFieldID) string { + index := d.fieldIndexByID(id) + if index < 0 { + return "" + } + return d[index].Value +} + +func (d initLinearDocument) fieldHidden(id initLinearFieldID) bool { + index := d.fieldIndexByID(id) + if index < 0 { + return true + } + return d[index].Hidden +} + +func (d initLinearDocument) selectedValue(id initLinearFieldID) string { + index := d.fieldIndexByID(id) + if index < 0 { + return "" + } + for _, option := range d[index].Options { + if option.Selected { + return option.Value + } + } + return "" +} + +func (m *initLinearEditorModel) handleFocusedInputKey(msg tea.KeyMsg) bool { + if m.focused < 0 || m.focused >= len(m.document) { + return false + } + field := &m.document[m.focused] + if (field.Kind != initLinearFieldInput && field.Kind != initLinearFieldTextarea) || !field.Editable { + return false + } + if field.Kind == initLinearFieldTextarea && (msg.String() == "ctrl+j" || msg.String() == "alt+enter") { + field.Value = initLinearInsertRunes(field.Value, field.Cursor, []rune{'\n'}) + field.Cursor++ + m.afterFieldChange(m.focused) + return true + } + key := tea.Key(msg) + //nolint:exhaustive // The text input consumes only editing keys; all other keys fall through to form navigation. + switch key.Type { + case tea.KeyRunes: + if msg.Alt { + return false + } + field.Value = initLinearInsertRunes(field.Value, field.Cursor, key.Runes) + field.Cursor += len(key.Runes) + case tea.KeyBackspace, tea.KeyCtrlH: + field.Value, field.Cursor = initLinearDeleteBeforeCursor(field.Value, field.Cursor) + case tea.KeyDelete, tea.KeyCtrlD: + field.Value = initLinearDeleteAtCursor(field.Value, field.Cursor) + case tea.KeyLeft, tea.KeyCtrlB: + field.Cursor = max(field.Cursor-1, 0) + case tea.KeyRight, tea.KeyCtrlF: + field.Cursor = min(field.Cursor+1, len([]rune(field.Value))) + case tea.KeyCtrlA: + field.Cursor = 0 + case tea.KeyCtrlE: + field.Cursor = len([]rune(field.Value)) + case tea.KeyCtrlU: + field.Value = "" + field.Cursor = 0 + case tea.KeyCtrlW: + field.Value, field.Cursor = initLinearDeleteWordBeforeCursor(field.Value, field.Cursor) + case tea.KeyCtrlK: + field.Value = initLinearDeleteAfterCursor(field.Value, field.Cursor) + default: + return false + } + m.afterFieldChange(m.focused) + return true +} + +func (m *initLinearEditorModel) handleFocusedSelectKey(msg tea.KeyMsg) (bool, tea.Cmd) { + if m.focused < 0 || m.focused >= len(m.document) { + return false, nil + } + field := &m.document[m.focused] + if field.Kind != initLinearFieldSelect || !field.Editable || len(field.Options) == 0 { + return false, nil + } + switch msg.String() { + case "up", "k": + initLinearMoveSelection(field, -1) + case "down", "j", " ": + initLinearMoveSelection(field, 1) + case "d": + if selected := initLinearSelectedOption(field); selected != nil && selected.Deletable { + m.resultAction = initLinearResultActionDelete + return true, tea.Quit + } + return false, nil + case "r": + if selected := initLinearSelectedOption(field); selected != nil && selected.Restorable { + m.resultAction = initLinearResultActionRestore + return true, tea.Quit + } + return false, nil + default: + return false, nil + } + m.afterFieldChange(m.focused) + return true, nil +} + +func initLinearSelectedOption(field *initLinearField) *initLinearOption { + if field == nil { + return nil + } + for index := range field.Options { + if field.Options[index].Selected { + return &field.Options[index] + } + } + return nil +} + +func initLinearMoveSelection(field *initLinearField, offset int) { + if len(field.Options) == 0 { + return + } + selectedIndex := 0 + for index, option := range field.Options { + if option.Selected { + selectedIndex = index + break + } + } + next := (selectedIndex + offset) % len(field.Options) + if next < 0 { + next += len(field.Options) + } + for index := range field.Options { + field.Options[index].Selected = index == next + } +} + +func (m *initLinearEditorModel) afterFieldChange(index int) { + m.validateField(index) + if m.onFieldChange != nil { + m.onFieldChange(m, index) + } +} + +func (m *initLinearEditorModel) validateAll() { + for index := range m.document { + m.validateField(index) + } +} + +func (m *initLinearEditorModel) validateField(index int) { + if index < 0 || index >= len(m.document) { + return + } + field := &m.document[index] + field.Error = "" + if field.Validate == nil { + return + } + if err := field.Validate(field.Value); err != nil { + field.Error = err.Error() + } +} + +func (m *initLinearEditorModel) setFieldValue(id initLinearFieldID, value string) { + index := m.document.fieldIndexByID(id) + if index < 0 { + return + } + m.document[index].Value = value + m.document[index].Cursor = len([]rune(value)) +} + +func (m *initLinearEditorModel) setFieldDescription(id initLinearFieldID, description string) { + index := m.document.fieldIndexByID(id) + if index < 0 { + return + } + m.document[index].Description = description +} + +func (m *initLinearEditorModel) selectFieldValue(id initLinearFieldID, value string) { + index := m.document.fieldIndexByID(id) + if index < 0 { + return + } + for optionIndex := range m.document[index].Options { + m.document[index].Options[optionIndex].Selected = m.document[index].Options[optionIndex].Value == value + } +} + +func (m *initLinearEditorModel) setFieldHidden(id initLinearFieldID, hidden bool) { + index := m.document.fieldIndexByID(id) + if index < 0 { + return + } + m.document[index].Hidden = hidden +} + +func (m *initLinearEditorModel) relayout() { + m.layout = initLinearLayoutDocument(m.document, m.viewport.Width, m.focused) + m.viewport.SetContent(m.layout.Content) +} + +func (m *initLinearEditorModel) ensureFocusedVisible() { + if m.focused < 0 || m.focused >= len(m.layout.Bounds) { + return + } + bounds := m.layout.Bounds[m.focused] + height := max(m.viewport.Height, 1) + top := m.viewport.YOffset + bottom := top + height + switch { + case bounds.Start < top: + m.viewport.SetYOffset(bounds.Start) + case bounds.Start >= bottom: + m.viewport.SetYOffset(bounds.Start) + case bounds.End > bottom: + if bounds.End-bounds.Start >= height { + m.viewport.SetYOffset(bounds.Start) + return + } + m.viewport.SetYOffset(max(bounds.End-height, 0)) + } +} + +func initLinearLayoutDocument(document initLinearDocument, width int, focused int) initLinearLayout { + width = max(width, 20) + lines := []string{} + bounds := make([]initLinearFieldBounds, len(document)) + selectedLines := map[int]bool{} + for index, field := range document { + if field.Hidden { + bounds[index] = initLinearFieldBounds{Start: len(lines), End: len(lines)} + continue + } + if len(lines) > 0 { + lines = append(lines, "") + } + start := len(lines) + initLinearAppendFieldLines(&lines, selectedLines, field, index == focused, width) + bounds[index] = initLinearFieldBounds{Start: start, End: len(lines)} + } + return initLinearLayout{ + Content: strings.TrimRight(strings.Join(lines, "\n"), "\n"), + Bounds: bounds, + Lines: len(lines), + SelectedLines: selectedLines, + } +} + +func initLinearAppendFieldLines(lines *[]string, selectedLines map[int]bool, field initLinearField, focused bool, width int) { + titlePrefix := "" + initLinearAppendWrappedWithPrefix(lines, titlePrefix, field.Title, width) + initLinearAppendWrappedWithPrefix(lines, titlePrefix, field.Description, width) + if strings.TrimSpace(field.Error) != "" { + initLinearAppendWrappedWithPrefix(lines, titlePrefix+"! ", field.Error, width) + } + switch field.Kind { + case initLinearFieldSection: + case initLinearFieldInput, initLinearFieldTextarea: + value := field.Value + if focused && field.Editable { + value = initLinearValueWithCursor(value, field.Cursor) + } + valueLines := strings.Split(value, "\n") + if len(valueLines) == 0 { + valueLines = []string{""} + } + for index, line := range valueLines { + prefix := " " + if focused && index == 0 { + prefix = "> " + } + initLinearAppendWrappedWithPrefix(lines, prefix, line, width) + } + case initLinearFieldSelect: + for _, option := range field.Options { + prefix := " " + if focused && option.Selected { + prefix = "> " + } + initLinearAppendWrappedWithPrefixMarked(lines, selectedLines, prefix, option.Label, width, option.Selected) + } + } +} + +func initLinearAppendWrappedWithPrefix(lines *[]string, prefix string, text string, width int) { + initLinearAppendWrappedWithPrefixMarked(lines, nil, prefix, text, width, false) +} + +func initLinearAppendWrappedWithPrefixMarked(lines *[]string, selectedLines map[int]bool, prefix string, text string, width int, selected bool) { + start := len(*lines) + available := max(width-len([]rune(prefix)), 1) + remaining := []rune(strings.TrimSpace(text)) + if len(remaining) == 0 { + *lines = append(*lines, prefix) + markInitLinearSelectedLines(selectedLines, selected, start, len(*lines)) + return + } + for len(remaining) > available { + cut := available + for cut > 0 && remaining[cut] != ' ' { + cut-- + } + if cut <= 0 { + cut = available + } + *lines = append(*lines, prefix+strings.TrimSpace(string(remaining[:cut]))) + remaining = []rune(strings.TrimSpace(string(remaining[cut:]))) + prefix = strings.Repeat(" ", len([]rune(prefix))) + available = max(width-len([]rune(prefix)), 1) + } + *lines = append(*lines, prefix+string(remaining)) + markInitLinearSelectedLines(selectedLines, selected, start, len(*lines)) +} + +func markInitLinearSelectedLines(selectedLines map[int]bool, selected bool, start int, end int) { + if !selected || selectedLines == nil { + return + } + for index := start; index < end; index++ { + selectedLines[index] = true + } +} + +func (m initLinearEditorModel) styleVisibleViewport() string { + lines := strings.Split(m.viewport.View(), "\n") + activeStart := -1 + activeEnd := -1 + if m.focused >= 0 && m.focused < len(m.layout.Bounds) { + bounds := m.layout.Bounds[m.focused] + activeStart = bounds.Start - m.viewport.YOffset + activeEnd = bounds.End - m.viewport.YOffset + } + for index, line := range lines { + selected := m.layout.SelectedLines[m.viewport.YOffset+index] + active := index >= activeStart && index < activeEnd + lines[index] = m.styleViewportLine(line, active, selected) + } + return strings.Join(lines, "\n") +} + +func (m initLinearEditorModel) styleViewportLine(line string, active bool, selected bool) string { + trimmed := strings.TrimSpace(line) + switch { + case trimmed == "": + return line + case strings.HasPrefix(trimmed, "! "): + return initLinearTheme.error.Render(line) + case strings.HasPrefix(trimmed, "> "): + return initLinearStyleSelectedLine(line) + case selected: + return initLinearTheme.selected.Render(line) + case active && m.looksLikeHeading(trimmed): + return initLinearTheme.activeTitle.Render(line) + case m.looksLikeHeading(trimmed): + return initLinearTheme.title.Render(line) + default: + return line + } +} + +func initLinearStyleSelectedLine(line string) string { + caretIndex := strings.Index(line, ">") + if caretIndex < 0 { + return initLinearTheme.selected.Render(line) + } + return line[:caretIndex] + + initLinearTheme.caret.Render(">") + + initLinearTheme.selected.Render(line[caretIndex+1:]) +} + +func (m initLinearEditorModel) looksLikeHeading(line string) bool { + for _, field := range m.document { + if !field.Hidden && field.Title == line { + return true + } + } + return false +} + +func initLinearInsertRunes(value string, cursor int, runes []rune) string { + existing := []rune(value) + cursor = min(max(cursor, 0), len(existing)) + next := make([]rune, 0, len(existing)+len(runes)) + next = append(next, existing[:cursor]...) + next = append(next, runes...) + next = append(next, existing[cursor:]...) + return string(next) +} + +func initLinearDeleteBeforeCursor(value string, cursor int) (string, int) { + existing := []rune(value) + cursor = min(max(cursor, 0), len(existing)) + if cursor == 0 { + return value, cursor + } + next := make([]rune, 0, len(existing)-1) + next = append(next, existing[:cursor-1]...) + next = append(next, existing[cursor:]...) + return string(next), cursor - 1 +} + +func initLinearDeleteWordBeforeCursor(value string, cursor int) (string, int) { + existing := []rune(value) + cursor = min(max(cursor, 0), len(existing)) + index := cursor + for index > 0 && existing[index-1] == ' ' { + index-- + } + for index > 0 && existing[index-1] != ' ' { + index-- + } + next := make([]rune, 0, len(existing)-(cursor-index)) + next = append(next, existing[:index]...) + next = append(next, existing[cursor:]...) + return string(next), index +} + +func initLinearDeleteAtCursor(value string, cursor int) string { + existing := []rune(value) + cursor = min(max(cursor, 0), len(existing)) + if cursor >= len(existing) { + return value + } + next := make([]rune, 0, len(existing)-1) + next = append(next, existing[:cursor]...) + next = append(next, existing[cursor+1:]...) + return string(next) +} + +func initLinearDeleteAfterCursor(value string, cursor int) string { + existing := []rune(value) + cursor = min(max(cursor, 0), len(existing)) + return string(existing[:cursor]) +} + +func initLinearValueWithCursor(value string, cursor int) string { + existing := []rune(value) + cursor = min(max(cursor, 0), len(existing)) + next := make([]rune, 0, len(existing)+1) + next = append(next, existing[:cursor]...) + next = append(next, '|') + next = append(next, existing[cursor:]...) + return string(next) +} diff --git a/internal/cmd/credentialcmd/init_llm_runtime_editor.go b/internal/cmd/credentialcmd/init_llm_runtime_editor.go new file mode 100644 index 0000000..4cd0aac --- /dev/null +++ b/internal/cmd/credentialcmd/init_llm_runtime_editor.go @@ -0,0 +1,408 @@ +package credentialcmd + +import ( + "fmt" + "io" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + + "github.com/open-cli-collective/codereview-cli/internal/config" +) + +type initLLMRuntimeEditorRunner func(initLinearEditor, io.Reader, io.Writer) (initLinearEditorModel, error) + +const ( + initLLMRuntimeFieldSelection initLinearFieldID = "llm_runtime_selection" + initLLMRuntimeFieldProvider initLinearFieldID = "llm_runtime_provider" + initLLMRuntimeFieldAuth initLinearFieldID = "llm_runtime_auth" + initLLMRuntimeFieldAdapter initLinearFieldID = "llm_runtime_adapter" + initLLMRuntimeFieldReplacement initLinearFieldID = "llm_runtime_replacement" + initLLMRuntimeFieldAction initLinearFieldID = "llm_runtime_action" +) + +const ( + initLLMRuntimeActionDelete = "delete" + initLLMRuntimeActionRestore = "restore" +) + +const initLLMRuntimeRestoreSelectionPrefix = "__restore_llm_runtime__:" + +func (p huhInitLLMRuntimePrompter) editLLMRuntimeLinear(prompt initLLMRuntimePrompt) (initDraft, error) { + seed := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) + editor := initLLMRuntimeLinearEditor(prompt.Context, seed, p.runtimeAvailabilityNote) + model, err := p.runLLMRuntimeEditor(editor) + if err != nil { + return initDraft{}, err + } + switch model.resultAction { + case initDetailActionEdit: + return initLLMRuntimeDraftFromLinearDocument(seed, prompt.Context, model.document), nil + case initLLMRuntimeActionDelete: + draft := initLLMRuntimeDraftForDeleteFromLinearDocument(seed, prompt.Context, model.document) + draft.Action = initDraftActionDeleteLLMRuntime + draft.ActionTarget = initLLMRuntimeSelectionValue(model.document) + return draft, nil + case initLLMRuntimeActionRestore: + runtimeName, _ := initLLMRuntimeRestoreSelectionName(initLLMRuntimeSelectionValue(model.document)) + return initDraft{ + Action: initDraftActionUndoDeleteLLMRuntime, + ActionTarget: runtimeName, + }, nil + default: + return initDraft{}, errInitNavigateBack + } +} + +func (p huhInitLLMRuntimePrompter) editLLMRuntimeDetailsLinear(seed initDraft) (initDraft, bool, error) { + editor := initLLMRuntimeDetailsEditor(seed, p.runtimeAvailabilityNote) + model, err := p.runLLMRuntimeEditor(editor) + if err != nil { + return initDraft{}, false, err + } + switch model.resultAction { + case initDetailActionEdit: + return initLLMRuntimeDraftFromDocument(seed, model.document), false, nil + default: + return initDraft{}, true, nil + } +} + +func (p huhInitLLMRuntimePrompter) runLLMRuntimeEditor(editor initLinearEditor) (initLinearEditorModel, error) { + if p.editorRunner != nil { + return p.editorRunner(editor, p.stdin, p.stderr) + } + program := tea.NewProgram(newInitLinearEditorModel(editor, 100, 28), tea.WithInput(p.stdin), tea.WithOutput(p.stderr)) + finalModel, err := program.Run() + if err != nil { + return initLinearEditorModel{}, err + } + model, ok := finalModel.(initLinearEditorModel) + if !ok { + return initLinearEditorModel{}, fmt.Errorf("LLM runtime editor returned %T", finalModel) + } + return model, nil +} + +func initLLMRuntimeDetailsEditor(seed initDraft, availabilityNote func(initLLMRuntimePreset) string) initLinearEditor { + runtime := initLLMRuntimeDraftFromSeedDraft(seed) + description := initLLMRuntimeSelectionDescription(runtime, availabilityNote(runtime.Preset)) + var document initLinearDocument + document.addSection("LLM runtime", description) + document.addEditableSelect(initLLMRuntimeFieldProvider, "LLM provider", "", []huh.Option[string]{ + huh.NewOption("Anthropic", string(config.LLMProviderAnthropic)), + huh.NewOption("OpenAI", string(config.LLMProviderOpenAI)), + huh.NewOption("Pi", string(config.LLMProviderPi)), + }, seed.LLMProvider) + document.addEditableSelect(initLLMRuntimeFieldAuth, "LLM auth mode", "", []huh.Option[string]{ + huh.NewOption("Subscription", string(config.LLMAuthSubscription)), + huh.NewOption("API key", string(config.LLMAuthAPIKey)), + }, seed.LLMAuth) + document.addEditableSelect(initLLMRuntimeFieldAdapter, "LLM adapter", "", []huh.Option[string]{ + huh.NewOption("Claude CLI", string(config.LLMAdapterClaudeCLI)), + huh.NewOption("Anthropic API", string(config.LLMAdapterAnthropicAPI)), + huh.NewOption("Codex CLI", string(config.LLMAdapterCodexCLI)), + huh.NewOption("OpenAI API", string(config.LLMAdapterOpenAIAPI)), + huh.NewOption("Pi RPC", string(config.LLMAdapterPiRPC)), + }, seed.LLMAdapter) + document.addEditableSelect(initLLMRuntimeFieldAction, "Runtime detail action", "", []huh.Option[string]{ + huh.NewOption("Stage these runtime details", initDetailActionEdit), + huh.NewOption("Back without staging", initDetailActionBack), + }, initDetailActionEdit) + return initLinearEditor{ + Document: document, + OnEnter: func(model *initLinearEditorModel) (bool, tea.Cmd) { + if model.focused < 0 || model.focused >= len(model.document) { + return false, nil + } + if model.document[model.focused].ID != initLLMRuntimeFieldAction { + return false, nil + } + switch model.document.selectedValue(initLLMRuntimeFieldAction) { + case initDetailActionBack: + model.resultAction = initDetailActionBack + return true, tea.Quit + case initDetailActionEdit: + model.resultAction = initDetailActionEdit + return true, tea.Quit + default: + return true, nil + } + }, + } +} + +func initLLMRuntimeLinearEditor(ctx initPromptContext, seed initDraft, availabilityNote func(initLLMRuntimePreset) string) initLinearEditor { + selectionOptions := initLLMRuntimeLinearSelectionOptions(ctx) + selection := initLLMRuntimeDefaultSelection(ctx, seed, selectionOptions) + replacementOptions := initLLMRuntimeReplacementOptions(ctx, selection) + + var document initLinearDocument + document.addSection("LLM runtime", "Choose how reviewer agents run for this profile. Configured runtimes can be reused by multiple profiles; templates seed common runtime shapes.") + document.addEditableSelect(initLLMRuntimeFieldSelection, "Runtime", "", selectionOptions, selection) + document.addSection("Runtime details", "") + document.addEditableSelect(initLLMRuntimeFieldProvider, "LLM provider", "", []huh.Option[string]{ + huh.NewOption("Anthropic", string(config.LLMProviderAnthropic)), + huh.NewOption("OpenAI", string(config.LLMProviderOpenAI)), + huh.NewOption("Pi", string(config.LLMProviderPi)), + }, seed.LLMProvider) + document.addEditableSelect(initLLMRuntimeFieldAuth, "LLM auth mode", "", []huh.Option[string]{ + huh.NewOption("Subscription", string(config.LLMAuthSubscription)), + huh.NewOption("API key", string(config.LLMAuthAPIKey)), + }, seed.LLMAuth) + document.addEditableSelect(initLLMRuntimeFieldAdapter, "LLM adapter", "", []huh.Option[string]{ + huh.NewOption("Claude CLI", string(config.LLMAdapterClaudeCLI)), + huh.NewOption("Anthropic API", string(config.LLMAdapterAnthropicAPI)), + huh.NewOption("Codex CLI", string(config.LLMAdapterCodexCLI)), + huh.NewOption("OpenAI API", string(config.LLMAdapterOpenAIAPI)), + huh.NewOption("Pi RPC", string(config.LLMAdapterPiRPC)), + }, seed.LLMAdapter) + document.addEditableSelect(initLLMRuntimeFieldReplacement, "Replacement LLM runtime", "Choose the runtime that should replace this deleted runtime for every affected profile.", replacementOptions, normalizeInitStringSelectionValue("", replacementOptions), initLinearFieldOptions{Hidden: true}) + document.addEditableSelect(initLLMRuntimeFieldAction, "Runtime action", "", initLLMRuntimeActionOptions(ctx, selection), initDetailActionEdit) + editor := initLinearEditor{ + Document: document, + OnFieldChange: func(model *initLinearEditorModel, index int) { + if index < 0 || index >= len(model.document) { + return + } + id := model.document[index].ID + if id == initLLMRuntimeFieldSelection || + id == initLLMRuntimeFieldAction || + id == initLLMRuntimeFieldReplacement { + initLLMRuntimeSyncLinearFields(model, ctx, seed, availabilityNote, true) + } + }, + OnEnter: func(model *initLinearEditorModel) (bool, tea.Cmd) { + if model.focused < 0 || model.focused >= len(model.document) { + return false, nil + } + if model.document[model.focused].ID != initLLMRuntimeFieldAction { + return false, nil + } + action := model.document.selectedValue(initLLMRuntimeFieldAction) + switch action { + case initDetailActionBack: + model.resultAction = initDetailActionBack + return true, tea.Quit + case initDetailActionEdit: + model.resultAction = initDetailActionEdit + return true, tea.Quit + case initLLMRuntimeActionDelete: + model.resultAction = initLLMRuntimeActionDelete + return true, tea.Quit + case initLLMRuntimeActionRestore: + model.resultAction = initLLMRuntimeActionRestore + return true, tea.Quit + default: + return true, nil + } + }, + } + model := newInitLinearEditorModel(editor, 100, 28) + initLLMRuntimeSyncLinearFields(&model, ctx, seed, availabilityNote, true) + editor.Document = model.document + return editor +} + +func initLLMRuntimeDraftFromDocument(seed initDraft, document initLinearDocument) initDraft { + draft := seed + selection := document.selectedValue(initLLMRuntimeFieldSelection) + if selection == "" { + selection = document.selectedValue(initLLMRuntimeFieldReplacement) + } + if document.selectedValue(initLLMRuntimeFieldAction) == initLLMRuntimeActionDelete { + selection = document.selectedValue(initLLMRuntimeFieldReplacement) + } + if selection != "" && selection != initCustomLLMRuntimeSelection { + applyLLMRuntimeSelection(&draft, selection) + } + draft.LLMProvider = document.selectedValue(initLLMRuntimeFieldProvider) + draft.LLMAuth = document.selectedValue(initLLMRuntimeFieldAuth) + draft.LLMAdapter = document.selectedValue(initLLMRuntimeFieldAdapter) + resolvedRuntimePreset := string(initLLMRuntimeDraftFromSeedDraft(draft).Preset) + applyLLMRuntimeSelection(&draft, resolvedRuntimePreset) + return draft +} + +func initLLMRuntimeDraftFromLinearDocument(seed initDraft, ctx initPromptContext, document initLinearDocument) initDraft { + draft := seed + selection := document.selectedValue(initLLMRuntimeFieldSelection) + if document.selectedValue(initLLMRuntimeFieldAction) == initLLMRuntimeActionDelete { + selection = document.selectedValue(initLLMRuntimeFieldReplacement) + } + if selection != "" && selection != initCustomLLMRuntimeSelection { + applyLLMRuntimeInventorySelection(&draft, selection, ctx.LLMRuntimes) + resolvedRuntimePreset := string(initLLMRuntimeDraftFromSeedDraft(draft).Preset) + applyLLMRuntimeSelection(&draft, resolvedRuntimePreset) + } + draft.LLMProvider = document.selectedValue(initLLMRuntimeFieldProvider) + draft.LLMAuth = document.selectedValue(initLLMRuntimeFieldAuth) + draft.LLMAdapter = document.selectedValue(initLLMRuntimeFieldAdapter) + resolvedRuntimePreset := string(initLLMRuntimeDraftFromSeedDraft(draft).Preset) + applyLLMRuntimeSelection(&draft, resolvedRuntimePreset) + return draft +} + +func initLLMRuntimeLinearSelectionOptions(ctx initPromptContext) []huh.Option[string] { + options := initLLMRuntimeOptions(ctx.LLMRuntimes) + pendingNames := make([]string, 0, len(ctx.PendingLLMRuntimeDeletes)) + for name := range ctx.PendingLLMRuntimeDeletes { + pendingNames = append(pendingNames, name) + } + sort.Strings(pendingNames) + for _, name := range pendingNames { + options = append(options, huh.NewOption(llmRuntimeDeletePendingLabel(name), initLLMRuntimeRestoreSelectionPrefix+name)) + } + return dedupeInitStringOptions(options) +} + +func initLLMRuntimeDefaultSelection(ctx initPromptContext, seed initDraft, options []huh.Option[string]) string { + if selected := strings.TrimSpace(ctx.ProfileLLMRuntimes[ctx.ExistingProfileName]); selected != "" { + return normalizeInitStringSelectionValue(selected, options) + } + currentRuntime := initLLMRuntimeDraftFromSeedDraft(seed) + for name, runtime := range ctx.LLMRuntimes { + if runtime.identityKey() == currentRuntime.identityKey() { + return normalizeInitStringSelectionValue(name, options) + } + } + return normalizeInitStringSelectionValue(string(currentRuntime.Preset), options) +} + +func initLLMRuntimeSelectionValue(document initLinearDocument) string { + return document.selectedValue(initLLMRuntimeFieldSelection) +} + +func initLLMRuntimeRestoreSelectionName(selection string) (string, bool) { + if !strings.HasPrefix(selection, initLLMRuntimeRestoreSelectionPrefix) { + return "", false + } + return strings.TrimPrefix(selection, initLLMRuntimeRestoreSelectionPrefix), true +} + +func initLLMRuntimeActionOptions(ctx initPromptContext, selection string) []huh.Option[string] { + if _, ok := initLLMRuntimeRestoreSelectionName(selection); ok { + return []huh.Option[string]{ + huh.NewOption("Back without staging", initDetailActionBack), + } + } + options := []huh.Option[string]{ + huh.NewOption("Stage these runtime details", initDetailActionEdit), + } + options = append(options, huh.NewOption("Back without staging", initDetailActionBack)) + return options +} + +func initLLMRuntimeReplacementOptions(ctx initPromptContext, deletedRuntimeName string) []huh.Option[string] { + options := make([]huh.Option[string], 0, len(ctx.LLMRuntimes)+6) + for _, option := range initLLMRuntimeOptions(ctx.LLMRuntimes) { + if option.Value == deletedRuntimeName { + continue + } + options = append(options, option) + } + return dedupeInitStringOptions(options) +} + +func initLLMRuntimeSyncLinearFields(model *initLinearEditorModel, ctx initPromptContext, seed initDraft, availabilityNote func(initLLMRuntimePreset) string, resetDetails bool) { + selection := model.document.selectedValue(initLLMRuntimeFieldSelection) + initLLMRuntimeSetSelectionOptions(model, ctx, selection) + actionIndex := model.document.fieldIndexByID(initLLMRuntimeFieldAction) + if actionIndex >= 0 { + selectedAction := model.document.selectedValue(initLLMRuntimeFieldAction) + model.document[actionIndex].Options = initLinearOptionsFromHuh(initLLMRuntimeActionOptions(ctx, selection), selectedAction) + if model.document.selectedValue(initLLMRuntimeFieldAction) == "" { + model.document[actionIndex].Options = initLinearOptionsFromHuh(initLLMRuntimeActionOptions(ctx, selection), initDetailActionEdit) + } + } + action := model.document.selectedValue(initLLMRuntimeFieldAction) + replacementVisible := action == initLLMRuntimeActionDelete + model.setFieldHidden(initLLMRuntimeFieldReplacement, !replacementVisible) + replacementOptions := initLLMRuntimeReplacementOptions(ctx, selection) + replacementIndex := model.document.fieldIndexByID(initLLMRuntimeFieldReplacement) + if replacementIndex >= 0 { + selectedReplacement := model.document.selectedValue(initLLMRuntimeFieldReplacement) + model.document[replacementIndex].Options = initLinearOptionsFromHuh(replacementOptions, selectedReplacement) + if model.document.selectedValue(initLLMRuntimeFieldReplacement) == "" { + model.document[replacementIndex].Options = initLinearOptionsFromHuh(replacementOptions, normalizeInitStringSelectionValue("", replacementOptions)) + } + } + + detailSelection := selection + if replacementVisible { + detailSelection = model.document.selectedValue(initLLMRuntimeFieldReplacement) + } + draft := seed + if detailSelection != "" && detailSelection != initCustomLLMRuntimeSelection { + applyLLMRuntimeInventorySelection(&draft, detailSelection, ctx.LLMRuntimes) + resolvedRuntimePreset := string(initLLMRuntimeDraftFromSeedDraft(draft).Preset) + applyLLMRuntimeSelection(&draft, resolvedRuntimePreset) + } + if resetDetails { + model.selectFieldValue(initLLMRuntimeFieldProvider, draft.LLMProvider) + model.selectFieldValue(initLLMRuntimeFieldAuth, draft.LLMAuth) + model.selectFieldValue(initLLMRuntimeFieldAdapter, draft.LLMAdapter) + } + initLLMRuntimeSetDetailsDescription(model, draft, availabilityNote) +} + +func initLLMRuntimeDraftForDeleteFromLinearDocument(seed initDraft, ctx initPromptContext, document initLinearDocument) initDraft { + draft := seed + replacement := document.selectedValue(initLLMRuntimeFieldReplacement) + if replacement != "" && replacement != initCustomLLMRuntimeSelection { + applyLLMRuntimeInventorySelection(&draft, replacement, ctx.LLMRuntimes) + resolvedRuntimePreset := string(initLLMRuntimeDraftFromSeedDraft(draft).Preset) + applyLLMRuntimeSelection(&draft, resolvedRuntimePreset) + return draft + } + draft.LLMProvider = document.selectedValue(initLLMRuntimeFieldProvider) + draft.LLMAuth = document.selectedValue(initLLMRuntimeFieldAuth) + draft.LLMAdapter = document.selectedValue(initLLMRuntimeFieldAdapter) + resolvedRuntimePreset := string(initLLMRuntimeDraftFromSeedDraft(draft).Preset) + applyLLMRuntimeSelection(&draft, resolvedRuntimePreset) + return draft +} + +func initLLMRuntimeSetSelectionOptions(model *initLinearEditorModel, ctx initPromptContext, selected string) { + index := model.document.fieldIndexByID(initLLMRuntimeFieldSelection) + if index < 0 { + return + } + options := initLinearOptionsFromHuh(initLLMRuntimeLinearSelectionOptions(ctx), selected) + for optionIndex := range options { + option := &options[optionIndex] + if _, configured := ctx.LLMRuntimes[option.Value]; configured { + option.Deletable = true + } + if _, restorable := initLLMRuntimeRestoreSelectionName(option.Value); restorable { + option.Restorable = true + } + } + model.document[index].Options = options +} + +func initLLMRuntimeSetDetailsDescription(model *initLinearEditorModel, draft initDraft, availabilityNote func(initLLMRuntimePreset) string) { + runtime := initLLMRuntimeDraftFromSeedDraft(draft) + detailsIndex := model.document.fieldIndexByTitle("Runtime details") + if detailsIndex < 0 { + return + } + model.document[detailsIndex].Description = initLLMRuntimeSelectionDescription(runtime, availabilityNote(runtime.Preset)) +} + +func initLinearOptionsFromHuh(options []huh.Option[string], selected string) []initLinearOption { + if selected == "" { + selected = normalizeInitStringSelectionValue("", options) + } + linearOptions := make([]initLinearOption, 0, len(options)) + for _, option := range options { + linearOptions = append(linearOptions, initLinearOption{ + Label: option.Key, + Value: option.Value, + Selected: option.Value == selected, + }) + } + return linearOptions +} diff --git a/internal/cmd/credentialcmd/init_profile_v2.go b/internal/cmd/credentialcmd/init_profile_v2.go index edf2f10..451956c 100644 --- a/internal/cmd/credentialcmd/init_profile_v2.go +++ b/internal/cmd/credentialcmd/init_profile_v2.go @@ -10,7 +10,6 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" "github.com/open-cli-collective/codereview-cli/internal/cmd/root" "github.com/open-cli-collective/codereview-cli/internal/config" @@ -26,19 +25,7 @@ type bubbleTeaInitProfileV2Prompter struct { type initProfileV2EditorRunner func(initProfileV2Editor) (initProfileV2EditorResult, error) -var initProfileV2Theme = struct { - title lipgloss.Style - selected lipgloss.Style - activeTitle lipgloss.Style - error lipgloss.Style - help lipgloss.Style -}{ - title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")), - selected: lipgloss.NewStyle().Foreground(lipgloss.Color("201")), - activeTitle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("201")), - error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), - help: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), -} +var initProfileV2Theme = initLinearTheme func newBubbleTeaInitProfileV2Prompter(opts *root.Options) initPrompter { return bubbleTeaInitProfileV2Prompter{stdin: opts.Stdin, stderr: opts.Stderr} @@ -241,6 +228,7 @@ func (m initProfileV2ReadOnlyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.handleLLMRuntimeBootstrapKey(msg) { m.requestLLMRuntimeBootstrap = true m.result = initProfileV2EditorResult{BootstrapLLMRuntime: true} + m.quitting = true return m, tea.Quit } if next, handled, cmd := m.handleProfileActionKey(msg); handled { @@ -250,12 +238,12 @@ func (m initProfileV2ReadOnlyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c", "q", "esc": m.quitting = true return m, tea.Quit - case "up", "k", "shift+tab": + case "shift+tab": m.focused = m.document.previousFocusableField(m.focused) m.relayout() m.ensureFocusedVisible() return m, nil - case "down", "j", "tab", "enter": + case "tab", "enter": m.focused = m.document.nextFocusableField(m.focused) m.relayout() m.ensureFocusedVisible() @@ -266,6 +254,10 @@ func (m initProfileV2ReadOnlyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "pgdown", "f", " ": m.viewport.HalfPageDown() return m, nil + case "up", "down", "j", "k": + // Up/Down only changes the focused select. Inputs should not leak + // these keys to the viewport and scroll the whole form. + return m, nil case "home", "g": m.focused = m.document.firstFocusableField() m.relayout() @@ -287,9 +279,9 @@ func (m initProfileV2ReadOnlyModel) View() string { if m.quitting { return "" } - help := "up/down focus - enter next - shift+tab previous - left/right change select - esc back" + help := "tab/enter next - shift+tab previous - up/down change select - esc back" if m.focused >= 0 && m.focused < len(m.document) && m.document[m.focused].Kind == initProfileV2FieldTextarea { - help = "up/down focus - enter next - shift+tab previous - ctrl+j newline - esc back" + help = "tab/enter next - shift+tab previous - ctrl+j newline - esc back" } return m.styleVisibleViewport() + "\n\n" + initProfileV2Theme.help.Render(help) } @@ -387,14 +379,14 @@ func initProfileV2Selection(ctx initPromptContext, selection string) (string, *c return selection, &profileCopy, ctx.RequestedProfileName } -type initProfileV2FieldKind string -type initProfileV2FieldID string +type initProfileV2FieldKind = initLinearFieldKind +type initProfileV2FieldID = initLinearFieldID const ( - initProfileV2FieldSection initProfileV2FieldKind = "section" - initProfileV2FieldInput initProfileV2FieldKind = "input" - initProfileV2FieldSelect initProfileV2FieldKind = "select" - initProfileV2FieldTextarea initProfileV2FieldKind = "textarea" + initProfileV2FieldSection initProfileV2FieldKind = initLinearFieldSection + initProfileV2FieldInput initProfileV2FieldKind = initLinearFieldInput + initProfileV2FieldSelect initProfileV2FieldKind = initLinearFieldSelect + initProfileV2FieldTextarea initProfileV2FieldKind = initLinearFieldTextarea ) const ( @@ -430,43 +422,11 @@ type initProfileV2Editor struct { Document initProfileV2Document } -type initProfileV2Document []initProfileV2Field - -type initProfileV2Field struct { - ID initProfileV2FieldID - Kind initProfileV2FieldKind - Title string - Description string - Value string - Cursor int - Options []initProfileV2Option - Focusable bool - Editable bool - Hidden bool - Error string - Validate func(string) error -} - -type initProfileV2FieldOptions struct { - Hidden bool -} - -type initProfileV2Option struct { - Label string - Value string - Selected bool -} - -type initProfileV2Layout struct { - Content string - Bounds []initProfileV2FieldBounds - Lines int -} - -type initProfileV2FieldBounds struct { - Start int - End int -} +type initProfileV2Document = initLinearDocument +type initProfileV2Field = initLinearField +type initProfileV2FieldOptions = initLinearFieldOptions +type initProfileV2Layout = initLinearLayout +type initProfileV2FieldBounds = initLinearFieldBounds func initProfileV2AppendRouteSection(document *initProfileV2Document, routeText string) { document.addSection("Automatic profile selection", "Routes tell cr when to use this profile automatically. Explicit --profile still wins; otherwise matching routes beat the default profile.") @@ -540,153 +500,8 @@ func initProfileV2AppendProfileActionSection(document *initProfileV2Document) { }, initDetailActionEdit) } -func (d *initProfileV2Document) addSection(title, description string) { - *d = append(*d, initProfileV2Field{ - Kind: initProfileV2FieldSection, - Title: title, - Description: description, - }) -} - -func (d *initProfileV2Document) addInput(title, description, value string) { - d.addInputField(initProfileV2FieldInput, "", title, description, value, false, nil, initProfileV2FieldOptions{}) -} - -func (d *initProfileV2Document) addEditableInput(id initProfileV2FieldID, title, description, value string, validate func(string) error, options ...initProfileV2FieldOptions) { - d.addInputField(initProfileV2FieldInput, id, title, description, value, true, validate, mergedInitProfileV2FieldOptions(options)) -} - -func (d *initProfileV2Document) addEditableTextarea(id initProfileV2FieldID, title, description, value string) { - d.addInputField(initProfileV2FieldTextarea, id, title, description, value, true, nil, initProfileV2FieldOptions{}) -} - -func (d *initProfileV2Document) addInputField(kind initProfileV2FieldKind, id initProfileV2FieldID, title, description, value string, editable bool, validate func(string) error, options initProfileV2FieldOptions) { - *d = append(*d, initProfileV2Field{ - Kind: kind, - ID: id, - Title: title, - Description: description, - Value: value, - Cursor: len([]rune(value)), - Focusable: true, - Editable: editable, - Hidden: options.Hidden, - Validate: validate, - }) -} - -func mergedInitProfileV2FieldOptions(options []initProfileV2FieldOptions) initProfileV2FieldOptions { - var merged initProfileV2FieldOptions - for _, option := range options { - if option.Hidden { - merged.Hidden = true - } - } - return merged -} - func initProfileV2AddSelect[T comparable](document *initProfileV2Document, title, description string, options []huh.Option[T], selected T) { - initProfileV2AddSelectField(document, "", title, description, options, selected, false, initProfileV2FieldOptions{}) -} - -func (d *initProfileV2Document) addEditableSelect(id initProfileV2FieldID, title, description string, options []huh.Option[string], selected string, fieldOptions ...initProfileV2FieldOptions) { - initProfileV2AddSelectField(d, id, title, description, options, selected, true, mergedInitProfileV2FieldOptions(fieldOptions)) -} - -func initProfileV2AddSelectField[T comparable](document *initProfileV2Document, id initProfileV2FieldID, title, description string, options []huh.Option[T], selected T, editable bool, fieldOptions initProfileV2FieldOptions) { - field := initProfileV2Field{ - Kind: initProfileV2FieldSelect, - ID: id, - Title: title, - Description: description, - Focusable: true, - Editable: editable, - Hidden: fieldOptions.Hidden, - Options: make([]initProfileV2Option, 0, len(options)), - } - for _, option := range options { - field.Options = append(field.Options, initProfileV2Option{ - Label: option.Key, - Value: fmt.Sprint(option.Value), - Selected: option.Value == selected, - }) - } - *document = append(*document, field) -} - -func (d initProfileV2Document) firstFocusableField() int { - for index, field := range d { - if field.Focusable && !field.Hidden { - return index - } - } - return 0 -} - -func (d initProfileV2Document) lastFocusableField() int { - for index := len(d) - 1; index >= 0; index-- { - if d[index].Focusable && !d[index].Hidden { - return index - } - } - return d.firstFocusableField() -} - -func (d initProfileV2Document) nextFocusableField(current int) int { - for index := current + 1; index < len(d); index++ { - if d[index].Focusable && !d[index].Hidden { - return index - } - } - return current -} - -func (d initProfileV2Document) previousFocusableField(current int) int { - for index := current - 1; index >= 0; index-- { - if d[index].Focusable && !d[index].Hidden { - return index - } - } - return current -} - -func (d initProfileV2Document) fieldIndexByTitle(title string) int { - for index, field := range d { - if field.Title == title { - return index - } - } - return -1 -} - -func (d initProfileV2Document) fieldIndexByID(id initProfileV2FieldID) int { - for index, field := range d { - if field.ID == id { - return index - } - } - return -1 -} - -func (d initProfileV2Document) fieldValue(id initProfileV2FieldID) string { - index := d.fieldIndexByID(id) - if index < 0 { - return "" - } - return d[index].Value -} - -func (d initProfileV2Document) selectedValue(id initProfileV2FieldID) string { - index := d.fieldIndexByID(id) - if index < 0 { - return "" - } - for _, option := range d[index].Options { - if option.Selected { - return option.Value - } - } - return "" + initLinearAddSelect(document, title, description, options, selected) } func (m *initProfileV2ReadOnlyModel) handleFocusedInputKey(msg tea.KeyMsg) bool { @@ -727,6 +542,8 @@ func (m *initProfileV2ReadOnlyModel) handleFocusedInputKey(msg tea.KeyMsg) bool case tea.KeyCtrlU: field.Value = "" field.Cursor = 0 + case tea.KeyCtrlW: + field.Value, field.Cursor = initLinearDeleteWordBeforeCursor(field.Value, field.Cursor) case tea.KeyCtrlK: field.Value = initProfileV2DeleteAfterCursor(field.Value, field.Cursor) default: @@ -745,9 +562,9 @@ func (m *initProfileV2ReadOnlyModel) handleFocusedSelectKey(msg tea.KeyMsg) bool return false } switch msg.String() { - case "left", "h": + case "up", "k": initProfileV2MoveSelection(field, -1) - case "right", "l", " ": + case "down", "j", " ": initProfileV2MoveSelection(field, 1) default: return false @@ -776,6 +593,7 @@ func (m initProfileV2ReadOnlyModel) handleProfileActionKey(msg tea.KeyMsg) (init switch m.document.selectedValue(initProfileV2FieldProfileAction) { case initDetailActionBack: m.result = initProfileV2EditorResult{} + m.quitting = true return m, true, tea.Quit case initDetailActionEdit: draft, err := m.validatedDraft() @@ -786,6 +604,7 @@ func (m initProfileV2ReadOnlyModel) handleProfileActionKey(msg tea.KeyMsg) (init return m, true, nil } m.result = initProfileV2EditorResult{StageProfile: true, Draft: draft} + m.quitting = true return m, true, tea.Quit default: return m, true, nil @@ -817,24 +636,13 @@ func (m *initProfileV2ReadOnlyModel) afterFieldChange(index int) { if index < 0 || index >= len(m.document) { return } - switch m.document[index].ID { - case initProfileV2FieldGitScope: + id := m.document[index].ID + if id == initProfileV2FieldGitScope { m.syncGitScopeFields() - case initProfileV2FieldLLMRuntime: + return + } + if id == initProfileV2FieldLLMRuntime { m.syncModelMapFields() - case initProfileV2FieldProfileName, - initProfileV2FieldRoutes, - initProfileV2FieldGitHost, - initProfileV2FieldGitAuth, - initProfileV2FieldReviewerEntity, - initProfileV2FieldReviewerModelTier, - initProfileV2FieldAgentSources, - initProfileV2FieldReviewMajorEvent, - initProfileV2FieldSelfApprove, - initProfileV2FieldResolveThreads, - initProfileV2FieldResolveAfter, - initProfileV2FieldGitStorageLabel, - initProfileV2FieldProfileAction: } } @@ -1121,6 +929,8 @@ func (m *initProfileV2ReadOnlyModel) ensureFocusedVisible() { switch { case bounds.Start < top: m.viewport.SetYOffset(bounds.Start) + case bounds.Start >= bottom: + m.viewport.SetYOffset(bounds.Start) case bounds.End > bottom: if bounds.End-bounds.Start >= height { m.viewport.SetYOffset(bounds.Start) @@ -1159,9 +969,9 @@ func initProfileV2AppendFieldLines(lines *[]string, field initProfileV2Field, fo titlePrefix = " " } initProfileV2AppendWrappedWithPrefix(lines, titlePrefix, field.Title, width) - initProfileV2AppendWrapped(lines, field.Description, width) + initProfileV2AppendWrappedWithPrefix(lines, titlePrefix, field.Description, width) if strings.TrimSpace(field.Error) != "" { - initProfileV2AppendWrappedWithPrefix(lines, "! ", field.Error, width) + initProfileV2AppendWrappedWithPrefix(lines, titlePrefix+"! ", field.Error, width) } switch field.Kind { case initProfileV2FieldSection: @@ -1176,7 +986,7 @@ func initProfileV2AppendFieldLines(lines *[]string, field initProfileV2Field, fo } for index, line := range valueLines { prefix := " " - if index == 0 { + if focused && index == 0 { prefix = "> " } initProfileV2AppendWrappedWithPrefix(lines, prefix, line, width) @@ -1184,7 +994,7 @@ func initProfileV2AppendFieldLines(lines *[]string, field initProfileV2Field, fo case initProfileV2FieldSelect: for _, option := range field.Options { prefix := " " - if option.Selected { + if focused && option.Selected { prefix = "> " } initProfileV2AppendWrappedWithPrefix(lines, prefix, option.Label, width) @@ -1192,15 +1002,6 @@ func initProfileV2AppendFieldLines(lines *[]string, field initProfileV2Field, fo } } -func initProfileV2AppendWrapped(lines *[]string, text string, width int) { - for _, line := range strings.Split(strings.TrimSpace(text), "\n") { - if strings.TrimSpace(line) == "" { - continue - } - initProfileV2AppendWrappedWithPrefix(lines, "", strings.TrimSpace(line), width) - } -} - func initProfileV2AppendWrappedWithPrefix(lines *[]string, prefix string, text string, width int) { available := max(width-len([]rune(prefix)), 1) remaining := []rune(strings.TrimSpace(text)) @@ -1248,7 +1049,7 @@ func initProfileV2StyleViewportLine(line string, active bool) string { case strings.HasPrefix(trimmed, "! "): return initProfileV2Theme.error.Render(line) case strings.HasPrefix(trimmed, "> "): - return initProfileV2Theme.selected.Render(line) + return initLinearStyleSelectedLine(line) case active && initProfileV2LooksLikeHeading(trimmed): return initProfileV2Theme.activeTitle.Render(line) case initProfileV2LooksLikeHeading(trimmed): diff --git a/internal/cmd/credentialcmd/init_reviewer_entity_editor.go b/internal/cmd/credentialcmd/init_reviewer_entity_editor.go new file mode 100644 index 0000000..06e0eca --- /dev/null +++ b/internal/cmd/credentialcmd/init_reviewer_entity_editor.go @@ -0,0 +1,451 @@ +package credentialcmd + +import ( + "fmt" + "io" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +type initReviewerEntityEditorRunner func(initLinearEditor, io.Reader, io.Writer) (initLinearEditorModel, error) + +const ( + initReviewerEntityFieldSelection initLinearFieldID = "reviewer_entity_selection" + initReviewerEntityFieldLabel initLinearFieldID = "reviewer_entity_label" + initReviewerEntityFieldSecretLocation initLinearFieldID = "reviewer_entity_secret_location" + initReviewerEntityFieldAction initLinearFieldID = "reviewer_entity_action" +) + +const ( + initReviewerEntityActionDelete = "delete" + initReviewerEntityActionRestore = "restore" +) + +const initReviewerEntityRestoreSelectionPrefix = "__restore_reviewer_entity__:" + +func (p huhInitReviewerEntityPrompter) editReviewerEntityLinear(prompt initReviewerEntityPrompt) (initDraft, error) { + seed := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) + editor := initReviewerEntityLinearEditor(prompt.Context, seed) + model, err := p.runReviewerEntityEditor(editor) + if err != nil { + return initDraft{}, err + } + selection := model.document.selectedValue(initReviewerEntityFieldSelection) + switch model.resultAction { + case initDetailActionEdit: + draft, err := initReviewerEntityDraftFromDocument(prompt.Context, seed, model.document) + if err != nil { + return initDraft{}, err + } + if _, ok := prompt.Context.ReviewerEntities[selection]; ok { + draft.ActionTarget = selection + } + return draft, nil + case initReviewerEntityActionDelete: + return initDraft{ + Action: initDraftActionDeleteReviewerEntity, + ActionTarget: selection, + }, nil + case initReviewerEntityActionRestore: + entityName, _ := initReviewerEntityRestoreSelectionName(selection) + return initDraft{ + Action: initDraftActionUndoDeleteReviewerEntity, + ActionTarget: entityName, + }, nil + default: + return initDraft{}, errInitNavigateBack + } +} + +func (p huhInitReviewerEntityPrompter) editReviewerEntityFieldsLinear(entity initReviewerEntityDraft, seed initDraft, preserveCurrentLocation bool) (initDraft, bool, error) { + editorState, err := newReviewerEntityEditorState(entity, seed, preserveCurrentLocation) + if err != nil { + return initDraft{}, false, err + } + model, err := p.runReviewerEntityEditor(editorState.editor()) + if err != nil { + return initDraft{}, false, err + } + switch model.resultAction { + case initDetailActionEdit: + draft, err := editorState.draftFromDocument(model.document) + if err != nil { + return initDraft{}, false, err + } + return draft, false, nil + default: + return initDraft{}, true, nil + } +} + +func (p huhInitReviewerEntityPrompter) runReviewerEntityEditor(editor initLinearEditor) (initLinearEditorModel, error) { + if p.editorRunner != nil { + return p.editorRunner(editor, p.stdin, p.stderr) + } + program := tea.NewProgram(newInitLinearEditorModel(editor, 100, 28), tea.WithInput(p.stdin), tea.WithOutput(p.stderr)) + finalModel, err := program.Run() + if err != nil { + return initLinearEditorModel{}, err + } + model, ok := finalModel.(initLinearEditorModel) + if !ok { + return initLinearEditorModel{}, fmt.Errorf("reviewer entity editor returned %T", finalModel) + } + return model, nil +} + +type reviewerEntityEditorState struct { + seed initDraft + kind initReviewerEntityKind + explicitDisplayName string + fallbackLabelSeed string + standardReviewerRef string + preserveCurrentLocation bool +} + +func newReviewerEntityEditorState(entity initReviewerEntityDraft, seed initDraft, preserveCurrentLocation bool) (reviewerEntityEditorState, error) { + editDraft := seed + kind := entity.Kind + applyReviewerEntitySelection(&editDraft, string(kind)) + standardReviewerRef := "" + if kind != initReviewerEntityKindUseGitIdentity { + ref, err := standardReviewerCredentialRef(editDraft.ProfileName) + if err != nil { + return reviewerEntityEditorState{}, err + } + standardReviewerRef = ref + } + _, explicitDisplayName, fallbackLabelSeed := reviewerEntityEditorLabelSeed(entity) + return reviewerEntityEditorState{ + seed: editDraft, + kind: kind, + explicitDisplayName: explicitDisplayName, + fallbackLabelSeed: fallbackLabelSeed, + standardReviewerRef: standardReviewerRef, + preserveCurrentLocation: preserveCurrentLocation, + }, nil +} + +func (s reviewerEntityEditorState) editor() initLinearEditor { + var document initLinearDocument + document.addSection("Reviewer entity", reviewerEntitySelectionDescription()) + document.addSection("Reviewer entity type", reviewerEntityKindDetailLabel(s.kind)) + if s.kind != initReviewerEntityKindUseGitIdentity { + labelInput, _, _ := reviewerEntityEditorLabelSeed(initReviewerEntityDraft{ + Kind: s.kind, + CredentialRef: s.seed.ReviewerCredentialRef, + DisplayName: s.seed.ReviewerDisplayName, + }) + if s.explicitDisplayName != "" { + labelInput = s.explicitDisplayName + } + if !s.preserveCurrentLocation { + labelInput = "" + } + reviewerSecretLocation := "" + if s.preserveCurrentLocation { + if currentRef := strings.TrimSpace(s.seed.ReviewerCredentialRef); currentRef != "" { + reviewerSecretLocation = currentRef + } else { + reviewerSecretLocation = s.standardReviewerRef + } + } + document.addEditableInput( + initReviewerEntityFieldLabel, + "Entity label", + "Choose a human-friendly name for this reviewer entity. Leave blank to clear any existing custom label.", + labelInput, + validateOptionalDisplayName, + ) + document.addEditableInput( + initReviewerEntityFieldSecretLocation, + "Reviewer secret location", + "Leave blank to use the standard reviewer secret location for this profile. Replace the value only if you need a custom location.", + reviewerSecretLocation, + validateOptionalCredentialRef, + ) + } + document.addEditableSelect(initReviewerEntityFieldAction, "Reviewer detail action", "", []huh.Option[string]{ + huh.NewOption("Stage reviewer settings", initDetailActionEdit), + huh.NewOption("Back without staging", initDetailActionBack), + }, initDetailActionEdit) + return initLinearEditor{ + Document: document, + OnEnter: func(model *initLinearEditorModel) (bool, tea.Cmd) { + if model.focused < 0 || model.focused >= len(model.document) { + return false, nil + } + if model.document[model.focused].ID != initReviewerEntityFieldAction { + return false, nil + } + model.document[model.focused].Error = "" + switch model.document.selectedValue(initReviewerEntityFieldAction) { + case initDetailActionBack: + model.resultAction = initDetailActionBack + return true, tea.Quit + case initDetailActionEdit: + if _, err := s.draftFromDocument(model.document); err != nil { + model.document[model.focused].Error = err.Error() + model.relayout() + model.ensureFocusedVisible() + return true, nil + } + model.resultAction = initDetailActionEdit + return true, tea.Quit + default: + return true, nil + } + }, + } +} + +func (s reviewerEntityEditorState) draftFromDocument(document initLinearDocument) (initDraft, error) { + editDraft := s.seed + applyReviewerEntitySelection(&editDraft, string(s.kind)) + if s.kind == initReviewerEntityKindUseGitIdentity { + return editDraft, nil + } + labelInput := document.fieldValue(initReviewerEntityFieldLabel) + if err := validateOptionalDisplayName(labelInput); err != nil { + return initDraft{}, err + } + reviewerSecretLocation := document.fieldValue(initReviewerEntityFieldSecretLocation) + if err := validateOptionalCredentialRef(reviewerSecretLocation); err != nil { + return initDraft{}, err + } + finalizeReviewerEntityEditorDraft(&editDraft, s.explicitDisplayName, s.fallbackLabelSeed, labelInput, reviewerSecretLocation, s.standardReviewerRef, s.preserveCurrentLocation) + return editDraft, nil +} + +func reviewerEntityKindDetailLabel(kind initReviewerEntityKind) string { + switch kind { + case initReviewerEntityKindUseGitIdentity: + return "Post using this profile's Git account" + case initReviewerEntityKindGitHubApp: + return "GitHub App reviewer" + case initReviewerEntityKindPAT: + return "Personal access token (PAT) reviewer" + default: + return strings.TrimSpace(string(kind)) + } +} + +func initReviewerEntityLinearEditor(ctx initPromptContext, seed initDraft) initLinearEditor { + options := initReviewerEntityLinearSelectionOptions(ctx) + selection := initReviewerEntityDefaultSelection(ctx, seed, options) + state, _ := reviewerEntityEditorStateForSelection(ctx, seed, selection) + var document initLinearDocument + document.addSection("Reviewer entity", reviewerEntitySelectionDescription()) + document.addEditableSelect(initReviewerEntityFieldSelection, "Reviewer entity", "", options, selection) + document.addSection("Reviewer details", "") + document.addEditableInput( + initReviewerEntityFieldLabel, + "Entity label", + "Choose a human-friendly name for this reviewer entity. Leave blank to clear any existing custom label.", + "", + validateOptionalDisplayName, + ) + document.addEditableInput( + initReviewerEntityFieldSecretLocation, + "Reviewer secret location", + "Leave blank to use the standard reviewer secret location for this profile. Replace the value only if you need a custom location.", + "", + validateOptionalCredentialRef, + ) + document.addEditableSelect(initReviewerEntityFieldAction, "Reviewer action", "", initReviewerEntityActionOptions(ctx, selection), initDetailActionEdit) + editor := initLinearEditor{ + Document: document, + OnFieldChange: func(model *initLinearEditorModel, index int) { + if index < 0 || index >= len(model.document) { + return + } + id := model.document[index].ID + if id == initReviewerEntityFieldSelection { + initReviewerEntitySyncLinearFields(model, ctx, seed, true) + return + } + if id == initReviewerEntityFieldAction { + initReviewerEntitySyncLinearFields(model, ctx, seed, false) + } + }, + OnEnter: func(model *initLinearEditorModel) (bool, tea.Cmd) { + if model.focused < 0 || model.focused >= len(model.document) { + return false, nil + } + if model.document[model.focused].ID != initReviewerEntityFieldAction { + return false, nil + } + action := model.document.selectedValue(initReviewerEntityFieldAction) + switch action { + case initDetailActionBack: + model.resultAction = initDetailActionBack + return true, tea.Quit + case initReviewerEntityActionDelete: + model.resultAction = initReviewerEntityActionDelete + return true, tea.Quit + case initReviewerEntityActionRestore: + model.resultAction = initReviewerEntityActionRestore + return true, tea.Quit + case initDetailActionEdit: + if _, err := initReviewerEntityDraftFromDocument(ctx, seed, model.document); err != nil { + model.document[model.focused].Error = err.Error() + model.relayout() + model.ensureFocusedVisible() + return true, nil + } + model.resultAction = initDetailActionEdit + return true, tea.Quit + default: + return true, nil + } + }, + } + model := newInitLinearEditorModel(editor, 100, 28) + _ = state + initReviewerEntitySyncLinearFields(&model, ctx, seed, true) + editor.Document = model.document + return editor +} + +func initReviewerEntityLinearSelectionOptions(ctx initPromptContext) []huh.Option[string] { + options := initReviewerEntityOptions(ctx.ReviewerEntities, focusedReviewerEntityFallbackLabel(ctx.ExistingProfile)) + pendingNames := make([]string, 0, len(ctx.PendingReviewerEntityDeletes)) + for name := range ctx.PendingReviewerEntityDeletes { + pendingNames = append(pendingNames, name) + } + sort.Strings(pendingNames) + for _, name := range pendingNames { + options = append(options, huh.NewOption(reviewerEntityDeletePendingLabel(name), initReviewerEntityRestoreSelectionPrefix+name)) + } + return dedupeInitStringOptions(options) +} + +func initReviewerEntityDefaultSelection(ctx initPromptContext, seed initDraft, options []huh.Option[string]) string { + if selected := strings.TrimSpace(ctx.ProfileReviewerEntities[ctx.ExistingProfileName]); selected != "" { + return normalizeInitStringSelectionValue(normalizeReviewerEntitySelectionValue(selected, ctx.ReviewerEntities), options) + } + current := initReviewerEntityDraftFromSeedDraft(seed) + for name, entity := range ctx.ReviewerEntities { + if entity.identityKey() != "" && entity.identityKey() == current.identityKey() { + return normalizeInitStringSelectionValue(name, options) + } + } + return normalizeInitStringSelectionValue(string(current.Kind), options) +} + +func initReviewerEntityRestoreSelectionName(selection string) (string, bool) { + if !strings.HasPrefix(selection, initReviewerEntityRestoreSelectionPrefix) { + return "", false + } + return strings.TrimPrefix(selection, initReviewerEntityRestoreSelectionPrefix), true +} + +func initReviewerEntityActionOptions(ctx initPromptContext, selection string) []huh.Option[string] { + if _, ok := initReviewerEntityRestoreSelectionName(selection); ok { + return []huh.Option[string]{ + huh.NewOption("Back without staging", initDetailActionBack), + } + } + options := []huh.Option[string]{ + huh.NewOption("Stage reviewer settings", initDetailActionEdit), + } + options = append(options, huh.NewOption("Back without staging", initDetailActionBack)) + return options +} + +func initReviewerEntitySyncLinearFields(model *initLinearEditorModel, ctx initPromptContext, seed initDraft, resetDetails bool) { + selection := model.document.selectedValue(initReviewerEntityFieldSelection) + initReviewerEntitySetSelectionOptions(model, ctx, selection) + actionIndex := model.document.fieldIndexByID(initReviewerEntityFieldAction) + if actionIndex >= 0 { + selectedAction := model.document.selectedValue(initReviewerEntityFieldAction) + model.document[actionIndex].Options = initLinearOptionsFromHuh(initReviewerEntityActionOptions(ctx, selection), selectedAction) + if model.document.selectedValue(initReviewerEntityFieldAction) == "" { + model.document[actionIndex].Options = initLinearOptionsFromHuh(initReviewerEntityActionOptions(ctx, selection), initDetailActionEdit) + } + } + state, err := reviewerEntityEditorStateForSelection(ctx, seed, selection) + if err != nil { + return + } + detailsIndex := model.document.fieldIndexByTitle("Reviewer details") + if detailsIndex >= 0 { + model.document[detailsIndex].Description = reviewerEntityKindDetailLabel(state.kind) + } + hideDetails := state.kind == initReviewerEntityKindUseGitIdentity + if _, restore := initReviewerEntityRestoreSelectionName(selection); restore { + hideDetails = true + if detailsIndex >= 0 { + model.document[detailsIndex].Description = "This reviewer entity is staged for deletion. Restore it to make it available again." + } + } + model.setFieldHidden(initReviewerEntityFieldLabel, hideDetails) + model.setFieldHidden(initReviewerEntityFieldSecretLocation, hideDetails) + if resetDetails && !hideDetails { + labelInput, _, _ := reviewerEntityEditorLabelSeed(initReviewerEntityDraft{ + Kind: state.kind, + CredentialRef: state.seed.ReviewerCredentialRef, + DisplayName: state.seed.ReviewerDisplayName, + }) + if state.explicitDisplayName != "" { + labelInput = state.explicitDisplayName + } + if !state.preserveCurrentLocation { + labelInput = "" + } + reviewerSecretLocation := "" + if state.preserveCurrentLocation { + if currentRef := strings.TrimSpace(state.seed.ReviewerCredentialRef); currentRef != "" { + reviewerSecretLocation = currentRef + } else { + reviewerSecretLocation = state.standardReviewerRef + } + } + model.setFieldValue(initReviewerEntityFieldLabel, labelInput) + model.setFieldValue(initReviewerEntityFieldSecretLocation, reviewerSecretLocation) + } +} + +func initReviewerEntitySetSelectionOptions(model *initLinearEditorModel, ctx initPromptContext, selected string) { + index := model.document.fieldIndexByID(initReviewerEntityFieldSelection) + if index < 0 { + return + } + options := initLinearOptionsFromHuh(initReviewerEntityLinearSelectionOptions(ctx), selected) + for optionIndex := range options { + option := &options[optionIndex] + if _, configured := ctx.ReviewerEntities[option.Value]; configured { + option.Deletable = true + } + if _, restorable := initReviewerEntityRestoreSelectionName(option.Value); restorable { + option.Restorable = true + } + } + model.document[index].Options = options +} + +func reviewerEntityEditorStateForSelection(ctx initPromptContext, seed initDraft, selection string) (reviewerEntityEditorState, error) { + if entity, ok := ctx.ReviewerEntities[selection]; ok { + candidate := seed + applyReviewerEntityInventorySelection(&candidate, selection, ctx.ReviewerEntities) + return newReviewerEntityEditorState(entity, candidate, true) + } + if _, restore := initReviewerEntityRestoreSelectionName(selection); restore { + return newReviewerEntityEditorState(initReviewerEntityDraft{Kind: initReviewerEntityKindUseGitIdentity}, seed, false) + } + candidate := seed + applyReviewerEntityInventorySelection(&candidate, selection, ctx.ReviewerEntities) + return newReviewerEntityEditorState(initReviewerEntityDraft{Kind: initReviewerEntityKind(selection)}, candidate, false) +} + +func initReviewerEntityDraftFromDocument(ctx initPromptContext, seed initDraft, document initLinearDocument) (initDraft, error) { + selection := document.selectedValue(initReviewerEntityFieldSelection) + state, err := reviewerEntityEditorStateForSelection(ctx, seed, selection) + if err != nil { + return initDraft{}, err + } + return state.draftFromDocument(document) +} diff --git a/internal/cmd/credentialcmd/init_secrets_management.go b/internal/cmd/credentialcmd/init_secrets_management.go index cd2245a..711acce 100644 --- a/internal/cmd/credentialcmd/init_secrets_management.go +++ b/internal/cmd/credentialcmd/init_secrets_management.go @@ -41,9 +41,19 @@ type initLegacySecretsManagementEditorResult struct { } func initSecretsBackendCatalog() []initSecretsBackendPresentation { - items := make([]initSecretsBackendPresentation, 0, len(credstore.ValidBackendNames())) - for _, name := range credstore.ValidBackendNames() { - kind := config.SecretsBackendKind(name) + order := []config.SecretsBackendKind{ + config.SecretsBackendKind(credstore.BackendKeychain), + config.SecretsBackendKind(credstore.BackendWinCred), + config.SecretsBackendKind(credstore.BackendSecretService), + config.SecretsBackendKind(credstore.BackendPass), + config.SecretsBackendKind(credstore.BackendFile), + config.SecretsBackendKind(credstore.BackendOPDesktop), + config.SecretsBackendKind(credstore.BackendOP), + config.SecretsBackendKind(credstore.BackendOPConnect), + config.SecretsBackendKind(credstore.BackendMemory), + } + items := make([]initSecretsBackendPresentation, 0, len(order)) + for _, kind := range order { items = append(items, initSecretsBackendPresentation{ Kind: kind, Label: initSecretsBackendDisplayLabel(kind), @@ -93,11 +103,11 @@ func initSecretsBackendDescription(kind config.SecretsBackendKind) string { case config.SecretsBackendKind(credstore.BackendPass): return "Store credentials in an initialized pass password store." case config.SecretsBackendKind(credstore.BackendOP): - return "Use 1Password service-account access with non-secret config only." + return "Use 1Password service-account access. Best for CI or server environments where cr can read a service-account token from an environment variable; requires a vault name or id and service token env var." case config.SecretsBackendKind(credstore.BackendOPConnect): - return "Use 1Password Connect with non-secret host and env wiring." + return "Use a 1Password Connect server. Best when your team runs a Connect API endpoint; requires a vault name or id, Connect host, and Connect token env var." case config.SecretsBackendKind(credstore.BackendOPDesktop): - return "Use 1Password desktop integration with a selected account." + return "Use the local 1Password desktop app integration. Most common for local use; best for interactive developer machines with an unlocked desktop app; requires a vault name or id and can optionally pin a desktop account id." case config.SecretsBackendKind(credstore.BackendMemory): return "Keep credentials in memory only. Best suited for tests or CI." default: @@ -156,11 +166,11 @@ func initSecretsManagementInventoryRows(cfg config.File) []initInventoryRow { rows = append(rows, initInventoryRow{ ID: initSecretsManagementLegacySelection, Title: initLegacySecretsManagementInventoryTitle(cfg), - Description: "Compatibility settings for the older keyring.backend workflow.", + Description: "Global fallback credential store used by profiles that do not choose a named secrets-management profile.", Kind: initInventoryRowKindActive, PrimaryAction: initInventoryActionCommand, Selectable: true, - FilterValue: strings.TrimSpace(strings.Join([]string{"legacy compatibility", strings.TrimSpace(cfg.Keyring.Backend)}, " ")), + FilterValue: strings.TrimSpace(strings.Join([]string{"default credential store global fallback keyring backend", strings.TrimSpace(cfg.Keyring.Backend)}, " ")), }) for _, backend := range initSecretsBackendCatalog() { @@ -215,7 +225,7 @@ func initLegacySecretsManagementInventoryTitle(cfg config.File) string { if backend == config.ProjectedLegacySecretsBackendKind { backendLabel = "Automatic OS default" } - return fmt.Sprintf("Legacy compatibility (%s)", backendLabel) + return fmt.Sprintf("Default credential store (%s)", backendLabel) } func initSecretsProfileDisplayName(id string, label string) string { @@ -253,6 +263,9 @@ func initSecretsProfileEditorLabelSeed(profile config.SecretsProfile, id string, } } fallback := initSecretsBackendDisplayLabel(kind) + if creating && kind == config.SecretsBackendKind(credstore.BackendOPDesktop) { + fallback = "1Password" + } return initSecretsProfileLabelSeed{ DisplayValue: fallback, FallbackValue: fallback, @@ -406,6 +419,9 @@ func (p huhInitKeyringBackendPrompter) runInventory(prompt initInventoryPrompt) } func (p huhInitKeyringBackendPrompter) EditKeyringBackend(prompt initKeyringBackendPrompt) (initKeyringBackendEdit, error) { + if p.inventoryRunner == nil { + return p.editKeyringBackendLinear(prompt) + } working := cloneInitConfigFile(prompt.Config) original := cloneInitConfigFile(prompt.Config) @@ -687,17 +703,17 @@ func (p huhInitKeyringBackendPrompter) editLegacySecretsManagement(currentBacken form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). - Title("Legacy persistent backend"). + Title("Default credential store backend"). Options(initLegacySecretsBackendOptions(currentBackend)...). Value(&backend), huh.NewSelect[string](). - Title("Legacy secrets-management action"). + Title("Default credential-store action"). Options( - huh.NewOption("Stage legacy compatibility settings", initDetailActionEdit), + huh.NewOption("Stage default credential-store settings", initDetailActionEdit), huh.NewOption("Back without staging", initDetailActionBack), ). Value(&action), - ).Title("Legacy Secrets Compatibility"), + ).Title("Default Credential Store"), ) back, err := runBackableInitForm(form, p.stdin, p.stderr) if err != nil { diff --git a/internal/cmd/credentialcmd/init_secrets_management_editor.go b/internal/cmd/credentialcmd/init_secrets_management_editor.go new file mode 100644 index 0000000..d95f744 --- /dev/null +++ b/internal/cmd/credentialcmd/init_secrets_management_editor.go @@ -0,0 +1,661 @@ +package credentialcmd + +import ( + "fmt" + "io" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/open-cli-collective/cli-common/credstore" + + "github.com/open-cli-collective/codereview-cli/internal/config" + "github.com/open-cli-collective/codereview-cli/internal/configedit" +) + +type initSecretsManagementEditorRunner func(initLinearEditor, io.Reader, io.Writer) (initLinearEditorModel, error) + +const ( + initSecretsManagementFieldTarget initLinearFieldID = "secrets_management_target" + initSecretsManagementFieldLegacyBackend initLinearFieldID = "secrets_management_legacy_backend" + initSecretsManagementFieldLabel initLinearFieldID = "secrets_management_label" + initSecretsManagementFieldBackend initLinearFieldID = "secrets_management_backend" + initSecretsManagementFieldVaultID initLinearFieldID = "secrets_management_1password_vault_id" + initSecretsManagementFieldTimeout initLinearFieldID = "secrets_management_1password_timeout" + initSecretsManagementFieldItemTitlePrefix initLinearFieldID = "secrets_management_1password_item_title_prefix" + initSecretsManagementFieldItemTag initLinearFieldID = "secrets_management_1password_item_tag" + initSecretsManagementFieldItemFieldTitle initLinearFieldID = "secrets_management_1password_item_field_title" + initSecretsManagementFieldConnectHost initLinearFieldID = "secrets_management_1password_connect_host" + initSecretsManagementFieldConnectTokenEnv initLinearFieldID = "secrets_management_1password_connect_token_env" + initSecretsManagementFieldServiceTokenEnv initLinearFieldID = "secrets_management_1password_service_token_env" + initSecretsManagementFieldDesktopAccountID initLinearFieldID = "secrets_management_1password_desktop_account_id" + initSecretsManagementFieldDefault initLinearFieldID = "secrets_management_default" + initSecretsManagementFieldAction initLinearFieldID = "secrets_management_action" + initSecretsManagementSectionLegacy initLinearFieldID = "secrets_management_section_legacy" + initSecretsManagementSectionProfile initLinearFieldID = "secrets_management_section_profile" + initSecretsManagementSectionOnePassword initLinearFieldID = "secrets_management_section_1password" + initSecretsManagementSectionConnect initLinearFieldID = "secrets_management_section_connect" + initSecretsManagementSectionServiceAccount initLinearFieldID = "secrets_management_section_service_account" + initSecretsManagementSectionDesktop initLinearFieldID = "secrets_management_section_desktop" + initSecretsManagementDefaultNo string = "no" + initSecretsManagementDefaultYes string = "yes" + initSecretsManagementActionDelete string = "delete" +) + +const initSecretsManagementRestoreSelectionPrefix = "__restore_secrets_management__:" + +type initPendingSecretsManagementDelete struct { + ID string + Profile config.SecretsProfile +} + +func (p huhInitKeyringBackendPrompter) editKeyringBackendLinear(prompt initKeyringBackendPrompt) (initKeyringBackendEdit, error) { + working := cloneInitConfigFile(prompt.Config) + pendingDeletes := map[string]initPendingSecretsManagementDelete{} + pendingDeleteOrder := []string{} + for { + editor := initSecretsManagementLinearEditorWithPendingOrder(working, pendingDeletes, pendingDeleteOrder) + model, err := p.runSecretsManagementEditor(editor) + if err != nil { + return initKeyringBackendEdit{}, err + } + switch model.resultAction { + case initDetailActionEdit: + return initSecretsManagementEditFromDocument(working, model.document) + case initLinearResultActionDelete: + selection := model.document.selectedValue(initSecretsManagementFieldTarget) + profile, ok := working.Secrets.Profiles[selection] + if !ok { + return initKeyringBackendEdit{}, fmt.Errorf("%w: %s", config.ErrSecretsProfileNotFound, selection) + } + nextCfg, _, err := configedit.RemoveSecretsProfile(working, selection) + if err != nil { + return initKeyringBackendEdit{}, err + } + pendingDeletes[selection] = initPendingSecretsManagementDelete{ID: selection, Profile: profile} + pendingDeleteOrder = appendInitSecretsManagementPendingDeleteOrder(pendingDeleteOrder, selection) + working = nextCfg + case initLinearResultActionRestore: + selection := model.document.selectedValue(initSecretsManagementFieldTarget) + id, ok := initSecretsManagementRestoreSelectionName(selection) + if !ok { + continue + } + pending, ok := pendingDeletes[id] + if !ok { + continue + } + patch := configedit.SecretsProfilePatch{Backend: &pending.Profile.Backend} + if strings.TrimSpace(pending.Profile.Label) != "" { + label := pending.Profile.Label + patch.Label = &label + } + nextCfg, _, _, err := configedit.SetSecretsProfile(working, id, patch) + if err != nil { + return initKeyringBackendEdit{}, err + } + delete(pendingDeletes, id) + pendingDeleteOrder = removeInitSecretsManagementPendingDeleteOrder(pendingDeleteOrder, id) + working = nextCfg + default: + return initKeyringBackendEdit{}, errInitNavigateBack + } + } +} + +func (p huhInitKeyringBackendPrompter) runSecretsManagementEditor(editor initLinearEditor) (initLinearEditorModel, error) { + if p.editorRunner != nil { + return p.editorRunner(editor, p.stdin, p.stderr) + } + program := tea.NewProgram(newInitLinearEditorModel(editor, 100, 28), tea.WithInput(p.stdin), tea.WithOutput(p.stderr)) + finalModel, err := program.Run() + if err != nil { + return initLinearEditorModel{}, err + } + model, ok := finalModel.(initLinearEditorModel) + if !ok { + return initLinearEditorModel{}, fmt.Errorf("secrets-management editor returned %T", finalModel) + } + return model, nil +} + +func initSecretsManagementLinearEditor(cfg config.File) initLinearEditor { + return initSecretsManagementLinearEditorWithPending(cfg, nil) +} + +func initSecretsManagementLinearEditorWithPending(cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete) initLinearEditor { + return initSecretsManagementLinearEditorWithPendingOrder(cfg, pendingDeletes, nil) +} + +func initSecretsManagementLinearEditorWithPendingOrder(cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string) initLinearEditor { + targetOptions := initSecretsManagementTargetOptions(cfg, pendingDeletes, pendingDeleteOrder) + selectedTarget := normalizeInitStringSelectionValue("", targetOptions) + var document initLinearDocument + document.addSection("Secrets management", initSecretsManagementInventoryDescription()) + document.addEditableSelect(initSecretsManagementFieldTarget, "Secrets-management target", "", targetOptions, selectedTarget) + document.addSectionField(initSecretsManagementSectionLegacy, "Default credential store", "Global fallback credential store used by profiles that do not choose a named secrets-management profile.") + document.addEditableSelect(initSecretsManagementFieldLegacyBackend, "Legacy persistent backend", "", initLegacySecretsBackendOptions(cfg.Keyring.Backend), strings.TrimSpace(cfg.Keyring.Backend)) + document.addSectionField(initSecretsManagementSectionProfile, "Secrets-management profile", "Secrets-management profiles are reusable credential-store definitions that review profiles can choose later.") + document.addEditableInput( + initSecretsManagementFieldLabel, + "Secrets-management profile label", + "Choose a human-friendly label for this secrets-management profile. This is what the init menus will show later.", + "", + validateOptionalDisplayName, + ) + document.addEditableSelect(initSecretsManagementFieldBackend, "Secrets-management backend", "", initSecretsProfileBackendOptions(config.SecretsBackendKind(credstore.BackendKeychain)), string(credstore.BackendKeychain)) + document.addSectionField(initSecretsManagementSectionOnePassword, "1Password details", "These are non-secret 1Password settings. Tokens are referenced by environment variable name, not collected here.") + document.addEditableInput(initSecretsManagementFieldVaultID, "1Password vault name or id", "Required for every 1Password-backed secrets-management profile. Enter a vault name such as Personal, Employee, or My Vault, or enter a stable vault ID. If you have the 1Password CLI installed, `op vault list --format=json` can help inspect available vaults.", "", nil) + document.addEditableInput(initSecretsManagementFieldItemFieldTitle, "1Password secret name", "Optional name for the field that stores the secret inside each cr-managed 1Password item. Leave blank to use the backend default.", "", validateOptionalDisplayName) + document.addEditableInput(initSecretsManagementFieldItemTag, "1Password item tag", "Optional tag added to cr-managed 1Password items so the backend can find only the items it owns.", "", validateOptionalDisplayName) + document.addEditableInput(initSecretsManagementFieldItemTitlePrefix, "1Password item title prefix (advanced)", "Advanced. Prefix for generated 1Password item titles. Leave blank to use the backend default naming convention.", "", validateOptionalDisplayName) + document.addEditableInput(initSecretsManagementFieldTimeout, "1Password request timeout", "How long cr waits for one 1Password backend request before failing. Leave the default unless your 1Password integration is unusually slow.", "", validateOptionalDuration) + document.addSectionField(initSecretsManagementSectionConnect, "1Password Connect", "Required only for 1Password Connect profiles.") + document.addEditableInput(initSecretsManagementFieldConnectHost, "1Password Connect host", "Required only for 1Password Connect profiles.", "", nil) + document.addEditableInput(initSecretsManagementFieldConnectTokenEnv, "1Password Connect token env var", "Environment variable that holds the 1Password Connect token.", "", validateOptionalDisplayName) + document.addSectionField(initSecretsManagementSectionServiceAccount, "1Password service account", "Required only for 1Password service-account profiles.") + document.addEditableInput(initSecretsManagementFieldServiceTokenEnv, "1Password service token env var", "Environment variable that holds the 1Password service-account token.", "", validateOptionalDisplayName) + document.addSectionField(initSecretsManagementSectionDesktop, "1Password desktop", "Optional desktop integration settings.") + document.addEditableInput(initSecretsManagementFieldDesktopAccountID, "1Password desktop account id (advanced)", "Advanced. Optional account id when you need to pin this profile to one signed-in 1Password desktop account. Most users should leave this blank.", "", validateOptionalDisplayName) + document.addEditableSelect(initSecretsManagementFieldDefault, "Default secrets-management profile", "", []huh.Option[string]{ + huh.NewOption("No, keep the current default secrets-management profile", initSecretsManagementDefaultNo), + huh.NewOption("Yes, make this the default secrets-management profile", initSecretsManagementDefaultYes), + }, initSecretsManagementDefaultNo) + document.addEditableSelect(initSecretsManagementFieldAction, "Secrets-management action", "", []huh.Option[string]{ + huh.NewOption("Stage secrets-management settings", initDetailActionEdit), + huh.NewOption("Back without staging", initDetailActionBack), + }, initDetailActionEdit) + editor := initLinearEditor{ + Document: document, + OnFieldChange: func(model *initLinearEditorModel, index int) { + if index < 0 || index >= len(model.document) { + return + } + id := model.document[index].ID + if id == initSecretsManagementFieldTarget { + initSecretsManagementSyncLinearFields(model, cfg, pendingDeletes, pendingDeleteOrder, true) + return + } + if id == initSecretsManagementFieldBackend { + initSecretsManagementSyncLinearFields(model, cfg, pendingDeletes, pendingDeleteOrder, false) + } + }, + OnEnter: func(model *initLinearEditorModel) (bool, tea.Cmd) { + if model.focused < 0 || model.focused >= len(model.document) { + return false, nil + } + if model.document[model.focused].ID != initSecretsManagementFieldAction { + return false, nil + } + model.document[model.focused].Error = "" + switch model.document.selectedValue(initSecretsManagementFieldAction) { + case initDetailActionBack: + model.resultAction = initDetailActionBack + return true, tea.Quit + case initDetailActionEdit: + if _, err := initSecretsManagementEditFromDocument(cfg, model.document); err != nil { + model.document[model.focused].Error = err.Error() + model.relayout() + model.ensureFocusedVisible() + return true, nil + } + model.resultAction = initDetailActionEdit + return true, tea.Quit + case initSecretsManagementActionDelete: + if _, err := initSecretsManagementEditFromDocument(cfg, model.document); err != nil { + model.document[model.focused].Error = err.Error() + model.relayout() + model.ensureFocusedVisible() + return true, nil + } + model.resultAction = initDetailActionEdit + return true, tea.Quit + default: + return true, nil + } + }, + } + model := newInitLinearEditorModel(editor, 100, 28) + initSecretsManagementSyncLinearFields(&model, cfg, pendingDeletes, pendingDeleteOrder, true) + editor.Document = model.document + return editor +} + +func initSecretsManagementTargetOptions(cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string) []huh.Option[string] { + rows := initSecretsManagementInventoryRows(cfg) + options := make([]huh.Option[string], 0, len(rows)+len(pendingDeletes)) + commandOptions := make([]huh.Option[string], 0, len(rows)) + for _, row := range rows { + if row.ID == initBackSelection || !row.Selectable { + continue + } + option := huh.NewOption(row.Title, row.ID) + if row.Kind == initInventoryRowKindActive && row.ID != initSecretsManagementLegacySelection { + options = append(options, option) + continue + } + commandOptions = append(commandOptions, option) + } + pendingIDs := orderedInitSecretsManagementPendingDeleteIDs(pendingDeletes, pendingDeleteOrder) + options = append(options, commandOptions...) + for _, id := range pendingIDs { + pending := pendingDeletes[id] + options = append(options, huh.NewOption(initPendingDeleteLabel(initSecretsProfilePendingDeleteTitle(id, pending.Profile)), initSecretsManagementRestoreSelectionPrefix+id)) + } + return dedupeInitStringOptions(options) +} + +func orderedInitSecretsManagementPendingDeleteIDs(pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string) []string { + if len(pendingDeletes) == 0 { + return nil + } + seen := map[string]bool{} + ordered := make([]string, 0, len(pendingDeletes)) + for _, id := range pendingDeleteOrder { + if _, ok := pendingDeletes[id]; ok && !seen[id] { + ordered = append(ordered, id) + seen[id] = true + } + } + remainder := make([]string, 0, len(pendingDeletes)-len(ordered)) + for id := range pendingDeletes { + if !seen[id] { + remainder = append(remainder, id) + } + } + sort.Strings(remainder) + return append(ordered, remainder...) +} + +func appendInitSecretsManagementPendingDeleteOrder(order []string, id string) []string { + order = removeInitSecretsManagementPendingDeleteOrder(order, id) + return append(order, id) +} + +func removeInitSecretsManagementPendingDeleteOrder(order []string, id string) []string { + next := order[:0] + for _, existing := range order { + if existing != id { + next = append(next, existing) + } + } + return next +} + +type initSecretsManagementSelectionState struct { + Profile config.SecretsProfile + ID string + IsDefault bool + Creating bool + Legacy bool + Pending bool +} + +func initSecretsManagementSelectionStateForDocument(cfg config.File, document initLinearDocument) (initSecretsManagementSelectionState, error) { + return initSecretsManagementSelectionStateForSelection(cfg, document.selectedValue(initSecretsManagementFieldTarget)) +} + +func initSecretsManagementSelectionStateForSelection(cfg config.File, selection string) (initSecretsManagementSelectionState, error) { + if selection == initSecretsManagementLegacySelection { + return initSecretsManagementSelectionState{Legacy: true}, nil + } + if kind, ok := initSecretsProfileSelectionKind(selection); ok { + return initSecretsManagementSelectionState{ + Profile: config.SecretsProfile{Backend: normalizeInitSecretsProfileBackend(config.SecretsProfileBackend{Kind: kind})}, + Creating: true, + }, nil + } + if id, ok := initSecretsManagementRestoreSelectionName(selection); ok { + return initSecretsManagementSelectionState{ID: id, Pending: true}, nil + } + profile, ok := cfg.Secrets.Profiles[selection] + if !ok { + return initSecretsManagementSelectionState{}, fmt.Errorf("%w: %s", config.ErrSecretsProfileNotFound, selection) + } + return initSecretsManagementSelectionState{ + Profile: profile, + ID: selection, + IsDefault: strings.TrimSpace(cfg.Secrets.DefaultProfile) == selection, + }, nil +} + +func initSecretsManagementSyncLinearFields(model *initLinearEditorModel, cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string, resetDetails bool) { + state, err := initSecretsManagementSelectionStateForDocument(cfg, model.document) + if err != nil { + return + } + initSecretsManagementSetTargetOptions(model, cfg, pendingDeletes, pendingDeleteOrder, model.document.selectedValue(initSecretsManagementFieldTarget)) + profileSectionVisible := !state.Legacy + profileVisible := profileSectionVisible && !state.Pending + model.setFieldHidden(initSecretsManagementSectionLegacy, !state.Legacy) + model.setFieldHidden(initSecretsManagementFieldLegacyBackend, !state.Legacy) + model.setFieldHidden(initSecretsManagementSectionProfile, !profileSectionVisible) + model.setFieldHidden(initSecretsManagementFieldLabel, !profileVisible) + model.setFieldHidden(initSecretsManagementFieldBackend, !profileVisible || state.Creating) + model.setFieldHidden(initSecretsManagementFieldDefault, !profileVisible) + initSecretsManagementSetActionOptions(model, !state.Creating && !state.Legacy && !state.Pending) + if state.Legacy { + initSecretsManagementSetOnePasswordHidden(model, true, true, true, true) + return + } + if state.Pending { + model.setFieldHidden(initSecretsManagementFieldAction, false) + model.setFieldDescription(initSecretsManagementSectionProfile, "This secrets-management profile is staged for deletion. Press r while it is selected to restore it.") + initSecretsManagementSetOnePasswordHidden(model, true, true, true, true) + return + } + model.setFieldDescription(initSecretsManagementSectionProfile, initSecretsManagementProfileSectionDescription(model.document, state)) + profile := state.Profile + if strings.TrimSpace(string(profile.Backend.Kind)) == "" { + profile.Backend = normalizeInitSecretsProfileBackend(config.SecretsProfileBackend{Kind: config.SecretsBackendKind(credstore.BackendKeychain)}) + } else { + profile.Backend = normalizeInitSecretsProfileBackend(profile.Backend) + } + if resetDetails { + labelSeed := initSecretsProfileEditorLabelSeed(profile, state.ID, profile.Backend.Kind, state.Creating) + model.setFieldValue(initSecretsManagementFieldLabel, labelSeed.DisplayValue) + initSecretsManagementSetBackendOptions(model, profile.Backend.Kind, state.Creating) + model.selectFieldValue(initSecretsManagementFieldBackend, string(profile.Backend.Kind)) + useDefault := initSecretsManagementDefaultNo + if state.IsDefault { + useDefault = initSecretsManagementDefaultYes + } + model.selectFieldValue(initSecretsManagementFieldDefault, useDefault) + onePassword := config.SecretsProfileOnePasswordConfig{} + if profile.Backend.OnePassword != nil { + onePassword = *profile.Backend.OnePassword + } + model.setFieldValue(initSecretsManagementFieldVaultID, onePassword.VaultID) + model.setFieldValue(initSecretsManagementFieldTimeout, onePassword.Timeout) + model.setFieldValue(initSecretsManagementFieldItemTitlePrefix, onePassword.ItemTitlePrefix) + model.setFieldValue(initSecretsManagementFieldItemTag, onePassword.ItemTag) + model.setFieldValue(initSecretsManagementFieldItemFieldTitle, onePassword.ItemFieldTitle) + model.setFieldValue(initSecretsManagementFieldConnectHost, onePassword.ConnectHost) + model.setFieldValue(initSecretsManagementFieldConnectTokenEnv, onePassword.ConnectTokenEnv) + model.setFieldValue(initSecretsManagementFieldServiceTokenEnv, onePassword.ServiceTokenEnv) + model.setFieldValue(initSecretsManagementFieldDesktopAccountID, onePassword.DesktopAccountID) + } + kind := config.SecretsBackendKind(model.document.selectedValue(initSecretsManagementFieldBackend)) + if state.Creating { + kind = profile.Backend.Kind + model.selectFieldValue(initSecretsManagementFieldBackend, string(kind)) + } + initSecretsManagementSetBackendOptions(model, kind, state.Creating) + model.setFieldDescription(initSecretsManagementFieldBackend, initSecretsManagementBackendFieldDescription(kind, state.Creating)) + onePassword := config.IsOnePasswordSecretsBackend(kind) + opConnect := kind == config.SecretsBackendKind(credstore.BackendOPConnect) + opService := kind == config.SecretsBackendKind(credstore.BackendOP) + opDesktop := kind == config.SecretsBackendKind(credstore.BackendOPDesktop) + initSecretsManagementSetOnePasswordHidden(model, !onePassword, !opConnect, !opService, !opDesktop) + model.setFieldHidden(initSecretsManagementFieldTimeout, !opService && !opDesktop) + if onePassword && strings.TrimSpace(model.document.fieldValue(initSecretsManagementFieldItemTitlePrefix)) == "" { + model.setFieldHidden(initSecretsManagementFieldItemTitlePrefix, true) + } + if opDesktop && strings.TrimSpace(model.document.fieldValue(initSecretsManagementFieldDesktopAccountID)) == "" { + model.setFieldHidden(initSecretsManagementFieldDesktopAccountID, true) + } + model.setFieldHidden(initSecretsManagementSectionDesktop, !opDesktop || model.document.fieldHidden(initSecretsManagementFieldDesktopAccountID)) +} + +func initSecretsManagementSetTargetOptions(model *initLinearEditorModel, cfg config.File, pendingDeletes map[string]initPendingSecretsManagementDelete, pendingDeleteOrder []string, selected string) { + index := model.document.fieldIndexByID(initSecretsManagementFieldTarget) + if index < 0 { + return + } + options := initLinearOptionsFromHuh(initSecretsManagementTargetOptions(cfg, pendingDeletes, pendingDeleteOrder), selected) + for optionIndex := range options { + option := &options[optionIndex] + if _, configured := cfg.Secrets.Profiles[option.Value]; configured { + option.Deletable = strings.TrimSpace(cfg.Secrets.DefaultProfile) != option.Value + } + if _, restorable := initSecretsManagementRestoreSelectionName(option.Value); restorable { + option.Restorable = true + } + } + model.document[index].Options = options +} + +func initSecretsManagementSetBackendOptions(model *initLinearEditorModel, current config.SecretsBackendKind, locked bool) { + index := model.document.fieldIndexByID(initSecretsManagementFieldBackend) + if index < 0 { + return + } + if locked { + model.document[index].Options = []initLinearOption{{ + Label: initSecretsManagementBackendOptionLabel(current), + Value: string(current), + Selected: true, + }} + return + } + selected := model.document.selectedValue(initSecretsManagementFieldBackend) + if selected == "" { + selected = string(current) + } + model.document[index].Options = initLinearOptionsFromHuh(initSecretsProfileBackendOptions(current), selected) +} + +func initSecretsManagementSetActionOptions(model *initLinearEditorModel, canDelete bool) { + index := model.document.fieldIndexByID(initSecretsManagementFieldAction) + if index < 0 { + return + } + selected := model.document.selectedValue(initSecretsManagementFieldAction) + options := []huh.Option[string]{ + huh.NewOption("Stage secrets-management settings", initDetailActionEdit), + huh.NewOption("Back without staging", initDetailActionBack), + } + if selected == initSecretsManagementActionDelete { + selected = initDetailActionEdit + } + model.document[index].Options = initLinearOptionsFromHuh(options, selected) +} + +func initSecretsManagementBackendOptionLabel(kind config.SecretsBackendKind) string { + if backend, ok := initSecretsBackendByKind(kind); ok { + label := backend.Label + if !backend.Available { + label += " (unavailable in this build; existing config)" + } + return label + } + return string(kind) +} + +func initSecretsManagementProfileSectionDescription(document initLinearDocument, state initSecretsManagementSelectionState) string { + target := initSecretsManagementSelectedOptionLabel(document, initSecretsManagementFieldTarget) + if state.Pending && target != "" { + return fmt.Sprintf("Selected target: %s. This profile is staged for deletion. Press r to restore it.", target) + } + if state.Creating && target != "" { + return fmt.Sprintf("Selected target: %s. Fields below configure that new secrets-management profile.", target) + } + if state.ID != "" && target != "" { + return fmt.Sprintf("Selected target: %s. Fields below edit this configured secrets-management profile.", target) + } + return "Secrets-management profiles are reusable credential-store definitions that review profiles can choose later." +} + +func initSecretsManagementRestoreSelectionName(selection string) (string, bool) { + if !strings.HasPrefix(selection, initSecretsManagementRestoreSelectionPrefix) { + return "", false + } + return strings.TrimPrefix(selection, initSecretsManagementRestoreSelectionPrefix), true +} + +func initSecretsProfilePendingDeleteTitle(id string, profile config.SecretsProfile) string { + return initSecretsProfileInventoryTitle(config.EffectiveSecretsProfile{ + ID: id, + Label: profile.Label, + Backend: string(profile.Backend.Kind), + Source: config.EffectiveSecretsProfileSourceConfigured, + }) +} + +func initSecretsManagementBackendFieldDescription(kind config.SecretsBackendKind, locked bool) string { + description := strings.TrimSpace(initSecretsBackendDescription(kind)) + if locked { + return strings.TrimSpace(description + " This backend is fixed by the selected create-new target; choose a different target above to create a different backend type.") + } + return strings.TrimSpace(description + " Use Up/Down here to change the backend for this configured secrets-management profile.") +} + +func initSecretsManagementSelectedOptionLabel(document initLinearDocument, id initLinearFieldID) string { + index := document.fieldIndexByID(id) + if index < 0 { + return "" + } + for _, option := range document[index].Options { + if option.Selected { + return option.Label + } + } + return "" +} + +func initSecretsManagementSetOnePasswordHidden(model *initLinearEditorModel, hideOnePassword bool, hideConnect bool, hideService bool, hideDesktop bool) { + model.setFieldHidden(initSecretsManagementSectionOnePassword, hideOnePassword) + model.setFieldHidden(initSecretsManagementFieldVaultID, hideOnePassword) + model.setFieldHidden(initSecretsManagementFieldItemTitlePrefix, hideOnePassword) + model.setFieldHidden(initSecretsManagementFieldItemTag, hideOnePassword) + model.setFieldHidden(initSecretsManagementFieldItemFieldTitle, hideOnePassword) + model.setFieldHidden(initSecretsManagementSectionConnect, hideOnePassword || hideConnect) + model.setFieldHidden(initSecretsManagementFieldConnectHost, hideOnePassword || hideConnect) + model.setFieldHidden(initSecretsManagementFieldConnectTokenEnv, hideOnePassword || hideConnect) + model.setFieldHidden(initSecretsManagementSectionServiceAccount, hideOnePassword || hideService) + model.setFieldHidden(initSecretsManagementFieldServiceTokenEnv, hideOnePassword || hideService) + model.setFieldHidden(initSecretsManagementSectionDesktop, hideOnePassword || hideDesktop) + model.setFieldHidden(initSecretsManagementFieldDesktopAccountID, hideOnePassword || hideDesktop) + model.setFieldHidden(initSecretsManagementFieldTimeout, hideOnePassword) +} + +func initSecretsManagementEditFromDocument(cfg config.File, document initLinearDocument) (initKeyringBackendEdit, error) { + state, err := initSecretsManagementSelectionStateForDocument(cfg, document) + if err != nil { + return initKeyringBackendEdit{}, err + } + working := cloneInitConfigFile(cfg) + if state.Legacy { + working.Keyring.Backend = strings.TrimSpace(document.selectedValue(initSecretsManagementFieldLegacyBackend)) + return initKeyringBackendEdit{Apply: true, HasConfigEdit: true, Config: config.Normalize(working)}, nil + } + if state.Pending { + return initKeyringBackendEdit{Apply: true, HasConfigEdit: true, Config: config.Normalize(working)}, nil + } + if document.selectedValue(initSecretsManagementFieldAction) == initSecretsManagementActionDelete { + if state.Creating || state.ID == "" { + return initKeyringBackendEdit{}, fmt.Errorf("only configured secrets-management profiles can be deleted") + } + nextCfg, _, err := configedit.RemoveSecretsProfile(working, state.ID) + if err != nil { + return initKeyringBackendEdit{}, err + } + return initKeyringBackendEdit{Apply: true, HasConfigEdit: true, Config: nextCfg}, nil + } + edit, err := initSecretsManagementProfileEditFromDocument(state, document) + if err != nil { + return initKeyringBackendEdit{}, err + } + if state.Creating { + id := initSecretsProfileIDFromLabel(edit.StoredLabel, edit.Backend.Kind, working.Secrets.Profiles) + patch := configedit.SecretsProfilePatch{Backend: &edit.Backend} + if edit.StoredLabel != "" { + label := edit.StoredLabel + patch.Label = &label + } + nextCfg, _, _, err := configedit.SetSecretsProfile(working, id, patch) + if err != nil { + return initKeyringBackendEdit{}, err + } + if edit.UseDefault { + nextCfg, _, err = configedit.SetDefaultSecretsProfile(nextCfg, id) + } + if err != nil { + return initKeyringBackendEdit{}, err + } + return initKeyringBackendEdit{Apply: true, HasConfigEdit: true, Config: nextCfg}, nil + } + patch := configedit.SecretsProfilePatch{Backend: &edit.Backend} + if edit.StoredLabel != "" { + label := edit.StoredLabel + patch.Label = &label + } else { + patch.ClearLabel = true + } + nextCfg, _, _, err := configedit.SetSecretsProfile(working, state.ID, patch) + if err != nil { + return initKeyringBackendEdit{}, err + } + if edit.UseDefault { + nextCfg, _, err = configedit.SetDefaultSecretsProfile(nextCfg, state.ID) + } else if strings.TrimSpace(working.Secrets.DefaultProfile) == state.ID { + nextCfg, _, err = configedit.UnsetDefaultSecretsProfile(nextCfg) + } + if err != nil { + return initKeyringBackendEdit{}, err + } + return initKeyringBackendEdit{Apply: true, HasConfigEdit: true, Config: nextCfg}, nil +} + +func initSecretsManagementProfileEditFromDocument(state initSecretsManagementSelectionState, document initLinearDocument) (initSecretsProfileEditorResult, error) { + profile := state.Profile + if strings.TrimSpace(string(profile.Backend.Kind)) == "" { + profile.Backend = normalizeInitSecretsProfileBackend(config.SecretsProfileBackend{Kind: config.SecretsBackendKind(credstore.BackendKeychain)}) + } else { + profile.Backend = normalizeInitSecretsProfileBackend(profile.Backend) + } + labelSeed := initSecretsProfileEditorLabelSeed(profile, state.ID, profile.Backend.Kind, state.Creating) + labelInput := document.fieldValue(initSecretsManagementFieldLabel) + if err := validateOptionalDisplayName(labelInput); err != nil { + return initSecretsProfileEditorResult{}, err + } + kindValue := document.selectedValue(initSecretsManagementFieldBackend) + kind := config.SecretsBackendKind(kindValue) + vaultValue := document.fieldValue(initSecretsManagementFieldVaultID) + if config.IsOnePasswordSecretsBackend(kind) { + if err := validateInitSecretsRequiredSingleLine(vaultValue, true, "1Password vault name or id"); err != nil { + return initSecretsProfileEditorResult{}, err + } + } + if kind == config.SecretsBackendKind(credstore.BackendOPConnect) { + if err := validateInitSecretsRequiredSingleLine(document.fieldValue(initSecretsManagementFieldConnectHost), true, "1Password Connect host"); err != nil { + return initSecretsProfileEditorResult{}, err + } + } + for _, field := range []struct { + id initLinearFieldID + validate func(string) error + }{ + {initSecretsManagementFieldTimeout, validateOptionalDuration}, + {initSecretsManagementFieldItemTitlePrefix, validateOptionalDisplayName}, + {initSecretsManagementFieldItemTag, validateOptionalDisplayName}, + {initSecretsManagementFieldItemFieldTitle, validateOptionalDisplayName}, + {initSecretsManagementFieldConnectTokenEnv, validateOptionalDisplayName}, + {initSecretsManagementFieldServiceTokenEnv, validateOptionalDisplayName}, + {initSecretsManagementFieldDesktopAccountID, validateOptionalDisplayName}, + } { + if err := field.validate(document.fieldValue(field.id)); err != nil { + return initSecretsProfileEditorResult{}, err + } + } + backend := initSecretsProfileBackendFromInputs( + kindValue, + document.fieldValue(initSecretsManagementFieldTimeout), + vaultValue, + document.fieldValue(initSecretsManagementFieldItemTitlePrefix), + document.fieldValue(initSecretsManagementFieldItemTag), + document.fieldValue(initSecretsManagementFieldItemFieldTitle), + document.fieldValue(initSecretsManagementFieldConnectHost), + document.fieldValue(initSecretsManagementFieldConnectTokenEnv), + document.fieldValue(initSecretsManagementFieldServiceTokenEnv), + document.fieldValue(initSecretsManagementFieldDesktopAccountID), + ) + return initSecretsProfileEditorResult{ + Apply: true, + Label: strings.TrimSpace(labelInput), + StoredLabel: normalizeInitSecretsProfileStoredLabel(labelInput, labelSeed.FallbackValue, labelSeed.StoredLabel, state.Creating), + Backend: backend, + UseDefault: document.selectedValue(initSecretsManagementFieldDefault) == initSecretsManagementDefaultYes, + }, nil +} diff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go index c7b1aff..e914020 100644 --- a/internal/credentials/credentials_test.go +++ b/internal/credentials/credentials_test.go @@ -478,7 +478,7 @@ func TestStoreOptionsForResolvedProfile_OnePasswordBackend(t *testing.T) { Kind: config.SecretsBackendKind(credstore.BackendOPDesktop), OnePassword: &config.SecretsProfileOnePasswordConfig{ Timeout: "9s", - VaultID: "vault-123", + VaultID: "Employee", ItemTitlePrefix: "cr", ItemTag: "codereview", ItemFieldTitle: "credential", @@ -488,7 +488,7 @@ func TestStoreOptionsForResolvedProfile_OnePasswordBackend(t *testing.T) { }, assert: func(t *testing.T, got *credstore.OnePasswordOptions) { t.Helper() - if got.Timeout != 9*time.Second || got.VaultID != "vault-123" || got.DesktopAccountID != "desktop-account" { + if got.Timeout != 9*time.Second || got.VaultID != "Employee" || got.DesktopAccountID != "desktop-account" { t.Fatalf("OnePassword = %#v, want desktop mapping", got) } }, diff --git a/scripts/probe-credential-store.go b/scripts/probe-credential-store.go new file mode 100644 index 0000000..7abf1e9 --- /dev/null +++ b/scripts/probe-credential-store.go @@ -0,0 +1,166 @@ +//go:build ignore + +package main + +import ( + "crypto/rand" + "encoding/hex" + "flag" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/open-cli-collective/codereview-cli/internal/config" + "github.com/open-cli-collective/codereview-cli/internal/credentials" +) + +func main() { + var configPath string + var reviewProfile string + var secretsProfile string + var ref string + var keep bool + flag.StringVar(&configPath, "config", "", "cr config path; defaults to the standard cr config path") + flag.StringVar(&reviewProfile, "profile", "", "review profile whose resolved secrets-management profile should be probed; defaults to cr's default profile") + flag.StringVar(&secretsProfile, "secrets-profile", "", "configured secrets-management profile id to probe directly") + flag.StringVar(&ref, "ref", "", "temporary credential ref to write; defaults to codereview/probe-") + flag.BoolVar(&keep, "keep", false, "leave the probe credential behind instead of deleting it") + flag.Parse() + + if configPath == "" { + path, err := config.Path() + fatalIf(err, "resolve config path") + configPath = path + } + cfg, err := config.Load(configPath) + fatalIf(err, "load config") + + resolved, err := resolveProbeSecretsProfile(cfg, reviewProfile, secretsProfile) + fatalIf(err, "resolve secrets-management profile") + + if ref == "" { + ref = fmt.Sprintf("codereview/probe-%d", time.Now().UnixNano()) + } + parsed, err := credentials.ParseRef(ref) + fatalIf(err, "parse probe ref") + + value, err := randomProbeValue() + fatalIf(err, "generate probe value") + + fmt.Printf("Config: %s\n", configPath) + fmt.Printf("Secrets profile: %s (%s, backend %s)\n", resolved.DisplayName(), resolved.ID, resolved.Backend) + fmt.Printf("Probe ref: %s\n", ref) + + store, err := credentials.OpenResolvedStore("", false, cfg, resolved) + fatalIf(err, "open credential store") + defer store.Close() + + _, err = store.SetBundle(parsed.Profile, map[string]string{ + credentials.GitTokenKey: value, + }) + fatalIf(err, "write probe credential") + + got, err := store.Get(parsed.Profile, credentials.GitTokenKey) + fatalIf(err, "read probe credential") + if got != value { + fatalf("read probe credential: value mismatch") + } + + if keep { + fmt.Printf("OK: wrote and read probe credential; left it in place because --keep was set\n") + return + } + if _, err := store.DeleteBundle(parsed.Profile); err != nil { + fatalf("delete probe credential after successful write/read: %v\nmanual cleanup: cr delete-credential --ref %s --key %s", err, ref, credentials.GitTokenKey) + } + fmt.Printf("OK: wrote, read, and deleted probe credential\n") +} + +func resolveProbeSecretsProfile(cfg config.File, reviewProfile string, secretsProfile string) (credentials.ResolvedSecretsProfile, error) { + if strings.TrimSpace(secretsProfile) != "" { + query := strings.TrimSpace(secretsProfile) + profile, ok := cfg.Secrets.Profiles[query] + if ok { + return resolvedProbeSecretsProfile(query, profile), nil + } + matches := matchingProbeSecretsProfiles(cfg, query) + if len(matches) == 1 { + profileID := matches[0] + return resolvedProbeSecretsProfile(profileID, cfg.Secrets.Profiles[profileID]), nil + } + if len(matches) > 1 { + return credentials.ResolvedSecretsProfile{}, fmt.Errorf("%w: %s matched multiple secrets-management profiles: %s", config.ErrSecretsProfileNotFound, query, strings.Join(matches, ", ")) + } + return credentials.ResolvedSecretsProfile{}, fmt.Errorf("%w: %s\nconfigured secrets-management profiles: %s", config.ErrSecretsProfileNotFound, query, availableProbeSecretsProfiles(cfg)) + } + _, profile, err := config.ResolveProfile(cfg, reviewProfile) + if err != nil { + return credentials.ResolvedSecretsProfile{}, err + } + return credentials.ResolveSecretsProfileForProfile(cfg, profile) +} + +func matchingProbeSecretsProfiles(cfg config.File, query string) []string { + query = strings.TrimSpace(query) + var matches []string + for id, profile := range cfg.Secrets.Profiles { + if strings.EqualFold(id, query) || strings.EqualFold(strings.TrimSpace(profile.Label), query) { + matches = append(matches, id) + } + } + sort.Strings(matches) + return matches +} + +func resolvedProbeSecretsProfile(id string, profile config.SecretsProfile) credentials.ResolvedSecretsProfile { + return credentials.ResolvedSecretsProfile{ + ID: id, + Label: strings.TrimSpace(profile.Label), + Backend: string(profile.Backend.Kind), + Source: config.EffectiveSecretsProfileSourceConfigured, + SelectionSource: credentials.SecretsProfileSelectionExplicit, + } +} + +func availableProbeSecretsProfiles(cfg config.File) string { + if len(cfg.Secrets.Profiles) == 0 { + return "none configured" + } + ids := make([]string, 0, len(cfg.Secrets.Profiles)) + for id := range cfg.Secrets.Profiles { + ids = append(ids, id) + } + sort.Strings(ids) + items := make([]string, 0, len(ids)) + for _, id := range ids { + profile := cfg.Secrets.Profiles[id] + label := strings.TrimSpace(profile.Label) + if label != "" { + items = append(items, fmt.Sprintf("%s (%s, backend %s)", id, label, profile.Backend.Kind)) + continue + } + items = append(items, fmt.Sprintf("%s (backend %s)", id, profile.Backend.Kind)) + } + return strings.Join(items, "; ") +} + +func randomProbeValue() (string, error) { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", err + } + return "cr-probe-" + hex.EncodeToString(bytes[:]), nil +} + +func fatalIf(err error, context string) { + if err != nil { + fatalf("%s: %v", context, err) + } +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "probe failed: "+format+"\n", args...) + os.Exit(1) +}