Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions cmd/dotenv/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,19 +143,39 @@ This wizard will help you:

showAllPrompts, _ := cmd.Flags().GetBool("all")

needsPulumi, pulumiLoggedIn, needsPassphrase := CheckPulumiSetup(repositoryRoot, variables)

m := Model{
initialScreen: wizardScreen,
DotenvFile: dotenvFile,
variables: variables,
contents: contents,
SuccessCmd: tea.Quit,
ShowAllPrompts: showAllPrompts,
initialScreen: wizardScreen,
DotenvFile: dotenvFile,
variables: variables,
contents: contents,
SuccessCmd: tea.Quit,
ShowAllPrompts: showAllPrompts,
NeedsPulumiLogin: needsPulumi,
PulumiAlreadyLoggedIn: pulumiLoggedIn,
NeedsPulumiPassphrase: needsPassphrase,
}
_, err = tui.Run(m, tea.WithAltScreen(), tea.WithContext(cmd.Context()))

finalModel, err := tui.Run(m, tea.WithAltScreen(), tea.WithContext(cmd.Context()))
if err != nil {
return err
}

// Check if the model has an error (e.g., from Pulumi login failure)
// The model is wrapped in InterruptibleModel, so we need to unwrap it
if finalM, ok := finalModel.(tui.InterruptibleModel); ok {
if m, ok := finalM.Model.(Model); ok {
if m.err != nil {
return m.err
}

if m.pulumiModel != nil && m.pulumiModel.err != nil {
return m.pulumiModel.err
}
}
Comment thread
ajalon1 marked this conversation as resolved.
}

// Update state after successful completion
_ = state.UpdateAfterDotenvSetup(repositoryRoot)

Expand Down
30 changes: 30 additions & 0 deletions cmd/dotenv/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright 2025 DataRobot, Inc. and its affiliates.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package dotenv

import (
"os"
"testing"
)

func TestMain(m *testing.M) {
// Clear environment variables that might leak from the host environment
// into tests, ensuring tests have a clean isolated environment.
os.Unsetenv("DATAROBOT_ENDPOINT")
os.Unsetenv("DATAROBOT_API_TOKEN")
os.Unsetenv("PULUMI_CONFIG_PASSPHRASE")

os.Exit(m.Run())
}
87 changes: 70 additions & 17 deletions cmd/dotenv/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,25 +45,30 @@ const (
listScreen = screens(iota)
editorScreen
wizardScreen
pulumiScreen
)

type Model struct {
screen screens
initialScreen screens
DotenvFile string
variables []envbuilder.Variable
err error
textarea textarea.Model
contents string
width int
height int
SuccessCmd tea.Cmd
prompts []envbuilder.UserPrompt
currentPromptIndex int
currentPrompt promptModel
hasPrompts *bool // Cache whether prompts are available
ShowAllPrompts bool // When true, show all prompts regardless of defaults
skippedPrompts int // Count of prompts skipped due to having defaults
screen screens
initialScreen screens
DotenvFile string
variables []envbuilder.Variable
err error
textarea textarea.Model
contents string
width int
height int
SuccessCmd tea.Cmd
prompts []envbuilder.UserPrompt
currentPromptIndex int
currentPrompt promptModel
hasPrompts *bool // Cache whether prompts are available
ShowAllPrompts bool // When true, show all prompts regardless of defaults
skippedPrompts int // Count of prompts skipped due to having defaults
pulumiModel *pulumiLoginModel // Sub-model for Pulumi login flow, shown before wizard if needed
NeedsPulumiLogin bool // Set by callers before Init(); true when login or passphrase setup is needed
PulumiAlreadyLoggedIn bool // Set by callers; when true, Pulumi screen skips backend selection
NeedsPulumiPassphrase bool // Set by callers; when true, passphrase prompt is shown after login
}

