Skip to content
Draft
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
48 changes: 48 additions & 0 deletions cmd/start/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
"github.com/datarobot/cli/internal/repo"
"github.com/datarobot/cli/internal/state"
"github.com/datarobot/cli/internal/tools"
Expand Down Expand Up @@ -67,6 +68,21 @@ type stepCompleteMsg struct {
needTemplateSetup bool // Whether we need to run template setup
}

// String serializes stepCompleteMsg to a string representation for logging
func (msg stepCompleteMsg) String() string {
return fmt.Sprintf(
"stepCompleteMsg{message: %q, waiting: %t, done: %t, hideMenu: %t, quickstartScriptPath: %q, selfUpdate: %t, executeScript: %t, needTemplateSetup: %t}",
msg.message,
msg.waiting,
msg.done,
msg.hideMenu,
msg.quickstartScriptPath,
msg.selfUpdate,
msg.executeScript,
msg.needTemplateSetup,
)
}

type scriptCompleteMsg struct{}

type stepErrorMsg struct {
Expand Down Expand Up @@ -100,6 +116,8 @@ func NewStartModel(opts Options) Model {
}

func (m Model) Init() tea.Cmd {
log.Info("start: init", "steps", len(m.steps), "answer_yes", m.opts.AnswerYes)

return m.executeCurrentStep()
}

Expand All @@ -109,6 +127,7 @@ func (m Model) executeCurrentStep() tea.Cmd {
}

currentStep := m.currentStep()
log.Info("start: execute step", "index", m.current, "description", currentStep.description)

return func() tea.Msg {
return currentStep.fn(&m)
Expand All @@ -119,7 +138,10 @@ func (m Model) executeNextStep() (Model, tea.Cmd) {
// Check if there are more steps
if m.current >= len(m.steps)-1 {
// No more steps, we're done
log.Info("start: all steps complete", "current", m.current, "steps", len(m.steps))

m.done = true

return m, tea.Quit
}

Expand Down Expand Up @@ -183,10 +205,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleStepComplete(msg)

case stepErrorMsg:
log.Debug("start: step error", "error", msg.err)

m.err = msg.err

return m, tea.Quit

case scriptCompleteMsg:
log.Debug("start: script complete")

// Script execution completed successfully, update state and quit
_ = state.UpdateAfterSuccessfulRun()

Expand All @@ -199,11 +226,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// If there's an error, any key press quits
if m.err != nil {
log.Debug("start: key ignored due to error", "key", msg.String(), "error", m.err)

return m, tea.Quit
}

// If we're waiting for user confirmation to execute the script
if m.waitingToExecute {
log.Debug("start: key while waiting", "key", msg.String(), "self_update", m.selfUpdate, "script", m.quickstartScriptPath)

switch msg.String() {
case "y", "Y", "enter":
// Punch it, Chewie!
Expand Down Expand Up @@ -239,14 +270,29 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Normal key handling when not waiting
switch msg.String() {
case "q", "esc":
log.Info("start: quit requested", "key", msg.String())

m.quitting = true

return m, tea.Quit
}

return m, nil
}

func (m Model) handleStepComplete(msg stepCompleteMsg) (tea.Model, tea.Cmd) {
log.Debug(
"start: step complete",
"message", msg.message,
"waiting", msg.waiting,
"done", msg.done,
"hide_menu", msg.hideMenu,
"self_update", msg.selfUpdate,
"execute_script", msg.executeScript,
"quickstart_script_path", msg.quickstartScriptPath,
"need_template_setup", msg.needTemplateSetup,
)

// Store any message from the completed step
if msg.message != "" {
m.stepCompleteMessage = msg.message
Expand Down Expand Up @@ -407,6 +453,8 @@ func checkRepository(m *Model) tea.Msg {
// Check if we're in a DataRobot repository
// If not, we need to run templates setup
if !repo.IsInRepo() {
pwd, _ := os.Getwd()
log.Info("start: pwd " + pwd + " is not a DataRobot repository")
// Not in a repo, signal that we need to run templates setup and quit
return stepCompleteMsg{
message: "Not in a DataRobot repository. Launching template setup...\n",
Expand Down
82 changes: 82 additions & 0 deletions cmd/start/model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// 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 start

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestStepCompleteMsg_String(t *testing.T) {
tests := []struct {
name string
msg stepCompleteMsg
expected string
}{
{
name: "empty message",
msg: stepCompleteMsg{},
expected: `stepCompleteMsg{message: "", waiting: false, done: false, hideMenu: false, quickstartScriptPath: "", selfUpdate: false, executeScript: false, needTemplateSetup: false}`,
},
{
name: "message with text",
msg: stepCompleteMsg{
message: "Test message",
},
expected: `stepCompleteMsg{message: "Test message", waiting: false, done: false, hideMenu: false, quickstartScriptPath: "", selfUpdate: false, executeScript: false, needTemplateSetup: false}`,
},
{
name: "all boolean flags set",
msg: stepCompleteMsg{
waiting: true,
done: true,
hideMenu: true,
selfUpdate: true,
executeScript: true,
needTemplateSetup: true,
},
expected: `stepCompleteMsg{message: "", waiting: true, done: true, hideMenu: true, quickstartScriptPath: "", selfUpdate: true, executeScript: true, needTemplateSetup: true}`,
},
{
name: "with quickstart script path",
msg: stepCompleteMsg{
quickstartScriptPath: "/path/to/quickstart.sh",
},
expected: `stepCompleteMsg{message: "", waiting: false, done: false, hideMenu: false, quickstartScriptPath: "/path/to/quickstart.sh", selfUpdate: false, executeScript: false, needTemplateSetup: false}`,
},
{
name: "complete example with all fields",
msg: stepCompleteMsg{
message: "Script found",
waiting: true,
done: false,
hideMenu: false,
quickstartScriptPath: "./quickstart.sh",
selfUpdate: false,
executeScript: true,
needTemplateSetup: false,
},
expected: `stepCompleteMsg{message: "Script found", waiting: true, done: false, hideMenu: false, quickstartScriptPath: "./quickstart.sh", selfUpdate: false, executeScript: true, needTemplateSetup: false}`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.msg.String()
assert.Equal(t, tt.expected, result, "String() output should match expected format")
})
}
}
120 changes: 120 additions & 0 deletions cmd/viper_env_order_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// 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 cmd

import (
"os"
"strings"
"testing"

"github.com/spf13/viper"
)

func TestViper_AutomaticEnv_RespectsKeyReplacerSetAfter(t *testing.T) {
v := viper.New()

v.SetEnvPrefix("DATAROBOT_CLI")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))

t.Setenv("DATAROBOT_CLI_SKIP_AUTH", "true")

if !v.GetBool("skip-auth") {
t.Fatalf("expected viper.GetBool(\"skip-auth\") to resolve DATAROBOT_CLI_SKIP_AUTH when SetEnvKeyReplacer is called after AutomaticEnv")
}

if !v.GetBool("skip_auth") {
t.Fatalf("expected viper.GetBool(\"skip_auth\") to resolve DATAROBOT_CLI_SKIP_AUTH when SetEnvKeyReplacer is called after AutomaticEnv")
}
}

func TestViper_AutomaticEnv_RespectsReverseKeyReplacerSetAfter(t *testing.T) {
v := viper.New()

v.SetEnvPrefix("DATAROBOT_CLI")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer("_", "-"))

// This should NOT work: SetEnvKeyReplacer affects how a Viper key is transformed
// into an ENV var name for lookup. It does not transform the OS env var name.
// With '_' -> '-', key "skip_auth" maps to env var DATAROBOT_CLI_SKIP-AUTH.
// But environment variable names with '-' are generally not usable/portable.
t.Setenv("DATAROBOT_CLI_SKIP_AUTH", "true")

if v.GetBool("skip_auth") {
t.Fatalf("expected viper.GetBool(\"skip_auth\") to be false when SetEnvKeyReplacer maps '_' -> '-' because it will look for DATAROBOT_CLI_SKIP-AUTH (not DATAROBOT_CLI_SKIP_AUTH)")
}

if v.GetBool("skip-auth") {
t.Fatalf("expected viper.GetBool(\"skip-auth\") to be false when SetEnvKeyReplacer maps '_' -> '-' because it will look for DATAROBOT_CLI_SKIP-AUTH (not DATAROBOT_CLI_SKIP_AUTH)")
}
}

func TestViper_AutomaticEnv_DoesNotUseReplacerIfNeverSet(t *testing.T) {
v := viper.New()

v.SetEnvPrefix("DATAROBOT_CLI")
v.AutomaticEnv()

t.Setenv("DATAROBOT_CLI_SKIP_AUTH", "true")

if v.GetBool("skip-auth") {
t.Fatalf("expected viper.GetBool(\"skip-auth\") to be false without SetEnvKeyReplacer; replacer is required to map '-' to '_' for env var lookup")
}

if !v.GetBool("skip_auth") {
t.Fatalf("expected viper.GetBool(\"skip_auth\") to resolve DATAROBOT_CLI_SKIP_AUTH without SetEnvKeyReplacer")
}
}

func TestViper_AutomaticEnv_RespectsReplacerSetBefore(t *testing.T) {
v := viper.New()

v.SetEnvPrefix("DATAROBOT_CLI")
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
v.AutomaticEnv()

t.Setenv("DATAROBOT_CLI_SKIP_AUTH", "true")

if !v.GetBool("skip-auth") {
t.Fatalf("expected viper.GetBool(\"skip-auth\") to resolve DATAROBOT_CLI_SKIP_AUTH when SetEnvKeyReplacer is called before AutomaticEnv")
}

if !v.GetBool("skip_auth") {
t.Fatalf("expected viper.GetBool(\"skip_auth\") to resolve DATAROBOT_CLI_SKIP_AUTH when SetEnvKeyReplacer is called before AutomaticEnv")
}
}

func TestViper_AutomaticEnv_NoPrefix(t *testing.T) {
v := viper.New()

v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
v.AutomaticEnv()

key := "SKIP_AUTH"
os.Setenv(key, "true")

t.Cleanup(func() {
_ = os.Unsetenv(key)
})

if !v.GetBool("skip-auth") {
t.Fatalf("expected viper.GetBool(\"skip-auth\") to resolve SKIP_AUTH when no prefix is set")
}

if !v.GetBool("skip_auth") {
t.Fatalf("expected viper.GetBool(\"skip_auth\") to resolve SKIP_AUTH when no prefix is set")
}
}
Loading