diff --git a/docs/development.md b/docs/development.md index a3c39e26..7470c288 100644 --- a/docs/development.md +++ b/docs/development.md @@ -55,10 +55,13 @@ make clean # remove build artifacts ## Interactive Init Notes -`cr init` interactive mode only collects non-secret configuration. When the -selected LLM auth mode is `api_key`, the wizard saves the non-secret profile -shape and prints a follow-up `cr set-credential` command instead of collecting -the API key inline. +`cr init` interactive mode keeps all writes draft-local until the user commits +staged changes. Reviewer setup may collect PAT or GitHub App reviewer secrets +inside the reviewer-entity flow, but those values are only written to the +credential store during **Commit staged changes and exit**. When the selected +LLM auth mode is `api_key`, the wizard saves the non-secret profile shape and +prints a follow-up `cr set-credential` command instead of collecting the API key +inline. Interactive `git.host` edits now route through the repository-route stage when the target profile already participates in `repository_profiles` routing. The diff --git a/docs/init-ux-contract.md b/docs/init-ux-contract.md index 796be8f6..edf941b6 100644 --- a/docs/init-ux-contract.md +++ b/docs/init-ux-contract.md @@ -144,19 +144,23 @@ Until the user chooses **Commit staged changes and exit**: Interactive `init` must offer both: -- **Commit staged changes and exit**: validate the draft, collect or defer - required secrets, then write config and keyring state in the defined - final commit order +- **Commit staged changes and exit**: validate the draft, collect or defer any + still-unhandled required secrets, then write config and keyring state in the + defined final commit order - **Discard staged changes and exit**: discard the draft and leave both config and keyring untouched -Credential collection belongs near final commit, after the user has assembled the -profile shape well enough to understand why each secret is needed. - -If the user cancels during credential collection after choosing **Commit staged -changes and exit**, any pending secret values remain draft-only and the session -returns to a no-write state. Until final commit begins, cancellation must still -leave both config and keyring untouched. +Credential values may be collected inside the relevant subflow once the user has +enough local context to understand why each secret is needed. For example, +reviewer-entity setup may collect PAT or GitHub App reviewer secrets immediately +after the reviewer settings are staged. Those values remain draft-local until +commit; final commit still handles untouched or deferred Git, reviewer, and LLM +credential refs. + +If the user cancels during credential collection, whether from a subflow or after +choosing **Commit staged changes and exit**, any pending secret values remain +draft-only and the session returns to a no-write state. Until final commit +begins, cancellation must still leave both config and keyring untouched. ## Draft-Local Reuse Rules diff --git a/internal/cmd/credentialcmd/credentialcmd.go b/internal/cmd/credentialcmd/credentialcmd.go index 3f5a7b97..38b03adb 100644 --- a/internal/cmd/credentialcmd/credentialcmd.go +++ b/internal/cmd/credentialcmd/credentialcmd.go @@ -438,39 +438,41 @@ type initPlan struct { } type initWorkspaceDraft struct { - path string - cfg config.File - previousProfile *config.Profile - profileName string - profile config.Profile - gitScopeName string - gitScopes map[string]initGitScopeDraft - reviewerEntityName string - reviewerEntities map[string]initReviewerEntityDraft - llmRuntimeName string - llmRuntimes map[string]initLLMRuntimeDraft - writes map[string]map[string]string - credentialPlan []initCredentialPlanEntry - overwriteRefs map[string]bool - satisfiedRefs map[string]bool - backendFlagSet bool - backendArg string - allowDeferredLLM bool - writeLLMHint bool + path string + cfg config.File + previousProfile *config.Profile + profileName string + profile config.Profile + gitScopeName string + gitScopes map[string]initGitScopeDraft + reviewerEntityName string + reviewerEntities map[string]initReviewerEntityDraft + llmRuntimeName string + llmRuntimes map[string]initLLMRuntimeDraft + writes map[string]map[string]string + credentialPlan []initCredentialPlanEntry + credentialDecisions map[initCredentialDecisionKey]initCredentialDecisionKind + overwriteRefs map[string]bool + satisfiedRefs map[string]bool + backendFlagSet bool + backendArg string + allowDeferredLLM bool + writeLLMHint bool } type initSessionPlan struct { - path string - originalCfg config.File - cfg config.File - profileNames []string - profileRefs map[string][]config.CredentialRef - writes map[string]map[string]string - credentialPlan []initCredentialPlanEntry - overwriteRefs map[string]bool - satisfiedRefs map[string]bool - backendFlagSet bool - backendArg string + path string + originalCfg config.File + cfg config.File + profileNames []string + profileRefs map[string][]config.CredentialRef + writes map[string]map[string]string + credentialPlan []initCredentialPlanEntry + credentialDecisions map[initCredentialDecisionKey]initCredentialDecisionKind + overwriteRefs map[string]bool + satisfiedRefs map[string]bool + backendFlagSet bool + backendArg string } type initSessionDraft struct { @@ -482,6 +484,7 @@ type initSessionDraft struct { workspace *initWorkspaceDraft touchedProfiles map[string]string writes map[string]map[string]string + credentialDecisions map[initCredentialDecisionKey]initCredentialDecisionKind overwriteRefs map[string]bool satisfiedRefs map[string]bool pendingProfileDeletes map[string]initPendingProfileDelete @@ -587,6 +590,21 @@ type initCredentialPlanEntry struct { State initCredentialPlanState } +type initCredentialDecisionKind string + +const ( + initCredentialDecisionDefer initCredentialDecisionKind = "defer" + initCredentialDecisionSkipOptional initCredentialDecisionKind = "skip_optional" +) + +type initCredentialDecisionKey struct { + Store string + Purpose string + Mode string + Ref string + Key string +} + type initSecretPrompter interface { ChooseCredentialAction(initCredentialSecretPrompt) (initCredentialSecretAction, error) ChooseSecretSource(initSecretValuePrompt) (initSecretSource, error) @@ -1066,6 +1084,7 @@ func bootstrapInteractiveInitSession(cmd *cobra.Command, opts *root.Options, fla backendFlagSet: cmderr.BackendFlagChanged(cmd), touchedProfiles: map[string]string{}, writes: map[string]map[string]string{}, + credentialDecisions: map[initCredentialDecisionKey]initCredentialDecisionKind{}, overwriteRefs: map[string]bool{}, satisfiedRefs: map[string]bool{}, pendingProfileDeletes: map[string]initPendingProfileDelete{}, @@ -1491,6 +1510,14 @@ func editInteractiveInitReviewerEntityStep(cmd *cobra.Command, opts *root.Option session.cfg = propagateSharedReviewerEntityChanges(previousCfg, session.cfg, workspace.profileName, previousEntity, nextEntity) session = rebuildInteractiveInitWorkspace(session, workspace.profileName) session.requestedProfileName = workspace.profileName + if session.workspace != nil { + credentialPlan, err := planInitCredentialsWithConfig(session.cfg, workspace.previousProfile, session.workspace.profile, projectInitPlannedWriteKeys(session.writes)) + if err != nil { + return initSessionDraft{}, false, cmderr.Config(err) + } + session.workspace.previousProfile = workspace.previousProfile + session.workspace.credentialPlan = credentialPlan + } if previousProfileName != workspace.profileName || !reflect.DeepEqual(previousProfile, session.workspace.profile) { session = recordTouchedProfile(session, workspace.profileName, draft.OriginalProfileName) if deps.menuPrompter != nil || deps.prompter == nil { @@ -3297,19 +3324,10 @@ func (p huhInitSecretPrompter) ChooseCredentialAction(prompt initCredentialSecre huh.NewOption("Back to main menu", initCredentialSecretActionBack), ) choice := options[0].Value - title := fmt.Sprintf("How should init handle %s credentials?", initCredentialPurposeLabel(prompt.Entry.Ref.Purpose)) - if prompt.Entry.Ref.Ref != "" { - title = fmt.Sprintf("%s (%s%s)", title, prompt.Entry.Ref.Ref, initSecretsProfilePromptSuffix(prompt.Entry.SecretsProfile)) - } else if suffix := initSecretsProfilePromptSuffix(prompt.Entry.SecretsProfile); suffix != "" { - title += suffix - } - if prompt.TargetHasAnyKeys && !prompt.TargetHasRequired { - title = fmt.Sprintf("%s Existing values were found; choose set-now to review them key by key.", title) - } form := huh.NewForm( huh.NewGroup( huh.NewSelect[initCredentialSecretAction](). - Title(title). + Title(initCredentialSecretPromptTitle(prompt)). Options(options...). Value(&choice), ), @@ -3324,6 +3342,22 @@ func (p huhInitSecretPrompter) ChooseCredentialAction(prompt initCredentialSecre return choice, nil } +func initCredentialSecretPromptTitle(prompt initCredentialSecretPrompt) string { + title := fmt.Sprintf("How should init handle %s?", initCredentialSecretBundleLabel(prompt.Entry)) + if prompt.Entry.Ref.Ref != "" { + title = fmt.Sprintf("%s (%s%s)", title, prompt.Entry.Ref.Ref, initSecretsProfilePromptSuffix(prompt.Entry.SecretsProfile)) + } else if suffix := initSecretsProfilePromptSuffix(prompt.Entry.SecretsProfile); suffix != "" { + title += suffix + } + if prompt.Entry.Ref.Purpose == "reviewer_credentials" { + title += initCredentialScopedKeySummary(prompt.Entry) + } + if prompt.TargetHasAnyKeys && !prompt.TargetHasRequired { + title = fmt.Sprintf("%s Existing values were found; choose set-now to review them key by key.", title) + } + return title +} + func (p huhInitSecretPrompter) ChooseSecretSource(prompt initSecretValuePrompt) (initSecretSource, error) { options := make([]huh.Option[initSecretSource], 0, 4) if prompt.TargetHasKey { @@ -3341,7 +3375,7 @@ func (p huhInitSecretPrompter) ChooseSecretSource(prompt initSecretValuePrompt) form := huh.NewForm( huh.NewGroup( huh.NewSelect[initSecretSource](). - Title(fmt.Sprintf("How should init get %s%s?", prompt.Key, initSecretsProfilePromptSuffix(prompt.Entry.SecretsProfile))). + Title(initSecretSourcePromptTitle(prompt)). Options(options...). Value(&choice), ), @@ -3356,6 +3390,10 @@ func (p huhInitSecretPrompter) ChooseSecretSource(prompt initSecretValuePrompt) return choice, nil } +func initSecretSourcePromptTitle(prompt initSecretValuePrompt) string { + return fmt.Sprintf("How should init get %s%s?", prompt.Key, initSecretsProfilePromptSuffix(prompt.Entry.SecretsProfile)) +} + func (p huhInitSecretPrompter) PasteSecret(prompt initSecretValuePrompt) (string, error) { var value string action := initDetailActionEdit @@ -4298,18 +4336,20 @@ func buildInteractiveInitSessionPlan(opts *root.Options, session initSessionDraf overwriteRefs := filterInitBoolMapByRefs(session.overwriteRefs, activeRefs) satisfiedRefs := filterInitBoolMapByRefs(session.satisfiedRefs, activeRefs) entries = refreshInteractiveCredentialPlan(entries, projectInitPlannedWriteKeys(writes), satisfiedRefs) + credentialDecisions := filterInitCredentialDecisions(session.credentialDecisions, entries) return initSessionPlan{ - path: session.path, - originalCfg: cloneInitConfigFile(session.originalCfg), - cfg: cloneInitConfigFile(session.cfg), - profileNames: profileNames, - profileRefs: profileRefs, - writes: writes, - credentialPlan: entries, - overwriteRefs: overwriteRefs, - satisfiedRefs: satisfiedRefs, - backendFlagSet: session.backendFlagSet, - backendArg: interactiveInitBackendArg(opts, session.backendFlagSet, session.cfg), + path: session.path, + originalCfg: cloneInitConfigFile(session.originalCfg), + cfg: cloneInitConfigFile(session.cfg), + profileNames: profileNames, + profileRefs: profileRefs, + writes: writes, + credentialPlan: entries, + credentialDecisions: credentialDecisions, + overwriteRefs: overwriteRefs, + satisfiedRefs: satisfiedRefs, + backendFlagSet: session.backendFlagSet, + backendArg: interactiveInitBackendArg(opts, session.backendFlagSet, session.cfg), }, nil } @@ -4362,6 +4402,7 @@ func collectInteractiveInitSessionWorkspaceSecrets(opts *root.Options, deps init } workspace := *session.workspace workspace.writes = cloneInitWrites(session.writes) + workspace.credentialDecisions = cloneInitCredentialDecisions(session.credentialDecisions) workspace.overwriteRefs = cloneInitBoolMap(session.overwriteRefs) workspace.satisfiedRefs = cloneInitBoolMap(session.satisfiedRefs) workspace.credentialPlan = refreshInteractiveCredentialPlan(entries, projectInitPlannedWriteKeys(workspace.writes), workspace.satisfiedRefs) @@ -4372,6 +4413,7 @@ func collectInteractiveInitSessionWorkspaceSecrets(opts *root.Options, deps init session.workspace = &nextWorkspace session.cfg = cloneInitConfigFile(nextWorkspace.cfg) session.writes = cloneInitWrites(nextWorkspace.writes) + session.credentialDecisions = cloneInitCredentialDecisions(nextWorkspace.credentialDecisions) session.overwriteRefs = cloneInitBoolMap(nextWorkspace.overwriteRefs) session.satisfiedRefs = cloneInitBoolMap(nextWorkspace.satisfiedRefs) return session, nil @@ -4419,23 +4461,24 @@ func rebuildInteractiveInitWorkspace(session initSessionDraft, profileName strin reviewerEntities, profileReviewerEntityNames := buildInitReviewerEntityInventory(session.cfg) llmRuntimes, profileRuntimeNames := buildInitLLMRuntimeInventory(session.cfg) workspace := initWorkspaceDraft{ - path: session.path, - cfg: cloneInitConfigFile(session.cfg), - profileName: profileName, - profile: profile, - gitScopeName: profileGitScopeNames[profileName], - gitScopes: gitScopes, - reviewerEntityName: profileReviewerEntityNames[profileName], - reviewerEntities: reviewerEntities, - llmRuntimeName: profileRuntimeNames[profileName], - llmRuntimes: llmRuntimes, - writes: map[string]map[string]string{}, - overwriteRefs: map[string]bool{}, - satisfiedRefs: map[string]bool{}, - backendFlagSet: session.backendFlagSet, - backendArg: interactiveInitBackendArg(&root.Options{}, session.backendFlagSet, session.cfg), - allowDeferredLLM: profile.LLM.Auth == config.LLMAuthAPIKey, - writeLLMHint: profile.LLM.Auth == config.LLMAuthAPIKey, + path: session.path, + cfg: cloneInitConfigFile(session.cfg), + profileName: profileName, + profile: profile, + gitScopeName: profileGitScopeNames[profileName], + gitScopes: gitScopes, + reviewerEntityName: profileReviewerEntityNames[profileName], + reviewerEntities: reviewerEntities, + llmRuntimeName: profileRuntimeNames[profileName], + llmRuntimes: llmRuntimes, + writes: map[string]map[string]string{}, + credentialDecisions: map[initCredentialDecisionKey]initCredentialDecisionKind{}, + overwriteRefs: map[string]bool{}, + satisfiedRefs: map[string]bool{}, + backendFlagSet: session.backendFlagSet, + backendArg: interactiveInitBackendArg(&root.Options{}, session.backendFlagSet, session.cfg), + allowDeferredLLM: profile.LLM.Auth == config.LLMAuthAPIKey, + writeLLMHint: profile.LLM.Auth == config.LLMAuthAPIKey, } session.workspace = &workspace session.requestedProfileName = profileName @@ -5539,6 +5582,47 @@ func initCredentialStoreKey(resolved credentials.ResolvedSecretsProfile) string return fmt.Sprintf("%s|%s|%s", resolved.ID, resolved.Source, resolved.Backend) } +func initCredentialDecisionMapKey(entry initCredentialPlanEntry, key string) initCredentialDecisionKey { + return initCredentialDecisionKey{ + Store: initCredentialStoreKey(entry.SecretsProfile), + Purpose: entry.Ref.Purpose, + Mode: entry.Ref.Mode, + Ref: entry.Ref.Ref, + Key: key, + } +} + +func recordInitCredentialEntryDecision(decisions map[initCredentialDecisionKey]initCredentialDecisionKind, entry initCredentialPlanEntry, kind initCredentialDecisionKind) { + for _, spec := range entry.KeySpecs { + if kind == initCredentialDecisionSkipOptional && spec.Required { + continue + } + if kind == initCredentialDecisionDefer && !spec.Required { + continue + } + decisions[initCredentialDecisionMapKey(entry, spec.Key)] = kind + } +} + +func clearInitCredentialEntryDecisions(decisions map[initCredentialDecisionKey]initCredentialDecisionKind, entry initCredentialPlanEntry) { + for _, spec := range entry.KeySpecs { + delete(decisions, initCredentialDecisionMapKey(entry, spec.Key)) + } +} + +func initCredentialEntryDeferredByDecision(decisions map[initCredentialDecisionKey]initCredentialDecisionKind, entry initCredentialPlanEntry) bool { + required := initCredentialRequiredKeys(entry) + if len(required) == 0 { + return false + } + for _, key := range required { + if decisions[initCredentialDecisionMapKey(entry, key)] != initCredentialDecisionDefer { + return false + } + } + return true +} + type initWriteGroup struct { Resolved credentials.ResolvedSecretsProfile Writes map[string]map[string]string @@ -5644,6 +5728,9 @@ func collectInteractiveInitSecrets(_ *cobra.Command, opts *root.Options, deps in if plan.satisfiedRefs == nil { plan.satisfiedRefs = map[string]bool{} } + if plan.credentialDecisions == nil { + plan.credentialDecisions = map[initCredentialDecisionKey]initCredentialDecisionKind{} + } for _, entry := range plan.credentialPlan { switch entry.State { @@ -5651,6 +5738,9 @@ func collectInteractiveInitSecrets(_ *cobra.Command, opts *root.Options, deps in continue case initCredentialPlanStateDefer, initCredentialPlanStateMissingRequired, initCredentialPlanStateOverwriteRef: } + if initCredentialEntryDeferredByDecision(plan.credentialDecisions, entry) { + continue + } credentialChoices: for { action, err := prompter.ChooseCredentialAction(initCredentialSecretPrompt{ @@ -5664,6 +5754,7 @@ func collectInteractiveInitSecrets(_ *cobra.Command, opts *root.Options, deps in return initWorkspaceDraft{}, errInitNavigateBack } if action == initCredentialSecretActionDefer { + recordInitCredentialEntryDecision(plan.credentialDecisions, entry, initCredentialDecisionDefer) break } activeStore, err := openStore(entry) @@ -5701,10 +5792,12 @@ func collectInteractiveInitSecrets(_ *cobra.Command, opts *root.Options, deps in if !targetHasRequired { return initWorkspaceDraft{}, exitcode.Usage(fmt.Errorf("%s credential ref %q does not have all required keys", initCredentialPurposeLabel(entry.Ref.Purpose), entry.Ref.Ref)) } + clearInitCredentialEntryDecisions(plan.credentialDecisions, entry) plan.satisfiedRefs[entry.Ref.Ref] = true break } if action == initCredentialSecretActionDefer { + recordInitCredentialEntryDecision(plan.credentialDecisions, entry, initCredentialDecisionDefer) break } if action != initCredentialSecretActionSetNow { @@ -5715,6 +5808,8 @@ func collectInteractiveInitSecrets(_ *cobra.Command, opts *root.Options, deps in entryWrites := map[string]map[string]string{ entry.Ref.Ref: copyStringMap(plan.writes[entry.Ref.Ref]), } + entryDecisions := cloneInitCredentialDecisions(plan.credentialDecisions) + clearInitCredentialEntryDecisions(entryDecisions, entry) for _, spec := range entry.KeySpecs { sourceChoices: for { @@ -5741,6 +5836,7 @@ func collectInteractiveInitSecrets(_ *cobra.Command, opts *root.Options, deps in if spec.Required { return initWorkspaceDraft{}, exitcode.Usage(fmt.Errorf("%s credential key %q is required", initCredentialPurposeLabel(entry.Ref.Purpose), spec.Key)) } + entryDecisions[initCredentialDecisionMapKey(entry, spec.Key)] = initCredentialDecisionSkipOptional case initSecretSourceClipboard: value, err := deps.clipboardRead() if err != nil { @@ -5790,6 +5886,7 @@ func collectInteractiveInitSecrets(_ *cobra.Command, opts *root.Options, deps in } else { plan.writes[entry.Ref.Ref] = planned } + plan.credentialDecisions = entryDecisions if overwriteRef { plan.overwriteRefs[entry.Ref.Ref] = true } @@ -5819,14 +5916,15 @@ func collectInteractiveInitSessionSecrets(opts *root.Options, deps initDeps, pla return initSessionPlan{}, cmderr.Credential(err) } workspacePlan := initWorkspaceDraft{ - cfg: plan.cfg, - writes: plan.writes, - credentialPlan: plan.credentialPlan, - overwriteRefs: plan.overwriteRefs, - satisfiedRefs: plan.satisfiedRefs, - backendFlagSet: plan.backendFlagSet, - backendArg: plan.backendArg, - allowDeferredLLM: true, + cfg: plan.cfg, + writes: plan.writes, + credentialPlan: plan.credentialPlan, + credentialDecisions: plan.credentialDecisions, + overwriteRefs: plan.overwriteRefs, + satisfiedRefs: plan.satisfiedRefs, + backendFlagSet: plan.backendFlagSet, + backendArg: plan.backendArg, + allowDeferredLLM: true, } workspacePlan, err = collectInteractiveInitSecrets(nil, opts, deps, workspacePlan) if err != nil { @@ -5834,6 +5932,7 @@ func collectInteractiveInitSessionSecrets(opts *root.Options, deps initDeps, pla } plan.writes = workspacePlan.writes plan.credentialPlan = workspacePlan.credentialPlan + plan.credentialDecisions = workspacePlan.credentialDecisions plan.overwriteRefs = workspacePlan.overwriteRefs plan.satisfiedRefs = workspacePlan.satisfiedRefs return plan, nil @@ -6000,6 +6099,17 @@ func cloneInitBoolMap(values map[string]bool) map[string]bool { return cloned } +func cloneInitCredentialDecisions(values map[initCredentialDecisionKey]initCredentialDecisionKind) map[initCredentialDecisionKey]initCredentialDecisionKind { + if len(values) == 0 { + return map[initCredentialDecisionKey]initCredentialDecisionKind{} + } + cloned := make(map[initCredentialDecisionKey]initCredentialDecisionKind, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + func filterInitWritesByRefs(writes map[string]map[string]string, activeRefs map[string]bool) map[string]map[string]string { filtered := map[string]map[string]string{} for ref, values := range writes { @@ -6011,6 +6121,29 @@ func filterInitWritesByRefs(writes map[string]map[string]string, activeRefs map[ return filtered } +func filterInitCredentialDecisions(values map[initCredentialDecisionKey]initCredentialDecisionKind, entries []initCredentialPlanEntry) map[initCredentialDecisionKey]initCredentialDecisionKind { + if len(values) == 0 { + return map[initCredentialDecisionKey]initCredentialDecisionKind{} + } + active := map[initCredentialDecisionKey]bool{} + for _, entry := range entries { + if entry.State == initCredentialPlanStateClearRef { + continue + } + for _, spec := range entry.KeySpecs { + active[initCredentialDecisionMapKey(entry, spec.Key)] = true + } + } + filtered := map[initCredentialDecisionKey]initCredentialDecisionKind{} + for key, value := range values { + if !active[key] { + continue + } + filtered[key] = value + } + return filtered +} + func filterInitBoolMapByRefs(values map[string]bool, activeRefs map[string]bool) map[string]bool { filtered := map[string]bool{} for key, value := range values { @@ -6340,6 +6473,23 @@ func initCredentialPurposeLabel(purpose string) string { } } +func initCredentialSecretBundleLabel(entry initCredentialPlanEntry) string { + switch entry.Ref.Purpose { + case "reviewer_credentials": + switch config.GitAuthMode(entry.Ref.Mode) { + case config.GitAuthModeGitHubApp: + return "GitHub App reviewer secrets" + case config.GitAuthModePAT: + return "PAT reviewer secret" + case config.GitAuthModeOAuthDevice: + return "reviewer OAuth credentials" + } + return "reviewer secrets" + default: + return initCredentialPurposeLabel(entry.Ref.Purpose) + " credentials" + } +} + func initSecretsProfilePromptSuffix(resolved credentials.ResolvedSecretsProfile) string { if !resolved.IsNamed() { return "" diff --git a/internal/cmd/credentialcmd/credentialcmd_test.go b/internal/cmd/credentialcmd/credentialcmd_test.go index 305e5d25..142648b7 100644 --- a/internal/cmd/credentialcmd/credentialcmd_test.go +++ b/internal/cmd/credentialcmd/credentialcmd_test.go @@ -13982,6 +13982,73 @@ func TestInitInteractiveMenuFocusedReviewerEntityRebuildsSecretPlanning(t *testi } } +func TestInitCredentialSecretPromptTitleReviewerKeys(t *testing.T) { + githubAppEntry := initCredentialPlanEntry{ + Ref: config.CredentialRef{ + Purpose: "reviewer_credentials", + Ref: "codereview/rianjs-bot", + Mode: string(config.GitAuthModeGitHubApp), + }, + KeySpecs: []credentials.KeySpec{ + {Key: credentials.GitHubAppIDKey, Required: true}, + {Key: credentials.GitHubAppPrivateKeyKey, Required: true}, + {Key: credentials.GitHubAppInstallationIDKey, Required: false}, + }, + } + patEntry := initCredentialPlanEntry{ + Ref: config.CredentialRef{ + Purpose: "reviewer_credentials", + Ref: "codereview/work-reviewer", + Mode: string(config.GitAuthModePAT), + }, + KeySpecs: []credentials.KeySpec{{Key: credentials.GitTokenKey, Required: true}}, + } + + tests := []struct { + name string + got string + want string + }{ + { + name: "github app action title", + got: initCredentialSecretPromptTitle(initCredentialSecretPrompt{ + Entry: githubAppEntry, + }), + want: "How should init handle GitHub App reviewer secrets? (codereview/rianjs-bot) (required: github_app_id, github_app_private_key; optional: github_app_installation_id)", + }, + { + name: "pat action title", + got: initCredentialSecretPromptTitle(initCredentialSecretPrompt{ + Entry: patEntry, + }), + want: "How should init handle PAT reviewer secret? (codereview/work-reviewer)", + }, + { + name: "pat key source title", + got: initSecretSourcePromptTitle(initSecretValuePrompt{ + Entry: patEntry, + Key: credentials.GitTokenKey, + }), + want: "How should init get git_token?", + }, + { + name: "github app private key source title", + got: initSecretSourcePromptTitle(initSecretValuePrompt{ + Entry: githubAppEntry, + Key: credentials.GitHubAppPrivateKeyKey, + }), + want: "How should init get github_app_private_key?", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("title = %q, want %q", tt.got, tt.want) + } + }) + } +} + func TestInitInteractiveMenuFocusedGitHubAppReviewerDeferEmitsReadinessAndHints(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") cfg := config.File{ @@ -14090,6 +14157,550 @@ func TestInitInteractiveMenuFocusedGitHubAppReviewerDeferEmitsReadinessAndHints( } } +func TestInitInteractiveMenuFocusedGitHubAppReviewerSetNowPromptsBeforeCommitAndDoesNotRepeat(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + cfg := config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{ + "work": basicProfile("work"), + }, + } + saveCredentialTestConfig(t, path, cfg) + var stdout bytes.Buffer + var stderr bytes.Buffer + opts := &root.Options{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + ConfigPath: path, + } + menu := &fakeInitMenuPrompter{ + actions: []initMenuAction{ + initMenuActionReviewerEntities, + initMenuActionSave, + }, + } + privateKey := strings.Join([]string{ + "-----BEGIN PRIVATE KEY-----", + "abc123", + "-----END PRIVATE KEY-----", + }, "\n") + var actionMenuPromptCounts []int + secretPrompter := &fakeInitSecretPrompter{ + actions: []initCredentialSecretAction{initCredentialSecretActionSetNow}, + sources: []initSecretSource{ + initSecretSourcePaste, + initSecretSourcePaste, + initSecretSourceSkip, + }, + pastes: []string{"12345", privateKey}, + onAction: func(initCredentialSecretPrompt) { + actionMenuPromptCounts = append(actionMenuPromptCounts, len(menu.prompts)) + }, + } + store := newFakeInitStore(map[string]map[string]string{ + "work": {credentials.GitTokenKey: "existing-token"}, + }) + reviewerPrompterCalls := 0 + deps := initDeps{ + menuPrompter: menu, + finalizePrompter: initFinalizePrompterFunc(func(initFinalizePrompt) (initFinalizeAction, error) { + return initFinalizeActionSave, nil + }), + reviewerPrompter: initReviewerEntityPrompterFunc(func(prompt initReviewerEntityPrompt) (initDraft, error) { + reviewerPrompterCalls++ + if reviewerPrompterCalls > 1 { + return initDraft{}, errInitNavigateBack + } + draft := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) + draft.ReviewerEnabled = true + draft.ReviewerAuth = string(config.GitAuthModeGitHubApp) + draft.ReviewerCredentialRef = "codereview/rianjs-bot" + draft.ReviewerDisplayName = "rianjs-bot" + return draft, nil + }), + secretPrompter: secretPrompter, + openStore: func(string, bool, config.File) (initStore, error) { + return store, nil + }, + configPath: func(*root.Options) (string, error) { return path, nil }, + loadConfig: loadConfigForInit, + saveConfig: config.Save, + } + + if err := runInitWithDeps(&cobra.Command{}, opts, initOptions{}, deps); err != nil { + t.Fatalf("runInitWithDeps: %v", err) + } + if !reflect.DeepEqual(actionMenuPromptCounts, []int{1}) { + t.Fatalf("action prompt menu counts = %#v, want prompt after reviewer menu action and before commit", actionMenuPromptCounts) + } + if len(secretPrompter.actionPrompts) != 1 { + t.Fatalf("action prompts = %d, want reviewer credential prompt once", len(secretPrompter.actionPrompts)) + } + actionPrompt := secretPrompter.actionPrompts[0] + if actionPrompt.Entry.Ref.Purpose != "reviewer_credentials" || actionPrompt.Entry.Ref.Mode != string(config.GitAuthModeGitHubApp) { + t.Fatalf("action prompt ref = %#v, want GitHub App reviewer credentials", actionPrompt.Entry.Ref) + } + gotKeys := make([]string, 0, len(secretPrompter.sourcePrompts)) + for _, prompt := range secretPrompter.sourcePrompts { + gotKeys = append(gotKeys, prompt.Key) + } + wantKeys := []string{credentials.GitHubAppIDKey, credentials.GitHubAppPrivateKeyKey, credentials.GitHubAppInstallationIDKey} + if !reflect.DeepEqual(gotKeys, wantKeys) { + t.Fatalf("source prompt keys = %#v, want %#v", gotKeys, wantKeys) + } + if got := store.bundles["rianjs-bot"][credentials.GitHubAppIDKey]; got != "12345" { + t.Fatalf("stored app id = %q, want staged value written at commit", got) + } + if got := store.bundles["rianjs-bot"][credentials.GitHubAppPrivateKeyKey]; got != privateKey { + t.Fatalf("stored private key = %q, want multi-line private key", got) + } + if _, ok := store.bundles["rianjs-bot"][credentials.GitHubAppInstallationIDKey]; ok { + t.Fatalf("installation id stored unexpectedly: %#v", store.bundles["rianjs-bot"]) + } + if strings.Contains(stderr.String(), "set-credential --ref codereview/rianjs-bot") { + t.Fatalf("stderr = %q, want no follow-up hints after set-now reviewer flow", stderr.String()) + } + if !strings.Contains(stdout.String(), "- work: ready") { + t.Fatalf("stdout = %q, want ready profile after set-now reviewer flow", stdout.String()) + } +} + +func TestInitInteractiveMenuFocusedPATReviewerPromptsForGitTokenOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + saveCredentialTestConfig(t, path, config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{"work": basicProfile("work")}, + }) + var stdout bytes.Buffer + var stderr bytes.Buffer + opts := &root.Options{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + ConfigPath: path, + } + secretPrompter := &fakeInitSecretPrompter{ + actions: []initCredentialSecretAction{initCredentialSecretActionSetNow}, + sources: []initSecretSource{initSecretSourcePaste}, + pastes: []string{"reviewer-pat"}, + } + store := newFakeInitStore(map[string]map[string]string{ + "work": {credentials.GitTokenKey: "existing-token"}, + }) + deps := initDeps{ + menuPrompter: &fakeInitMenuPrompter{ + actions: []initMenuAction{ + initMenuActionReviewerEntities, + initMenuActionSave, + }, + }, + finalizePrompter: initFinalizePrompterFunc(func(initFinalizePrompt) (initFinalizeAction, error) { + return initFinalizeActionSave, nil + }), + reviewerPrompter: initReviewerEntityPrompterFunc(func(prompt initReviewerEntityPrompt) (initDraft, error) { + draft := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) + draft.ReviewerEnabled = true + draft.ReviewerAuth = string(config.GitAuthModePAT) + draft.ReviewerCredentialRef = "codereview/work-reviewer" + return draft, nil + }), + secretPrompter: secretPrompter, + openStore: func(string, bool, config.File) (initStore, error) { + return store, nil + }, + configPath: func(*root.Options) (string, error) { return path, nil }, + loadConfig: loadConfigForInit, + saveConfig: config.Save, + } + + if err := runInitWithDeps(&cobra.Command{}, opts, initOptions{}, deps); err != nil { + t.Fatalf("runInitWithDeps: %v", err) + } + if len(secretPrompter.actionPrompts) != 1 { + t.Fatalf("action prompts = %d, want one PAT reviewer prompt", len(secretPrompter.actionPrompts)) + } + prompt := secretPrompter.actionPrompts[0] + if prompt.Entry.Ref.Purpose != "reviewer_credentials" || prompt.Entry.Ref.Mode != string(config.GitAuthModePAT) { + t.Fatalf("prompt ref = %#v, want PAT reviewer", prompt.Entry.Ref) + } + if len(secretPrompter.sourcePrompts) != 1 { + t.Fatalf("source prompts = %d, want exactly one reviewer PAT key prompt", len(secretPrompter.sourcePrompts)) + } + sourcePrompt := secretPrompter.sourcePrompts[0] + if sourcePrompt.Key != credentials.GitTokenKey || sourcePrompt.Optional { + t.Fatalf("source prompt = %#v, want required reviewer git_token", sourcePrompt) + } + if got := initSecretSourcePromptTitle(sourcePrompt); got != "How should init get git_token?" { + t.Fatalf("source prompt title = %q, want reviewer git_token prompt", got) + } + if got := store.bundles["work-reviewer"][credentials.GitTokenKey]; got != "reviewer-pat" { + t.Fatalf("stored reviewer PAT = %q, want staged value written at commit", got) + } + if strings.Contains(stderr.String(), credentials.GitHubAppIDKey) || strings.Contains(stderr.String(), credentials.GitHubAppPrivateKeyKey) { + t.Fatalf("stderr = %q, want PAT reviewer flow not GitHub App keys", stderr.String()) + } +} + +func TestInitInteractiveMenuFocusedUseGitReviewerSkipsReviewerCredentialPrompt(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + saveCredentialTestConfig(t, path, config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{"work": basicProfile("work")}, + }) + opts := &root.Options{ + Stdin: strings.NewReader(""), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + ConfigPath: path, + } + deps := initDeps{ + menuPrompter: &fakeInitMenuPrompter{ + actions: []initMenuAction{ + initMenuActionReviewerEntities, + initMenuActionSave, + }, + }, + finalizePrompter: initFinalizePrompterFunc(func(initFinalizePrompt) (initFinalizeAction, error) { + return initFinalizeActionSave, nil + }), + reviewerPrompter: initReviewerEntityPrompterFunc(func(prompt initReviewerEntityPrompt) (initDraft, error) { + draft := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) + draft.ReviewerEnabled = false + return draft, nil + }), + secretPrompter: &fakeInitSecretPrompter{}, + openStore: func(string, bool, config.File) (initStore, error) { + return newFakeInitStore(map[string]map[string]string{ + "work": {credentials.GitTokenKey: "existing-token"}, + }), nil + }, + configPath: func(*root.Options) (string, error) { return path, nil }, + loadConfig: loadConfigForInit, + saveConfig: config.Save, + } + + if err := runInitWithDeps(&cobra.Command{}, opts, initOptions{}, deps); err != nil { + t.Fatalf("runInitWithDeps: %v", err) + } +} + +func TestInitInteractiveMenuReviewerCredentialDecisionDropsAfterReviewerCleared(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + saveCredentialTestConfig(t, path, config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{"work": basicProfile("work")}, + }) + var stdout bytes.Buffer + var stderr bytes.Buffer + opts := &root.Options{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + ConfigPath: path, + } + reviewerPrompterCalls := 0 + secretPrompter := &fakeInitSecretPrompter{ + actions: []initCredentialSecretAction{initCredentialSecretActionDefer}, + } + deps := initDeps{ + menuPrompter: &fakeInitMenuPrompter{ + actions: []initMenuAction{ + initMenuActionReviewerEntities, + initMenuActionReviewerEntities, + initMenuActionSave, + }, + }, + finalizePrompter: initFinalizePrompterFunc(func(initFinalizePrompt) (initFinalizeAction, error) { + return initFinalizeActionSave, nil + }), + reviewerPrompter: initReviewerEntityPrompterFunc(func(prompt initReviewerEntityPrompt) (initDraft, error) { + reviewerPrompterCalls++ + draft := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) + switch reviewerPrompterCalls { + case 1: + draft.ReviewerEnabled = true + draft.ReviewerAuth = string(config.GitAuthModeGitHubApp) + draft.ReviewerCredentialRef = "codereview/rianjs-bot" + case 2: + draft.ReviewerEnabled = false + default: + return initDraft{}, errInitNavigateBack + } + return draft, nil + }), + secretPrompter: secretPrompter, + openStore: func(string, bool, config.File) (initStore, error) { + return newFakeInitStore(map[string]map[string]string{ + "work": {credentials.GitTokenKey: "existing-token"}, + }), nil + }, + configPath: func(*root.Options) (string, error) { return path, nil }, + loadConfig: loadConfigForInit, + saveConfig: config.Save, + } + + if err := runInitWithDeps(&cobra.Command{}, opts, initOptions{}, deps); err != nil { + t.Fatalf("runInitWithDeps: %v", err) + } + if len(secretPrompter.actionPrompts) != 1 { + t.Fatalf("action prompts = %d, want only initial deferred reviewer prompt", len(secretPrompter.actionPrompts)) + } + if strings.Contains(stdout.String(), "reviewer deferred") || strings.Contains(stderr.String(), "rianjs-bot") { + t.Fatalf("stdout/stderr kept stale reviewer decision:\nstdout=%s\nstderr=%s", stdout.String(), stderr.String()) + } + got, err := config.Load(path) + if err != nil { + t.Fatalf("Load config: %v", err) + } + if got.Profiles["work"].ReviewerCredentials != nil { + t.Fatalf("reviewer credentials = %#v, want cleared reviewer", got.Profiles["work"].ReviewerCredentials) + } +} + +func TestInitInteractiveMenuReviewerCredentialDecisionDropsAfterReviewerRefChanges(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + saveCredentialTestConfig(t, path, config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{"work": basicProfile("work")}, + }) + var stdout bytes.Buffer + var stderr bytes.Buffer + opts := &root.Options{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &stderr, + ConfigPath: path, + } + reviewerPrompterCalls := 0 + secretPrompter := &fakeInitSecretPrompter{ + actions: []initCredentialSecretAction{ + initCredentialSecretActionDefer, + initCredentialSecretActionDefer, + }, + } + deps := initDeps{ + menuPrompter: &fakeInitMenuPrompter{ + actions: []initMenuAction{ + initMenuActionReviewerEntities, + initMenuActionReviewerEntities, + initMenuActionSave, + }, + }, + finalizePrompter: initFinalizePrompterFunc(func(initFinalizePrompt) (initFinalizeAction, error) { + return initFinalizeActionSave, nil + }), + reviewerPrompter: initReviewerEntityPrompterFunc(func(prompt initReviewerEntityPrompt) (initDraft, error) { + reviewerPrompterCalls++ + draft := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) + draft.ReviewerEnabled = true + draft.ReviewerAuth = string(config.GitAuthModeGitHubApp) + switch reviewerPrompterCalls { + case 1: + draft.ReviewerCredentialRef = "codereview/old-reviewer" + case 2: + draft.ReviewerCredentialRef = "codereview/new-reviewer" + default: + return initDraft{}, errInitNavigateBack + } + return draft, nil + }), + secretPrompter: secretPrompter, + openStore: func(string, bool, config.File) (initStore, error) { + return newFakeInitStore(map[string]map[string]string{ + "work": {credentials.GitTokenKey: "existing-token"}, + }), nil + }, + configPath: func(*root.Options) (string, error) { return path, nil }, + loadConfig: loadConfigForInit, + saveConfig: config.Save, + } + + if err := runInitWithDeps(&cobra.Command{}, opts, initOptions{}, deps); err != nil { + t.Fatalf("runInitWithDeps: %v", err) + } + if len(secretPrompter.actionPrompts) != 2 { + t.Fatalf("action prompts = %d, want old and new reviewer prompts", len(secretPrompter.actionPrompts)) + } + out := stdout.String() + "\n" + stderr.String() + if strings.Contains(out, "old-reviewer") { + t.Fatalf("output kept stale reviewer ref:\n%s", out) + } + if !strings.Contains(out, "codereview/new-reviewer") { + t.Fatalf("output = %q, want follow-up hint for new reviewer ref", out) + } + got, err := config.Load(path) + if err != nil { + t.Fatalf("Load config: %v", err) + } + if got.Profiles["work"].ReviewerCredentials == nil || got.Profiles["work"].ReviewerCredentials.CredentialRef != "codereview/new-reviewer" { + t.Fatalf("reviewer credentials = %#v, want new reviewer ref", got.Profiles["work"].ReviewerCredentials) + } +} + +func TestInitInteractiveMenuReviewerSetNowDiscardDoesNotWriteConfigOrCredentials(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + cfg := config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{"work": basicProfile("work")}, + } + saveCredentialTestConfig(t, path, cfg) + opts := &root.Options{ + Stdin: strings.NewReader(""), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + ConfigPath: path, + } + store := newFakeInitStore(map[string]map[string]string{ + "work": {credentials.GitTokenKey: "existing-token"}, + }) + store.setBundleFunc = func(string, map[string]string, ...credstore.SetOpt) (credstore.Result, error) { + t.Fatal("SetBundle called before commit/discard") + return credstore.Result{}, nil + } + deps := initDeps{ + menuPrompter: &fakeInitMenuPrompter{ + actions: []initMenuAction{ + initMenuActionReviewerEntities, + initMenuActionExit, + }, + }, + reviewerPrompter: initReviewerEntityPrompterFunc(func(prompt initReviewerEntityPrompt) (initDraft, error) { + draft := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) + draft.ReviewerEnabled = true + draft.ReviewerAuth = string(config.GitAuthModeGitHubApp) + draft.ReviewerCredentialRef = "codereview/rianjs-bot" + return draft, nil + }), + secretPrompter: &fakeInitSecretPrompter{ + actions: []initCredentialSecretAction{initCredentialSecretActionSetNow}, + sources: []initSecretSource{ + initSecretSourcePaste, + initSecretSourcePaste, + initSecretSourceSkip, + }, + pastes: []string{"sentinel-app-id", "sentinel-private-key"}, + }, + openStore: func(string, bool, config.File) (initStore, error) { + return store, nil + }, + configPath: func(*root.Options) (string, error) { return path, nil }, + loadConfig: loadConfigForInit, + saveConfig: func(string, config.File) error { + t.Fatal("saveConfig called on discard") + return nil + }, + } + + if err := runInitWithDeps(&cobra.Command{}, opts, initOptions{}, deps); err != nil { + t.Fatalf("runInitWithDeps: %v", err) + } + got, err := config.Load(path) + if err != nil { + t.Fatalf("Load config: %v", err) + } + if got.Profiles["work"].ReviewerCredentials != nil { + t.Fatalf("reviewer credentials after discard = %#v, want original nil reviewer", got.Profiles["work"].ReviewerCredentials) + } + // #nosec G304 -- path is a test-controlled temporary config file. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile config: %v", err) + } + if strings.Contains(string(raw), "rianjs-bot") || strings.Contains(string(raw), "sentinel") { + t.Fatalf("config after discard contains staged reviewer data:\n%s", string(raw)) + } + if _, ok := store.bundles["rianjs-bot"]; ok { + t.Fatalf("reviewer bundle = %#v, want no credential write on discard", store.bundles["rianjs-bot"]) + } +} + +func TestInitInteractiveMenuReviewerSetNowBackDuringSecretStepDiscardsPartialSecretWrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + cfg := config.File{ + DefaultProfile: "work", + Profiles: map[string]config.Profile{"work": basicProfile("work")}, + } + saveCredentialTestConfig(t, path, cfg) + var stdout bytes.Buffer + opts := &root.Options{ + Stdin: strings.NewReader(""), + Stdout: &stdout, + Stderr: &bytes.Buffer{}, + ConfigPath: path, + } + store := newFakeInitStore(map[string]map[string]string{ + "work": {credentials.GitTokenKey: "existing-token"}, + }) + store.setBundleFunc = func(string, map[string]string, ...credstore.SetOpt) (credstore.Result, error) { + t.Fatal("SetBundle called after backing out of reviewer secret capture") + return credstore.Result{}, nil + } + deps := initDeps{ + menuPrompter: &fakeInitMenuPrompter{ + actions: []initMenuAction{ + initMenuActionReviewerEntities, + initMenuActionSave, + }, + }, + finalizePrompter: initFinalizePrompterFunc(func(initFinalizePrompt) (initFinalizeAction, error) { + return initFinalizeActionSave, nil + }), + reviewerPrompter: initReviewerEntityPrompterFunc(func(prompt initReviewerEntityPrompt) (initDraft, error) { + draft := seedInteractiveInitDraft(prompt.Context.RequestedProfileName, prompt.Context.ExistingProfileName, prompt.Context.DefaultProfileName, prompt.Context.ExistingProfile) + draft.ReviewerEnabled = true + draft.ReviewerAuth = string(config.GitAuthModeGitHubApp) + draft.ReviewerCredentialRef = "codereview/rianjs-bot" + return draft, nil + }), + secretPrompter: &fakeInitSecretPrompter{ + actions: []initCredentialSecretAction{ + initCredentialSecretActionSetNow, + initCredentialSecretActionBack, + initCredentialSecretActionDefer, + }, + sources: []initSecretSource{ + initSecretSourcePaste, + initSecretSourcePaste, + initSecretSourceBack, + }, + pastes: []string{"sentinel-app-id", "sentinel-private-key"}, + }, + openStore: func(string, bool, config.File) (initStore, error) { + return store, nil + }, + configPath: func(*root.Options) (string, error) { return path, nil }, + loadConfig: loadConfigForInit, + saveConfig: config.Save, + } + + if err := runInitWithDeps(&cobra.Command{}, opts, initOptions{}, deps); err != nil { + t.Fatalf("runInitWithDeps: %v", err) + } + got, err := config.Load(path) + if err != nil { + t.Fatalf("Load config: %v", err) + } + reviewer := got.Profiles["work"].ReviewerCredentials + if reviewer == nil || reviewer.CredentialRef != "codereview/rianjs-bot" { + t.Fatalf("reviewer credentials after secret-step back = %#v, want staged reviewer saved after final defer", reviewer) + } + // #nosec G304 -- path is a test-controlled temporary config file. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile config: %v", err) + } + if strings.Contains(string(raw), "sentinel") { + t.Fatalf("config after secret-step back contains partial secret values:\n%s", string(raw)) + } + if _, ok := store.bundles["rianjs-bot"]; ok { + t.Fatalf("reviewer bundle = %#v, want no credential write after secret-step back", store.bundles["rianjs-bot"]) + } + if !strings.Contains(stdout.String(), "reviewer deferred") { + t.Fatalf("stdout = %q, want final defer hint after backing out of focused secret step", stdout.String()) + } +} + func TestInitInteractiveMenuFocusedReviewerEntitySavePreservesCustomCredentialRef(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") cfg := config.File{ @@ -17489,13 +18100,21 @@ func TestWriteBundlesReportsPreviouslyWrittenRefsForCleanup(t *testing.T) { } type fakeInitSecretPrompter struct { - actions []initCredentialSecretAction - sources []initSecretSource - pastes []string - pasteErrors []error + actions []initCredentialSecretAction + sources []initSecretSource + pastes []string + pasteErrors []error + onAction func(initCredentialSecretPrompt) + actionPrompts []initCredentialSecretPrompt + sourcePrompts []initSecretValuePrompt + pastePrompts []initSecretValuePrompt } -func (f *fakeInitSecretPrompter) ChooseCredentialAction(initCredentialSecretPrompt) (initCredentialSecretAction, error) { +func (f *fakeInitSecretPrompter) ChooseCredentialAction(prompt initCredentialSecretPrompt) (initCredentialSecretAction, error) { + f.actionPrompts = append(f.actionPrompts, prompt) + if f.onAction != nil { + f.onAction(prompt) + } if len(f.actions) == 0 { return "", errors.New("unexpected credential action prompt") } @@ -17504,7 +18123,8 @@ func (f *fakeInitSecretPrompter) ChooseCredentialAction(initCredentialSecretProm return action, nil } -func (f *fakeInitSecretPrompter) ChooseSecretSource(initSecretValuePrompt) (initSecretSource, error) { +func (f *fakeInitSecretPrompter) ChooseSecretSource(prompt initSecretValuePrompt) (initSecretSource, error) { + f.sourcePrompts = append(f.sourcePrompts, prompt) if len(f.sources) == 0 { return "", errors.New("unexpected secret source prompt") } @@ -17513,7 +18133,8 @@ func (f *fakeInitSecretPrompter) ChooseSecretSource(initSecretValuePrompt) (init return source, nil } -func (f *fakeInitSecretPrompter) PasteSecret(initSecretValuePrompt) (string, error) { +func (f *fakeInitSecretPrompter) PasteSecret(prompt initSecretValuePrompt) (string, error) { + f.pastePrompts = append(f.pastePrompts, prompt) if len(f.pasteErrors) > 0 { err := f.pasteErrors[0] f.pasteErrors = f.pasteErrors[1:]