type (
Expand Down Expand Up @@ -160,7 +165,14 @@ func (m Model) loadPrompts() tea.Cmd {
return func() tea.Msg {
currentDir := filepath.Dir(m.DotenvFile)

userPrompts, err := envbuilder.GatherUserPrompts(currentDir, m.variables)
variables := m.variables
if len(variables) == 0 {
// Read from .env file (falls back to default template when file doesn't exist)
// so that promptsWithValues can apply defaults and env var values correctly.
variables, _, _ = readDotenvFileVariables(m.DotenvFile)
}

userPrompts, err := envbuilder.GatherUserPrompts(currentDir, variables)
if err != nil {
return errMsg{err}
}
Expand Down Expand Up @@ -245,7 +257,32 @@ func (m Model) Init() tea.Cmd {
return tea.Batch(m.loadVariables(), tea.WindowSize())
}

func (m Model) handlePulumiUpdate(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case pulumiLoginCompleteMsg:
// Pulumi setup finished — reload prompts so the newly saved passphrase
// is picked up before the wizard starts.
m.pulumiModel = nil
m.NeedsPulumiLogin = false

return m, m.loadPrompts()
Comment thread
cursor[bot] marked this conversation as resolved.
}

subModel, cmd := m.pulumiModel.Update(msg)

if plm, ok := subModel.(pulumiLoginModel); ok {
m.pulumiModel = &plm
}

return m, cmd
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint: cyclop
// If Pulumi login sub-model is active, delegate to it
if m.pulumiModel != nil {
return m.handlePulumiUpdate(msg)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WindowSizeMsg lost during Pulumi login flow

Low Severity

When pulumiModel is active, all messages including tea.WindowSizeMsg are delegated to handlePulumiUpdate, which doesn't update the parent Model's width and height. If the terminal is resized during the Pulumi login flow, the parent model retains stale dimensions, causing potential layout issues when the wizard screen renders afterward.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems like a valid issue @carsongee


switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
Expand Down Expand Up @@ -281,9 +318,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint: cyclop

if len(m.prompts) == 0 {
m.screen = listScreen

return m, nil
}

// Check if Pulumi login/passphrase setup is needed before the wizard
if m.NeedsPulumiLogin {
plm := newPulumiLoginModel(m.PulumiAlreadyLoggedIn, m.NeedsPulumiPassphrase)
m.pulumiModel = &plm
m.screen = pulumiScreen

return m, plm.Init()
}

return m.moveToNextPrompt()
case openEditorMsg:
m.screen = editorScreen
Expand All @@ -307,6 +354,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint: cyclop
}

switch m.screen {
case pulumiScreen:
// Handled above via m.pulumiModel delegation
case listScreen:
switch msg := msg.(type) {
case tea.KeyMsg:
Expand Down Expand Up @@ -385,6 +434,10 @@ func (m Model) View() string {
var sb strings.Builder

switch m.screen {
case pulumiScreen:
if m.pulumiModel != nil {
sb.WriteString(m.pulumiModel.View())
}
case listScreen:
sb.WriteString(m.viewListScreen())
case editorScreen:
Expand Down
8 changes: 8 additions & 0 deletions cmd/dotenv/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ type DotenvModelTestSuite struct {
}

func (suite *DotenvModelTestSuite) SetupTest() {
// Reset viper to prevent user's drconfig.yaml values from leaking into tests.
viper.Reset()
Comment thread
ajalon1 marked this conversation as resolved.

dir, _ := os.MkdirTemp("", "datarobot-config-test")
suite.tempDir = dir

Expand Down Expand Up @@ -171,6 +174,7 @@ func (suite *DotenvModelTestSuite) FinalModel(tm *teatest.TestModel) Model {
func (suite *DotenvModelTestSuite) TestDotenvModel_Happy_Path() {
tm := suite.NewTestModel(Model{
screen: wizardScreen,
initialScreen: wizardScreen,
DotenvFile: filepath.Join(suite.tempDir, ".env"),
ShowAllPrompts: true,
})
Expand Down Expand Up @@ -217,6 +221,7 @@ func (suite *DotenvModelTestSuite) TestDotenvModel_Happy_Path() {
func (suite *DotenvModelTestSuite) TestDotenvModel_Branching_Path() {
tm := suite.NewTestModel(Model{
screen: wizardScreen,
initialScreen: wizardScreen,
DotenvFile: filepath.Join(suite.tempDir, ".env"),
ShowAllPrompts: true,
})
Expand Down Expand Up @@ -274,6 +279,7 @@ func (suite *DotenvModelTestSuite) TestDotenvModel_Branching_Path() {
func (suite *DotenvModelTestSuite) TestDotenvModel_Both_Path() {
tm := suite.NewTestModel(Model{
screen: wizardScreen,
initialScreen: wizardScreen,
DotenvFile: filepath.Join(suite.tempDir, ".env"),
ShowAllPrompts: true,
})
Expand Down Expand Up @@ -345,6 +351,7 @@ func (suite *DotenvModelTestSuite) Test__loadPromptsFindsEnvValues() {
suite.T().Setenv("PULUMI_CONFIG_PASSPHRASE", "existing_passphrase")
tm := suite.NewTestModel(Model{
screen: wizardScreen,
initialScreen: wizardScreen,
DotenvFile: filepath.Join(suite.tempDir, ".env"),
ShowAllPrompts: true,
})
Expand Down Expand Up @@ -419,6 +426,7 @@ func (suite *DotenvModelTestSuite) TestDotenvModel_SkipsPromptsWithDefaults() {
// The wizard should start directly at DATAROBOT_DEFAULT_USE_CASE
tm := suite.NewTestModel(Model{
screen: wizardScreen,
initialScreen: wizardScreen,
DotenvFile: filepath.Join(suite.tempDir, ".env"),
ShowAllPrompts: false, // Default behavior - skip prompts with defaults
})
Expand Down
Loading
Loading